diff --git a/.clang-format b/.clang-format index 31db2f468..0d1102725 100644 --- a/.clang-format +++ b/.clang-format @@ -1,7 +1,8 @@ BasedOnStyle: LLVM IndentWidth: 4 ColumnLimit: 120 -UseTab: Never +UseTab: Always +TabWidth: 4 # Disallow single-line statements AllowShortIfStatementsOnASingleLine: false @@ -31,4 +32,5 @@ PointerAlignment: Left AccessModifierOffset: -4 PackConstructorInitializers: Never FixNamespaceComments: true -SortIncludes: true +SortIncludes: false +NamespaceIndentation: All \ No newline at end of file diff --git a/.clangd b/.clangd index eedaea903..b65daaf0c 100644 --- a/.clangd +++ b/.clangd @@ -1,8 +1,8 @@ CompileFlags: - CompilationDatabase: build/ninja-debug + CompilationDatabase: build/windows-clang Add: - -Wall - - -Wextra + - -Wextras Remove: - -fmodules-ts - -fmodule-mapper=* @@ -10,8 +10,9 @@ CompileFlags: - -Winvalid-pch - -Winvalid-offsetof -Index: - Background: Skip Diagnostics: UnusedIncludes: None +Index: + Background: Skip + diff --git a/.gitattributes b/.gitattributes index 8cc1b614d..632178c3d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,28 +1,25 @@ *.glb filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.bin -text -*.gltf -text -*.obj -text -*.fbx -text -*.png -text -*.jpg -text -*.hdr -text -*.hdr filter=lfs diff=lfs merge=lfs -text -*.fbx filter=lfs diff=lfs merge=lfs -text *.gltf filter=lfs diff=lfs merge=lfs -text +*.fbx filter=lfs diff=lfs merge=lfs -text *.obj filter=lfs diff=lfs merge=lfs -text +*.hdr filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text *.jpg filter=lfs diff=lfs merge=lfs -text -*.wav filter=lfs diff=lfs merge=lfs -text -*.mp3 filter=lfs diff=lfs merge=lfs -text -*.ogg filter=lfs diff=lfs merge=lfs -text -*.pack filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text *.tga filter=lfs diff=lfs merge=lfs -text *.bmp filter=lfs diff=lfs merge=lfs -text *.psd filter=lfs diff=lfs merge=lfs -text +*.tif filter=lfs diff=lfs merge=lfs -text +*.tiff filter=lfs diff=lfs merge=lfs -text *.exr filter=lfs diff=lfs merge=lfs -text *.dds filter=lfs diff=lfs merge=lfs -text +*.ktx filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text *.7z filter=lfs diff=lfs merge=lfs -text *.rar filter=lfs diff=lfs merge=lfs -text +*.wav filter=lfs diff=lfs merge=lfs -text +*.mp3 filter=lfs diff=lfs merge=lfs -text +*.ogg filter=lfs diff=lfs merge=lfs -text *.flac filter=lfs diff=lfs merge=lfs -text +*.pack filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 000000000..b794e9b89 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,13 @@ +#!/bin/sh +# Git pre-commit hook: Auto-format staged C++ files using clang-format (excluding thirdparty/) + +set -e + +# Find staged C++ files excluding thirdparty/ +FILES=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(cpp|h|hpp|cc|cxx)$' | grep -v '^thirdparty/' || true) + +if [ -n "$FILES" ]; then + echo "[pre-commit] Formatting staged C++ files with clang-format..." + echo "$FILES" | xargs clang-format -i --style=file + echo "$FILES" | xargs git add +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f10a8750..7c211697a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,31 +2,46 @@ name: CI on: push: - branches: [main, develop] + branches: [main, develop, opengl] paths: - "**/*.cpp" - "**/*.h" + - "**/*.hpp" + - "**/*.c" + - "**/*.inl" - "**/*.cs" - "**/*.csproj" + - "**/*.props" + - "**/*.py" + - "tools/**" + - "resources/shaders/**" - "**/CMakeLists.txt" - "**/CMakePresets.json" - - "cmake/*.cmake" - - ".github/workflows/*.yml" + - "cmake/**" + - ".github/**" pull_request: - branches: [main, develop, chained-gui, refactor-branch] + branches: [main, develop, opengl] + types: [opened, synchronize, reopened, ready_for_review] paths: - "**/*.cpp" - "**/*.h" + - "**/*.hpp" + - "**/*.c" + - "**/*.inl" - "**/*.cs" - "**/*.csproj" + - "**/*.props" + - "**/*.py" + - "tools/**" + - "resources/shaders/**" - "**/CMakeLists.txt" - "**/CMakePresets.json" - - "cmake/*.cmake" - - ".github/workflows/*.yml" + - "cmake/**" + - ".github/**" workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true env: @@ -36,6 +51,10 @@ env: DOTNET_NOLOGO: true jobs: + format: + name: Format Check + uses: ./.github/workflows/format.yml + linux: name: Linux Builds uses: ./.github/workflows/linux.yml @@ -46,14 +65,25 @@ jobs: uses: ./.github/workflows/windows.yml secrets: inherit - managed: - name: Managed Tests - uses: ./.github/workflows/managed.yml - secrets: inherit - - quality-gate: - name: PR Gate + ci-success: + name: CI Passed runs-on: ubuntu-latest - needs: [linux, windows, managed] + needs: [format, linux, windows] + if: always() steps: - - run: echo "Native builds and managed tests passed." \ No newline at end of file + - name: Evaluate CI status + run: | + echo "Format Check: ${{ needs.format.result }}" + echo "Linux Builds: ${{ needs.linux.result }}" + echo "Windows Builds: ${{ needs.windows.result }}" + + if [[ "${{ needs.format.result }}" != "success" ]] || \ + [[ "${{ needs.linux.result }}" != "success" ]] || \ + [[ "${{ needs.windows.result }}" != "success" ]]; then + echo "CI failed: One or more required workflow jobs did not succeed." + exit 1 + fi + + echo "All CI workflow jobs passed successfully!" + + diff --git a/.github/workflows/deploy-sdk.yml b/.github/workflows/deploy-sdk.yml index 8c724fc89..ed2735293 100644 --- a/.github/workflows/deploy-sdk.yml +++ b/.github/workflows/deploy-sdk.yml @@ -4,31 +4,62 @@ on: push: tags: - 'v*' + - 'release/*' + - 'release-*' + branches: + - main + - opengl + - develop workflow_dispatch: + inputs: + tag_name: + description: "Release tag / version name (e.g. release/1.0.0 or v1.0.0)" + required: false + default: "" + type: string + create_release: + description: "Publish as official GitHub Release?" + required: true + default: true + type: boolean + is_draft: + description: "Publish as Draft Release?" + required: false + default: false + type: boolean + is_prerelease: + description: "Mark as Pre-release?" + required: false + default: false + type: boolean + +concurrency: + group: deploy-sdk-${{ github.ref }} + cancel-in-progress: true env: CI: true DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true DOTNET_NOLOGO: true + NODE_OPTIONS: "--no-deprecation" jobs: - deploy: - name: Deploy SDK (${{ matrix.os }}) + build: + name: Build SDK (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] include: - os: ubuntu-latest - name: linux - preset: linux-gcc - config_preset: linux-gcc + platform: linux + preset: linux-clang + config: Release - os: windows-latest - name: windows - preset: windows-gcc - config_preset: windows-gcc + platform: windows + preset: windows-clang + config: Release defaults: run: @@ -38,46 +69,70 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - submodules: recursive + submodules: false + fetch-depth: 1 - - name: Get CMake and Ninja - uses: lukka/get-cmake@latest + - name: Write submodule SHA manifest + run: git submodule status | tee submodule-shas.txt - - name: Setup Compiler Cache - uses: hendrikmuhs/ccache-action@v1.2 + - name: Cache submodules + uses: actions/cache@v4 with: - variant: ${{ matrix.os == 'windows-latest' && 'sccache' || 'ccache' }} - key: ${{ runner.os }}-${{ matrix.config_preset }} - max-size: 5G + path: | + .git/modules + thirdparty + key: submodules-${{ runner.os }}-${{ hashFiles('submodule-shas.txt') }} + restore-keys: | + submodules-${{ runner.os }}- - - name: Get current version/date - id: info - shell: bash - run: | - echo "date=$(date +'%d.%m.%y')" >> $GITHUB_OUTPUT - TAG_VERSION=${GITHUB_REF#refs/tags/v} - echo "version=${TAG_VERSION:-manual}" >> $GITHUB_OUTPUT + - name: Init submodules + run: git submodule update --init --recursive --depth 1 - - name: Setup environment - id: build-env - shell: bash + - name: Setup MSYS2 (Windows) + if: matrix.os == 'windows-latest' + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + update: false + path-type: inherit + install: mingw-w64-x86_64-clang lld mingw-w64-x86_64-lld mingw-w64-x86_64-gcc mingw-w64-x86_64-make + + - name: Prefer MSYS2 toolchain over preinstalled MinGW + if: matrix.os == 'windows-latest' + shell: msys2 {0} run: | - mkdir -p build/${{ matrix.preset }} - mkdir -p package/bin - echo "package-dir=$(pwd)/package" >> $GITHUB_OUTPUT + cygpath -w /mingw64/bin >> "$GITHUB_PATH" - - name: Install dependencies (Ubuntu) + - name: Setup Mold Linker (Linux) + if: matrix.os == 'ubuntu-latest' + uses: rui314/setup-mold@v1 + + - name: Install Linux Dependencies if: matrix.os == 'ubuntu-latest' run: | + set -euo pipefail sudo apt-get update - sudo apt-get install -y build-essential cmake ninja-build ccache \ - libgl1-mesa-dev libx11-dev libxrandr-dev libxinerama-dev \ - libxcursor-dev libxi-dev libasound2-dev libglu1-mesa-dev \ - libwayland-dev libxkbcommon-dev libegl1-mesa-dev \ + sudo apt-get install -y --no-install-recommends \ + build-essential \ + xorg-dev \ + libgl1-mesa-dev libglu1-mesa-dev \ + libasound2-dev \ pkg-config libgtk-3-dev libdrm-dev libgbm-dev \ - xvfb libxkbcommon-x11-0 gcc-14 g++-14 - echo "CC=gcc-14" >> $GITHUB_ENV - echo "CXX=g++-14" >> $GITHUB_ENV + xvfb libxkbcommon-x11-0 libgl1-mesa-dri mesa-utils \ + clang-18 clang++-18 libc++-18-dev libc++abi-18-dev lld-18 llvm-18 + echo "CC=clang-18" >> $GITHUB_ENV + echo "CXX=clang++-18" >> $GITHUB_ENV + echo "LDFLAGS=-fuse-ld=mold" >> $GITHUB_ENV + + - name: Get CMake and Ninja + uses: lukka/get-cmake@latest + + - name: Setup Compiler Cache + uses: hendrikmuhs/ccache-action@v1.2 + with: + variant: ${{ matrix.os == 'windows-latest' && 'sccache' || 'ccache' }} + key: SDK-${{ matrix.preset }}-${{ matrix.config }} + max-size: 5G - name: Setup Python uses: actions/setup-python@v5 @@ -90,106 +145,233 @@ jobs: python -m pip install --upgrade pip python -m pip install jinja2 - - name: Setup MSYS2 - if: matrix.os == 'windows-latest' - uses: msys2/setup-msys2@v2 - with: - msystem: MINGW64 - update: true - install: >- - mingw-w64-x86_64-gcc - - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - - name: Run managed progression tests + - name: Get current version/date + id: info shell: bash run: | - dotnet test ./game/chaineddecos/scripts/tests/ChainedDecos.Scripts.Tests.csproj \ - -c Release \ - --logger "trx;LogFileName=deploy-managed-tests-${{ matrix.name }}.trx" - - - name: Upload managed test report - if: always() - uses: actions/upload-artifact@v4 - with: - name: deploy-managed-tests-${{ matrix.name }} - path: "**/deploy-managed-tests-${{ matrix.name }}.trx" - retention-days: 7 + echo "date=$(date +'%d.%m.%y')" >> $GITHUB_OUTPUT + if [[ -n "${{ inputs.tag_name }}" ]]; then + RAW_TAG="${{ inputs.tag_name }}" + TAG_VERSION="${RAW_TAG#release/}" + TAG_VERSION="${TAG_VERSION#release-}" + TAG_VERSION="${TAG_VERSION#v}" + elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then + RAW_TAG="${GITHUB_REF#refs/tags/}" + TAG_VERSION="${RAW_TAG#release/}" + TAG_VERSION="${TAG_VERSION#release-}" + TAG_VERSION="${TAG_VERSION#v}" + else + BRANCH_NAME="${GITHUB_REF#refs/heads/}" + CLEAN_BRANCH="${BRANCH_NAME//\//-}" + SHORT_SHA="${GITHUB_SHA:0:7}" + TAG_VERSION="${CLEAN_BRANCH}-${SHORT_SHA}" + RAW_TAG="v${TAG_VERSION}" + fi + echo "raw_tag=${RAW_TAG}" >> $GITHUB_OUTPUT + echo "version=${TAG_VERSION}" >> $GITHUB_OUTPUT - name: Configure CMake run: | set -euo pipefail - PYTHON_EXE=$(python -c "import sys; print(sys.executable)") - extra_flags="" + PYTHON_EXE=$(python3 -c "import sys; print(sys.executable)" 2>/dev/null || python -c "import sys; print(sys.executable)") launcher="sccache" if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then - extra_flags="-DCMAKE_C_COMPILER=$CC -DCMAKE_CXX_COMPILER=$CXX" launcher="ccache" fi + mkdir -p "build/${{ matrix.preset }}" cmake --preset ${{ matrix.preset }} \ - -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_C_COMPILER_LAUNCHER=$launcher \ -DCMAKE_CXX_COMPILER_LAUNCHER=$launcher \ - -DCMAKE_INSTALL_PREFIX="${{ steps.build-env.outputs.package-dir }}" \ -DPython_EXECUTABLE="${PYTHON_EXE}" \ -DPython3_EXECUTABLE="${PYTHON_EXE}" \ -DCH_CI=ON \ - -DENABLE_UNITY_BUILD=ON \ + -DENABLE_UNITY_BUILD=OFF \ -DENABLE_PCH=OFF \ -DBUILD_TESTS=OFF \ - $extra_flags + 2>&1 | tee "build/${{ matrix.preset }}/configure.log" - name: Build binaries run: | set -euo pipefail - cmake --build --preset ${{ matrix.preset }} \ - --target ChainedEditor ChainedRuntime \ - --parallel $(nproc 2>/dev/null || echo 8) \ - 2>&1 | tee "build/${{ matrix.preset }}/build.log" + cmake --build "build/${{ matrix.preset }}" --config ${{ matrix.config }} --parallel \ + 2>&1 | tee "build/${{ matrix.preset }}/build_${{ matrix.config }}.log" - - name: Install to package directory - shell: bash + - name: Sync resources + run: | + set -euo pipefail + python tools/sync_resources.py --root . --bin "build/${{ matrix.preset }}/bin" --config ${{ matrix.config }} + + - name: Package with CPack run: | - cmake --install build/${{ matrix.preset }} \ - --component Runtime + set -euo pipefail + cd "build/${{ matrix.preset }}" + cpack -C ${{ matrix.config }} || true - - name: Package SDK + - name: Package all archives + id: package shell: bash run: | - cd "${{ steps.build-env.outputs.package-dir }}" - # Exact naming from screenshot: Chained Engine(Chained Decos) SDK DD.MM.YY - PACKAGE_NAME="Chained Engine(Chained Decos) SDK ${{ steps.info.outputs.date }}" - echo "Creating SDK package: $PACKAGE_NAME" - - if [[ "${{ matrix.os }}" == "windows-latest" ]]; then - powershell -Command " - Compress-Archive -Path '*' -DestinationPath '${{ steps.build-env.outputs.package-dir }}/$PACKAGE_NAME.zip' -Force - " - echo "p-file=$PACKAGE_NAME.zip" >> $GITHUB_ENV + set -euo pipefail + VERSION="${{ steps.info.outputs.version }}" + DATE="${{ steps.info.outputs.date }}" + PLATFORM="${{ matrix.platform }}" + IS_WINDOWS="${{ matrix.os == 'windows-latest' }}" + + BIN_DIR="${{ github.workspace }}/build/${{ matrix.preset }}/bin/${{ matrix.config }}" + if [[ ! -d "$BIN_DIR" ]]; then + BIN_DIR="${{ github.workspace }}/build/${{ matrix.preset }}/bin" + fi + + make_archive() { + local NAME="$1" + local SRC_DIR="$2" + local ARCHIVE_NAME="${NAME}-${VERSION}-${DATE}-${PLATFORM}" + local DEST="${{ github.workspace }}/${ARCHIVE_NAME}" + cd "$SRC_DIR" + if [[ "$IS_WINDOWS" == "true" ]]; then + powershell -Command "Compress-Archive -Path * -DestinationPath '${DEST}.zip' -Force" + echo "${ARCHIVE_NAME}.zip" + else + tar -czf "${DEST}.tar.gz" . + echo "${ARCHIVE_NAME}.tar.gz" + fi + } + + # --- SDK (editor + game + assets + resources) --- + SDK_DIR="${{ github.workspace }}/package-sdk" + mkdir -p "$SDK_DIR" + cp -r "$BIN_DIR"/* "$SDK_DIR/" + if [[ -d "${{ github.workspace }}/assets" ]]; then + cp -r "${{ github.workspace }}/assets" "$SDK_DIR/" + fi + SDK_ARCHIVE=$(make_archive "ChainedEngine-SDK" "$SDK_DIR") + echo "sdk_archive=$SDK_ARCHIVE" >> $GITHUB_OUTPUT + + # --- Game (ChainedDecos + assets + resources) --- + GAME_DIR="${{ github.workspace }}/package-game" + mkdir -p "$GAME_DIR" + cp "$BIN_DIR/ChainedDecos" "$GAME_DIR/" 2>/dev/null || true + cp "$BIN_DIR/ChainedDecos.exe" "$GAME_DIR/" 2>/dev/null || true + if [[ -d "${{ github.workspace }}/assets" ]]; then + cp -r "${{ github.workspace }}/assets" "$GAME_DIR/" + fi + GAME_ARCHIVE=$(make_archive "ChainedDecos-Game" "$GAME_DIR") + echo "game_archive=$GAME_ARCHIVE" >> $GITHUB_OUTPUT + + # --- Editor (ChainedEditor only) --- + EDITOR_DIR="${{ github.workspace }}/package-editor" + mkdir -p "$EDITOR_DIR" + cp "$BIN_DIR/ChainedEditor" "$EDITOR_DIR/" 2>/dev/null || true + cp "$BIN_DIR/ChainedEditor.exe" "$EDITOR_DIR/" 2>/dev/null || true + EDITOR_ARCHIVE=$(make_archive "ChainedEditor" "$EDITOR_DIR") + echo "editor_archive=$EDITOR_ARCHIVE" >> $GITHUB_OUTPUT + + - name: Upload CPack Packages + uses: actions/upload-artifact@v4 + with: + name: CPack-Packages-${{ matrix.platform }} + path: | + build/${{ matrix.preset }}/*.zip + build/${{ matrix.preset }}/*.tar.gz + build/${{ matrix.preset }}/*.deb + build/${{ matrix.preset }}/*.exe + if-no-files-found: ignore + retention-days: 30 + + - name: Upload SDK artifact + uses: actions/upload-artifact@v4 + with: + name: SDK-${{ matrix.platform }} + path: ${{ github.workspace }}/${{ steps.package.outputs.sdk_archive }} + retention-days: 30 + + - name: Upload Game artifact + uses: actions/upload-artifact@v4 + with: + name: Game-${{ matrix.platform }} + path: ${{ github.workspace }}/${{ steps.package.outputs.game_archive }} + retention-days: 30 + + - name: Upload Editor artifact + uses: actions/upload-artifact@v4 + with: + name: Editor-${{ matrix.platform }} + path: ${{ github.workspace }}/${{ steps.package.outputs.editor_archive }} + retention-days: 30 + + release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: build + if: >- + (github.event_name == 'workflow_dispatch' && (inputs.create_release == true || inputs.create_release == 'true')) || + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get release info + id: info + run: | + echo "date=$(date +'%d.%m.%y')" >> $GITHUB_OUTPUT + if [[ -n "${{ inputs.tag_name }}" ]]; then + RAW_TAG="${{ inputs.tag_name }}" + TAG_VERSION="${RAW_TAG#release/}" + TAG_VERSION="${TAG_VERSION#release-}" + TAG_VERSION="${TAG_VERSION#v}" + elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then + RAW_TAG="${GITHUB_REF#refs/tags/}" + TAG_VERSION="${RAW_TAG#release/}" + TAG_VERSION="${TAG_VERSION#release-}" + TAG_VERSION="${TAG_VERSION#v}" else - tar -czf "$PACKAGE_NAME.tar.gz" . - echo "p-file=$PACKAGE_NAME.tar.gz" >> $GITHUB_ENV + BRANCH_NAME="${GITHUB_REF#refs/heads/}" + CLEAN_BRANCH="${BRANCH_NAME//\//-}" + SHORT_SHA="${GITHUB_SHA:0:7}" + TAG_VERSION="${CLEAN_BRANCH}-${SHORT_SHA}" + RAW_TAG="v${TAG_VERSION}" fi + echo "raw_tag=${RAW_TAG}" >> $GITHUB_OUTPUT + echo "version=${TAG_VERSION}" >> $GITHUB_OUTPUT + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: release-assets + pattern: "{SDK,Game,Editor,CPack}-*" + merge-multiple: true - name: Create Release - if: matrix.os == 'ubuntu-latest' uses: softprops/action-gh-release@v2 with: - name: "Chained Engine(Chained Decos) SDK ${{ steps.info.outputs.date }}" - tag_name: "release-sdk-${{ steps.info.outputs.date }}" - files: | - package/*.zip - package/*.tar.gz + name: "Chained Engine v${{ steps.info.outputs.version }}" + tag_name: "${{ steps.info.outputs.raw_tag }}" + files: release-assets/* body: | - ### Chained Engine SDK Update - - **Version**: ${{ steps.info.outputs.version }} + ### Chained Engine v${{ steps.info.outputs.version }} - **Build Date**: ${{ steps.info.outputs.date }} - - **Architecture**: ${{steps.build-env}} - draft: false - prerelease: false + - **Platforms**: Linux (clang), Windows (clang) + + #### Assets + - **ChainedEngine-SDK** — full SDK (editor + game + engine) + - **ChainedDecos-Game** — game only + - **ChainedEditor** — editor only + - **CPack Packages** — standalone ZIP, NSIS Windows installer, DEB, tar.gz packages + draft: ${{ inputs.is_draft || false }} + prerelease: ${{ inputs.is_prerelease || false }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 000000000..e4f43c407 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,53 @@ +name: Format + +on: + workflow_call: + +jobs: + clang-format: + name: clang-format check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + fetch-depth: 0 + + - name: Install clang-format-18 + run: | + sudo apt-get update + sudo apt-get install -y clang-format-18 + + - name: Determine changed C++ files + id: changed + run: | + set -euo pipefail + BASE_REF="" + if [[ -n "${{ github.base_ref }}" ]] && git rev-parse --verify "origin/${{ github.base_ref }}" >/dev/null 2>&1; then + BASE_REF="origin/${{ github.base_ref }}" + elif [[ -n "${{ github.event.pull_request.base.sha }}" ]] && git rev-parse --verify "${{ github.event.pull_request.base.sha }}" >/dev/null 2>&1; then + BASE_REF="${{ github.event.pull_request.base.sha }}" + else + BASE_REF="HEAD~1" + fi + + if git rev-parse --verify "$BASE_REF" >/dev/null 2>&1; then + FILES=$(git diff --name-only --diff-filter=ACMR "$BASE_REF"...HEAD -- '*.cpp' '*.h' '*.hpp' '*.cc' '*.cxx' '*.inl' '*.c' | grep -v -E '^(thirdparty/|engine/scripting/generated/|build/)' || true) + else + FILES=$(git diff --name-only --diff-filter=ACMR HEAD~1 -- '*.cpp' '*.h' '*.hpp' '*.cc' '*.cxx' '*.inl' '*.c' | grep -v -E '^(thirdparty/|engine/scripting/generated/|build/)' || true) + fi + echo "files<> "$GITHUB_OUTPUT" + echo "$FILES" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Run clang-format --dry-run + if: steps.changed.outputs.files != '' + run: | + set -euo pipefail + echo "${{ steps.changed.outputs.files }}" | xargs -r clang-format-18 --dry-run --Werror -style=file + + - name: No changed C++ files + if: steps.changed.outputs.files == '' + run: echo "No C++ files changed — skipping format check." + diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index bba3f95ae..a22f05dbc 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -8,10 +8,11 @@ env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true DOTNET_NOLOGO: true + NODE_OPTIONS: "--no-deprecation" jobs: build-linux: - name: ubuntu-latest / ${{ matrix.preset }} (${{ matrix.build_type }}) + name: build (ubuntu-latest, ${{ matrix.build_type }}, ${{ matrix.toolchain.compiler }}) runs-on: ubuntu-latest defaults: run: @@ -19,16 +20,34 @@ jobs: strategy: fail-fast: false matrix: - preset: [linux-gcc, linux-clang] build_type: [Debug, Release] + toolchain: + - { preset: linux-clang, compiler: clang } + - { preset: linux-gcc, compiler: gcc } steps: - name: Checkout uses: actions/checkout@v4 with: - submodules: recursive + submodules: false fetch-depth: 1 + - name: Write submodule SHA manifest + run: git submodule status | tee submodule-shas.txt + + - name: Cache submodules + uses: actions/cache@v4 + with: + path: | + .git/modules + thirdparty + key: submodules-${{ runner.os }}-${{ hashFiles('submodule-shas.txt') }} + restore-keys: | + submodules-${{ runner.os }}- + + - name: Init submodules + run: git submodule update --init --recursive --depth 1 + - name: Get CMake and Ninja uses: lukka/get-cmake@latest @@ -36,7 +55,7 @@ jobs: uses: hendrikmuhs/ccache-action@v1.2 with: variant: ccache - key: Linux-${{ matrix.preset }}-${{ matrix.build_type }} + key: Linux-${{ matrix.toolchain.preset }}-${{ matrix.build_type }} max-size: 5G - name: Setup Mold Linker @@ -48,49 +67,51 @@ jobs: python3 -m pip install jinja2 - name: Install Linux Dependencies - uses: awalsh128/cache-apt-pkgs-action@latest - with: - packages: >- - build-essential - libgl1-mesa-dev libx11-dev libxrandr-dev libxinerama-dev - libxcursor-dev libxi-dev libasound2-dev libglu1-mesa-dev - pkg-config libgtk-3-dev libdrm-dev libgbm-dev + run: | + set -euo pipefail + sudo apt-get update + # xorg-dev is the GLFW-recommended meta-package: it pulls the full set of + # X11 development headers/libs (libx11, xrandr, xinerama, xcursor, xi, xext, + # and the X protocol headers) that find_package(X11) needs. Installing it + # directly (rather than via a cache-apt action) avoids cache-restore gaps + # where a package is "installed" per the manifest but its files are absent. + sudo apt-get install -y --no-install-recommends \ + build-essential \ + xorg-dev \ + libgl1-mesa-dev libglu1-mesa-dev \ + libasound2-dev \ + pkg-config libgtk-3-dev libdrm-dev libgbm-dev \ xvfb libxkbcommon-x11-0 libgl1-mesa-dri mesa-utils - version: 1.0 - name: Setup Linux Compilers run: | - if [[ "${{ matrix.preset }}" == *"gcc"* ]]; then - sudo apt-get install -y gcc-14 g++-14 - echo "CC=gcc-14" >> $GITHUB_ENV - echo "CXX=g++-14" >> $GITHUB_ENV - elif [[ "${{ matrix.preset }}" == *"clang"* ]]; then + set -euo pipefail + if [[ "${{ matrix.toolchain.compiler }}" == "clang" ]]; then sudo apt-get install -y clang-18 clang++-18 libc++-18-dev libc++abi-18-dev lld-18 llvm-18 echo "CC=clang-18" >> $GITHUB_ENV echo "CXX=clang++-18" >> $GITHUB_ENV - echo "LDFLAGS=-fuse-ld=mold" >> $GITHUB_ENV + else + sudo apt-get install -y gcc-13 g++-13 + echo "CC=gcc-13" >> $GITHUB_ENV + echo "CXX=g++-13" >> $GITHUB_ENV fi + echo "LDFLAGS=-fuse-ld=mold" >> $GITHUB_ENV - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Preflight run: | set -euo pipefail - echo "Repository root: $PWD" - git submodule status --recursive cmake --version dotnet --info - echo "CC=${CC:-unset}" - echo "CXX=${CXX:-unset}" if [[ -n "${CC:-}" ]] && command -v "$CC" >/dev/null 2>&1; then "$CC" --version | head -n 1 fi - if [[ -n "${CXX:-}" ]] && command -v "$CXX" >/dev/null 2>&1; then - "$CXX" --version | head -n 1 - fi - name: Configure run: | @@ -100,46 +121,59 @@ jobs: SANITIZERS_FLAG="ON" fi - mkdir -p "build/${{ matrix.preset }}" + mkdir -p "build/${{ matrix.toolchain.preset }}" PYTHON_EXE=$(python3 -c "import sys; print(sys.executable)") - cmake --preset ${{ matrix.preset }} \ - -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ + + cmake --preset ${{ matrix.toolchain.preset }} \ -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/install" \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ -DPython_EXECUTABLE="${PYTHON_EXE}" \ -DPython3_EXECUTABLE="${PYTHON_EXE}" \ -DCH_CI=ON \ - -DENABLE_UNITY_BUILD=ON \ + -DENABLE_UNITY_BUILD=OFF \ -DENABLE_SANITIZERS=$SANITIZERS_FLAG \ -DENABLE_LTO=OFF \ -DENABLE_PCH=OFF \ - 2>&1 | tee "build/${{ matrix.preset }}/configure.log" + 2>&1 | tee "build/${{ matrix.toolchain.preset }}/configure.log" - name: Build run: | set -euo pipefail - cmake --build --preset ${{ matrix.preset }} \ - 2>&1 | tee "build/${{ matrix.preset }}/build.log" + cmake --build "build/${{ matrix.toolchain.preset }}" --config ${{ matrix.build_type }} \ + 2>&1 | tee "build/${{ matrix.toolchain.preset }}/build_${{ matrix.build_type }}.log" - name: Test run: | set -euo pipefail - BUILD_DIR="${{ github.workspace }}/build/${{ matrix.preset }}" + BUILD_DIR="${{ github.workspace }}/build/${{ matrix.toolchain.preset }}" export LIBGL_ALWAYS_SOFTWARE=1 export GALLIUM_DRIVER=llvmpipe export MESA_GL_VERSION_OVERRIDE=4.5 export LSAN_OPTIONS="suppressions=${{ github.workspace }}/.github/lsan.supp" - xvfb-run -a ctest --test-dir "$BUILD_DIR" -C "${{ matrix.build_type }}" --output-on-failure --timeout 300 --output-junit "$BUILD_DIR/junit.xml" \ - 2>&1 | tee "$BUILD_DIR/ctest.log" + + xvfb-run -a ctest --test-dir "$BUILD_DIR" -C "${{ matrix.build_type }}" --output-on-failure --timeout 300 --output-junit "$BUILD_DIR/junit_${{ matrix.build_type }}.xml" \ + 2>&1 | tee "$BUILD_DIR/ctest_${{ matrix.build_type }}.log" - name: Upload Artifacts if: always() uses: actions/upload-artifact@v4 with: - name: Linux-${{ matrix.preset }}-${{ matrix.build_type }}-binaries + name: Linux-${{ matrix.toolchain.preset }}-${{ matrix.build_type }}-binaries + if-no-files-found: ignore + path: | + build/${{ matrix.toolchain.preset }}/**/*.log + build/${{ matrix.toolchain.preset }}/**/junit_*.xml + retention-days: 7 + + - name: Upload Release Binaries + if: matrix.build_type == 'Release' + uses: actions/upload-artifact@v4 + with: + name: Linux-${{ matrix.toolchain.preset }}-Release-executables if-no-files-found: ignore path: | - build/${{ matrix.preset }}/**/*.log - build/${{ matrix.preset }}/junit.xml + build/${{ matrix.toolchain.preset }}/bin/Release/ChainedEditor + build/${{ matrix.toolchain.preset }}/bin/Release/ChainedDecos + build/${{ matrix.toolchain.preset }}/bin/Release/*.so retention-days: 7 \ No newline at end of file diff --git a/.github/workflows/managed.yml b/.github/workflows/managed.yml deleted file mode 100644 index c475eded3..000000000 --- a/.github/workflows/managed.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Managed - -on: - workflow_call: - -env: - CI: true - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true - DOTNET_CLI_TELEMETRY_OPTOUT: true - DOTNET_NOLOGO: true - -jobs: - managed-tests: - name: Managed Scripts Tests - runs-on: ubuntu-latest - defaults: - run: - shell: bash - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - submodules: recursive - fetch-depth: 1 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '9.0.x' - - - name: Preflight - run: | - set -euo pipefail - echo "Repository root: $PWD" - git submodule status --recursive - dotnet --info - - - name: Restore managed tests - run: | - set -euo pipefail - dotnet restore ./game/chaineddecos/scripts/tests/ChainedDecos.Scripts.Tests.csproj \ - 2>&1 | tee managed-tests-restore.log - - - name: Run managed tests - run: | - set -euo pipefail - dotnet test ./game/chaineddecos/scripts/tests/ChainedDecos.Scripts.Tests.csproj -c Release --no-restore --logger "trx;LogFileName=managed-tests.trx" \ - 2>&1 | tee managed-tests.log - - - name: Upload managed test report - if: always() - uses: actions/upload-artifact@v4 - with: - name: managed-tests-report - if-no-files-found: ignore - path: | - managed-tests-restore.log - managed-tests.log - **/managed-tests.trx - retention-days: 7 \ No newline at end of file diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index b10e2f8ec..e17b28336 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -8,10 +8,11 @@ env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true DOTNET_NOLOGO: true + NODE_OPTIONS: "--no-deprecation" jobs: build-windows: - name: windows-latest / ${{ matrix.preset }} (${{ matrix.build_type }}) + name: build (windows-latest, ${{ matrix.build_type }}, ${{ matrix.compiler }}) runs-on: windows-latest defaults: run: @@ -19,28 +20,94 @@ jobs: strategy: fail-fast: false matrix: - preset: [windows-gcc, windows-clang, windows-msvc] - build_type: [Debug, Release] + include: + - preset: windows-clang + compiler: clang + build_type: Release + - preset: windows-clang + compiler: clang + build_type: Debug + - preset: windows-vs2026 + compiler: vs2026 + build_type: Release + - preset: windows-vs2026 + compiler: vs2026 + build_type: Debug + - preset: windows-gcc + compiler: gcc + build_type: Release + - preset: windows-gcc + compiler: gcc + build_type: Debug + steps: - name: Checkout uses: actions/checkout@v4 with: - submodules: recursive + submodules: false fetch-depth: 1 + - name: Write submodule SHA manifest + run: git submodule status | tee submodule-shas.txt + + - name: Cache submodules + uses: actions/cache@v4 + with: + path: | + .git/modules + thirdparty + key: submodules-${{ runner.os }}-${{ hashFiles('submodule-shas.txt') }} + restore-keys: | + submodules-${{ runner.os }}- + + - name: Init submodules + run: git submodule update --init --recursive --depth 1 + - name: Setup MSYS2 - if: contains(matrix.preset, 'gcc') || contains(matrix.preset, 'clang') + if: "contains(matrix.preset, 'clang') || contains(matrix.preset, 'gcc')" uses: msys2/setup-msys2@v2 with: msystem: MINGW64 update: false - install: ${{ contains(matrix.preset, 'clang') && 'mingw-w64-x86_64-clang lld' || 'mingw-w64-x86_64-gcc' }} + path-type: inherit + install: mingw-w64-x86_64-clang lld mingw-w64-x86_64-lld mingw-w64-x86_64-gcc mingw-w64-x86_64-make mingw-w64-x86_64-gdb - name: Setup MSVC - if: contains(matrix.preset, 'msvc') + if: "contains(matrix.preset, 'msvc') || contains(matrix.preset, 'vs')" uses: ilammy/msvc-dev-cmd@v1 + - name: Setup lld-link (MSVC only) + if: matrix.use_lld == true + shell: pwsh + run: | + # LLVM is pre-installed on windows-latest; find lld-link and put it on PATH. + # Prefer the LLVM install that ships with VS, fall back to standalone LLVM. + $candidates = @( + "C:\Program Files\LLVM\bin", + "C:\Program Files (x86)\LLVM\bin" + ) + $llvmBin = $candidates | Where-Object { Test-Path "$_\lld-link.exe" } | Select-Object -First 1 + if (-not $llvmBin) { + # Standalone LLVM not found — install via winget (fast, no choco overhead) + choco install llvm --no-progress -y | Out-Null + $llvmBin = "C:\Program Files\LLVM\bin" + } + Write-Host "Using lld-link from: $llvmBin" + "$llvmBin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + lld-link --version + + - name: Prefer MSYS2 toolchain over preinstalled MinGW + if: "contains(matrix.preset, 'clang') || contains(matrix.preset, 'gcc')" + shell: msys2 {0} + run: | + # The windows-latest image preinstalls a MinGW-Builds GCC at C:\mingw64 + # which shadows the MSYS2 toolchain installed above (jobs run in Git + # Bash, not the msys2 shell). That build's libstdc++ segfaults in + # std::format Debug code, and it is NOT the toolchain anyone tests + # against locally. Put MSYS2's mingw64/bin first on PATH. + cygpath -w /mingw64/bin >> "$GITHUB_PATH" + - name: Get CMake and Ninja uses: lukka/get-cmake@latest @@ -48,7 +115,7 @@ jobs: uses: hendrikmuhs/ccache-action@v1.2 with: variant: sccache - key: Windows-${{ matrix.preset }}-${{ matrix.build_type }} + key: Windows-${{ matrix.preset }}-${{ matrix.build_type }}${{ matrix.use_lld == true && '-lld' || '' }} max-size: 5G - name: Setup Python @@ -65,54 +132,83 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Preflight run: | set -euo pipefail - echo "Repository root: $PWD" - git submodule status --recursive cmake --version dotnet --info if [[ "${{ matrix.preset }}" == *"clang"* ]]; then clang --version | head -n 1 - elif [[ "${{ matrix.preset }}" == *"msvc"* ]]; then + elif [[ "${{ matrix.preset }}" == *"msvc"* || "${{ matrix.preset }}" == *"vs"* ]]; then cl || true - else + elif [[ "${{ matrix.preset }}" == *"gcc"* ]]; then gcc --version | head -n 1 fi - name: Configure run: | set -euo pipefail + SANITIZERS_FLAG="OFF" + # Disable ASan on Windows Clang due to static CRT conflicts (libucrtd.lib vs clang_rt.asan) mkdir -p "build/${{ matrix.preset }}" PYTHON_EXE=$(python -c "import sys; print(sys.executable)") + + LLD_FLAG="" + if [[ "${{ matrix.use_lld }}" == "true" ]]; then + LLD_FLAG="-DCMAKE_LINKER_TYPE=LLD" + fi + cmake --preset ${{ matrix.preset }} \ - -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/install" \ -DCMAKE_C_COMPILER_LAUNCHER=sccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=sccache \ -DPython_EXECUTABLE="${PYTHON_EXE}" \ -DPython3_EXECUTABLE="${PYTHON_EXE}" \ -DCH_CI=ON \ - -DENABLE_UNITY_BUILD=ON \ - -DENABLE_SANITIZERS=OFF \ + -DENABLE_UNITY_BUILD=OFF \ + -DENABLE_SANITIZERS=$SANITIZERS_FLAG \ -DENABLE_LTO=OFF \ -DENABLE_PCH=OFF \ + ${LLD_FLAG} \ 2>&1 | tee "build/${{ matrix.preset }}/configure.log" - name: Build run: | set -euo pipefail - cmake --build --preset ${{ matrix.preset }} \ - 2>&1 | tee "build/${{ matrix.preset }}/build.log" + cmake --build "build/${{ matrix.preset }}" --config ${{ matrix.build_type }} --parallel \ + 2>&1 | tee "build/${{ matrix.preset }}/build_${{ matrix.build_type }}.log" - name: Test + env: + COREHOST_TRACE: 1 run: | set -euo pipefail BUILD_DIR="${{ github.workspace }}/build/${{ matrix.preset }}" - ctest --test-dir "$BUILD_DIR" -C "${{ matrix.build_type }}" --output-on-failure --timeout 300 --output-junit "$BUILD_DIR/junit.xml" \ - 2>&1 | tee "$BUILD_DIR/ctest.log" + ctest --test-dir "$BUILD_DIR" -C "${{ matrix.build_type }}" --output-on-failure --timeout 300 --output-junit "$BUILD_DIR/junit_${{ matrix.build_type }}.xml" \ + 2>&1 | tee "$BUILD_DIR/ctest_${{ matrix.build_type }}.log" + + - name: Crash backtrace (gcc Debug only) + if: failure() && matrix.compiler == 'gcc' && matrix.build_type == 'Debug' + env: + COREHOST_TRACE: 1 + ENGINE_ROOT: ${{ github.workspace }} + run: | + # The integration tests crash with an access violation on this runner + # but pass locally with the identical build config. Run one failing + # test directly under gdb to capture the faulting module and stack. + BIN_DIR="build/${{ matrix.preset }}/bin/${{ matrix.build_type }}" + cd "$BIN_DIR" + gdb -batch -return-child-result \ + -ex "set pagination off" \ + -ex run \ + -ex "bt" \ + -ex "info registers" \ + -ex "info sharedlibrary" \ + --args ./engine_tests_integration.exe --gtest_filter=RegressionTest.CopyEntityResetsUUID || true - name: Upload Artifacts if: always() @@ -122,5 +218,7 @@ jobs: if-no-files-found: ignore path: | build/${{ matrix.preset }}/**/*.log - build/${{ matrix.preset }}/junit.xml + build/${{ matrix.preset }}/**/junit_*.xml + build/${{ matrix.preset }}/${{ matrix.build_type }}/*.exe + build/${{ matrix.preset }}/${{ matrix.build_type }}/*.dll retention-days: 7 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 63e70b529..8bf290e56 100644 --- a/.gitignore +++ b/.gitignore @@ -58,7 +58,6 @@ include/*-build/ build/ cmake-build-*/ -.cmake .cache .zencoder .idea/ @@ -86,3 +85,32 @@ engine/script/managed/ManagedDependencies.props *.log build_error.txt + +# Engine Cache +*.chcache +*.chasset +*.pdf + +.opencode +.claude +.zcode +AGENTS.md +CLAUDE.md +# CMake install directory +install/ + +# Deploy packaging +package/ + +# Test results +*.trx + +# Zip archives (SDK builds) +*.zip +*.tar.gz + +exported + +nul + +.serena diff --git a/.gitmodules b/.gitmodules index d86dea438..6de4e97e9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,65 +1,102 @@ +[submodule "thirdparty/imgui"] + path = thirdparty/imgui + url = https://github.com/ocornut/imgui.git + branch = master -[submodule "include/imgui"] - path = include/imgui - url = https://github.com/ocornut/imgui.git - branch = master +[submodule "thirdparty/nfd"] + path = thirdparty/nfd + url = https://github.com/btzy/nativefiledialog-extended.git + branch = master -[submodule "include/nfd"] - path = include/nfd - url = https://github.com/btzy/nativefiledialog-extended.git - branch = master +[submodule "thirdparty/googletest"] + path = thirdparty/googletest + url = https://github.com/google/googletest.git + branch = main -[submodule "include/googletest"] - path = include/googletest - url = https://github.com/google/googletest.git - branch = main +[submodule "thirdparty/JoltPhysics"] + path = thirdparty/JoltPhysics + url = https://github.com/jrouwe/JoltPhysics.git + branch = master -[submodule "include/entt"] - path = include/entt - url = https://github.com/skypjack/entt.git - branch = master +[submodule "thirdparty/entt"] + path = thirdparty/entt + url = https://github.com/skypjack/entt.git + branch = master -[submodule "include/yaml-cpp"] - path = include/yaml-cpp - url = https://github.com/jbeder/yaml-cpp.git +[submodule "thirdparty/yaml-cpp"] + path = thirdparty/yaml-cpp + url = https://github.com/jbeder/yaml-cpp.git -[submodule "include/imguizmo"] - path = include/imguizmo - url = https://github.com/CedricGuillemet/ImGuizmo.git +[submodule "thirdparty/imguizmo"] + path = thirdparty/imguizmo + url = https://github.com/CedricGuillemet/ImGuizmo.git -[submodule "include/glm"] - path = include/glm - url = https://github.com/g-truc/glm.git +[submodule "thirdparty/glm"] + path = thirdparty/glm + url = https://github.com/g-truc/glm.git -[submodule "include/assimp"] - path = include/assimp - url = https://github.com/assimp/assimp.git +[submodule "thirdparty/assimp"] + path = thirdparty/assimp + url = https://github.com/assimp/assimp.git -[submodule "include/coral"] - path = include/coral - url = https://github.com/StudioCherno/Coral.git +[submodule "thirdparty/coral"] + path = thirdparty/coral + url = https://github.com/StudioCherno/Coral.git -[submodule "include/enet"] - path = include/enet +[submodule "thirdparty/miniaudio"] + path = thirdparty/miniaudio + url = https://github.com/mackron/miniaudio.git + +[submodule "thirdparty/stb"] + path = thirdparty/stb + url = https://github.com/nothings/stb.git + +[submodule "thirdparty/glad"] + path = thirdparty/glad + url = https://github.com/Dav1dde/glad.git + branch = glad2 + +[submodule "thirdparty/glfw"] + path = thirdparty/glfw + url = https://github.com/glfw/glfw.git + +[submodule "thirdparty/cereal"] + path = thirdparty/cereal + url = https://github.com/USCiLab/cereal.git + +[submodule "thirdparty/zstd"] + path = thirdparty/zstd + url = https://github.com/facebook/zstd.git + +[submodule "thirdparty/spdlog"] + path = thirdparty/spdlog + url = https://github.com/gabime/spdlog.git + +[submodule "thirdparty/reflect-cpp"] + path = thirdparty/reflect-cpp + url = https://github.com/getml/reflect-cpp.git + +[submodule "thirdparty/portable-file-dialogs"] + path = thirdparty/portable-file-dialogs + url = https://github.com/samhocevar/portable-file-dialogs.git +[submodule "cmake/external/thirdparty/portable-file-dialogs"] + path = cmake/external/thirdparty/portable-file-dialogs + url = https://github.com/samhocevar/portable-file-dialogs.git + +[submodule "thirdparty/pack"] + path = thirdparty/pack + url = https://github.com/cfnptr/pack.git + +[submodule "thirdparty/miniupnp"] + path = thirdparty/miniupnp + url = https://github.com/miniupnp/miniupnp.git + +[submodule "thirdparty/enet"] + path = thirdparty/enet url = https://github.com/zpl-c/enet.git - branch = master - -[submodule "include/GameNetworkingSockets"] - path = include/GameNetworkingSockets - url = https://github.com/ValveSoftware/GameNetworkingSockets.git - branch = master - -[submodule "include/miniaudio"] - path = include/miniaudio - url = https://github.com/mackron/miniaudio.git - -[submodule "include/stb"] - path = include/stb - url = https://github.com/nothings/stb.git -[submodule "include/glad"] - path = include/glad - url = https://github.com/Dav1dde/glad.git - branch = glad2 -[submodule "include/glfw"] - path = include/glfw - url = https://github.com/glfw/glfw.git +[submodule "thirdparty/freetype"] + path = thirdparty/freetype + url = https://github.com/freetype/freetype.git +[submodule "thirdparty/freetype-gl"] + path = thirdparty/freetype-gl + url = https://github.com/rougier/freetype-gl.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 116d0803c..5db2f8f65 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,10 @@ cmake_minimum_required(VERSION 3.31) set(CMAKE_POLICY_VERSION_MINIMUM 3.5) +# Enforce static CRT (/MT) globally — must be set before project() or early +# so that third-party add_subdirectory calls don't override it. +set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + # Windows SDK Version (must be set before project()) if(WIN32) set(WINDOWS_SDK_VERSION "10.0" CACHE STRING "Windows SDK Version") @@ -9,35 +13,37 @@ endif() project(ChainedDecos LANGUAGES CXX C) -set(CMAKE_CXX_STANDARD 23) -set(CMAKE_C_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_C_STANDARD_REQUIRED ON) -set(CMAKE_POSITION_INDEPENDENT_CODE ON) - -set(CMAKE_EXPORT_COMPILE_COMMANDS ON CACHE BOOL "Generate compile_commands.json") -set(CMAKE_BUILD_WITH_INSTALL_RPATH ON) # Options option(BUILD_TESTS "Build unit tests" ON) option(BUILD_SHARED_LIBS "Build shared libraries" OFF) option(CH_CI "Set to ON for CI environments" OFF) +option(CH_NETWORKING "Enable networking features in the editor" ON) if(CH_CI) add_compile_definitions(CH_CI) endif() # Global library definitions -add_compile_definitions(GLM_ENABLE_EXPERIMENTAL GLM_FORCE_DEPTH_ZERO_TO_ONE) +# GLM behavior macros live here (not in engine_pch.h) so every TU — including +# NO_PCH modules — sees the same GLM configuration. +add_compile_definitions(GLM_FORCE_DEPTH_ZERO_TO_ONE GLM_ENABLE_EXPERIMENTAL) + +# Enable logging only in Debug and RelWithDebInfo configurations +add_compile_definitions($<$,$>:CH_ENABLE_LOGGING>) + +# Embed build preset name and configuration so the exporter knows exactly which build was used +# e.g. CH_BUILD_PRESET="windows-clang-debug", CH_BUILD_CONFIG="Debug" +add_compile_definitions($<$:CH_BUILD_CONFIG="Debug">) +add_compile_definitions($<$:CH_BUILD_CONFIG="Release">) +add_compile_definitions($<$:CH_BUILD_CONFIG="RelWithDebInfo">) +if(CMAKE_BUILD_PRESET) + add_compile_definitions(CH_BUILD_PRESET="${CMAKE_BUILD_PRESET}") +endif() -# Warning Settings -option(DISABLE_ALL_WARNINGS "Disable all compiler warnings" OFF) -option(ENABLE_WARNINGS "Enable compiler warnings" OFF) -option(WARNINGS_AS_ERRORS "Treat warnings as errors" OFF) # Optional: ccache support for faster rebuilds -# Install ccache: choco install ccache (Windows) or apt install ccache (Linux) option(USE_CCACHE "Use ccache if available" ON) if(USE_CCACHE) find_program(CCACHE_PROGRAM ccache) @@ -50,70 +56,73 @@ if(USE_CCACHE) endif() endif() -# === BUILD OPTIMIZATION: Prevent unnecessary recompilation === -# 1. Don't regenerate build files if nothing changed -set(CMAKE_SUPPRESS_REGENERATION OFF) - -# 2. Incremental linking (faster linking) -if(MSVC) - add_link_options(/INCREMENTAL) -endif() - -# 3. Parallel build must be controlled by the build tool (cmake --build --parallel) -# Never pass -j as a compiler flag, because compilers like gcc treat it as invalid. - -# 4. Unity builds in Release (combine multiple source files) -# Trades disk space for faster compilation times -if(CMAKE_BUILD_TYPE STREQUAL Release OR CMAKE_CONFIGURATION_TYPES) - set(CMAKE_UNITY_BUILD ON CACHE BOOL "Enable Unity builds" FORCE) - set(CMAKE_UNITY_BUILD_BATCH_SIZE 8 CACHE STRING "Unity build batch size") -endif() +# Note: do NOT add /INCREMENTAL here. MSVC enables it by default in Debug; +# forcing it globally conflicts with /OPT:ICF in Release (LNK4075). # Dependencies include(cmake/CompilerSettings.cmake) include(cmake/Dependencies.cmake) include(cmake/ProjectHelpers.cmake) -chained_generate_build_preset_header() +# Find Python interpreter +find_package(Python3 REQUIRED COMPONENTS Interpreter) +set(CH_PYTHON_EXECUTABLE "${Python3_EXECUTABLE}" CACHE FILEPATH "Python interpreter used for build helper scripts" FORCE) set(CH_ACTIVE_GAME "chaineddecos" CACHE STRING "Active game project to build") set_property(CACHE CH_ACTIVE_GAME PROPERTY STRINGS chaineddecos testproject) -# Useful definitions if(UNIX AND NOT APPLE) add_compile_definitions(_GNU_SOURCE) endif() if(MINGW) - add_compile_options(-Wa,-mbig-obj) + # Serialize link steps under MinGW. Ninja otherwise links several heavy + # Debug targets concurrently, and each linker process holds large archives + # in memory at once — the combined footprint is what exhausts RAM. A + # single-slot link pool keeps compilation fully parallel but links one + # target at a time. + set_property(GLOBAL PROPERTY JOB_POOLS link_pool=1) + set(CMAKE_JOB_POOL_LINK link_pool) endif() -file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" PROJECT_ROOT_DIR_ESCAPED) -add_compile_definitions(PROJECT_ROOT_DIR="${PROJECT_ROOT_DIR_ESCAPED}") - -# Standardize output directories for all targets -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") -set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") -set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") - add_subdirectory(engine) -add_subdirectory(scripting) - -# Build Game Projects -# Build the selected game project -if(CH_ACTIVE_GAME STREQUAL "chaineddecos") - add_subdirectory(game/chaineddecos) -elseif(CH_ACTIVE_GAME STREQUAL "testproject") - add_subdirectory(game/testproject) -else() - message(FATAL_ERROR "Unknown CH_ACTIVE_GAME='${CH_ACTIVE_GAME}'. Supported values are: chaineddecos, testproject.") -endif() - -add_subdirectory(editor) -add_subdirectory(runtime) -if(BUILD_TESTS) - enable_testing() - add_subdirectory(tests) -endif() +# ── Build Game Projects ────────────────────────────────────────────────────── +file(GLOB GAME_PROJECT_LIST RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}/game" "game/*") +foreach(GAME_PROJECT_DIR ${GAME_PROJECT_LIST}) + set(GAME_PATH "${CMAKE_CURRENT_SOURCE_DIR}/game/${GAME_PROJECT_DIR}") + if(IS_DIRECTORY "${GAME_PATH}" AND EXISTS "${GAME_PATH}/CMakeLists.txt") + if(NOT CH_ACTIVE_GAME OR CH_ACTIVE_GAME STREQUAL GAME_PROJECT_DIR) + add_subdirectory("game/${GAME_PROJECT_DIR}") + endif() + endif() +endforeach() +add_subdirectory(editor) +# Google Tests for engine +enable_testing() +add_subdirectory(tests) + +# ── Resource Sync Targets ──────────────────────────────────────────────────── +add_custom_target(sync-resources + COMMAND ${CH_PYTHON_EXECUTABLE} + "${CMAKE_SOURCE_DIR}/tools/sync_resources.py" + sync-resources + --root "${CMAKE_SOURCE_DIR}" + --bin "${CMAKE_BINARY_DIR}/bin" + --all-configs + COMMENT "Syncing resources to all configs" + VERBATIM +) + +add_custom_target(sync-scripts + COMMAND ${CH_PYTHON_EXECUTABLE} + "${CMAKE_SOURCE_DIR}/tools/sync_scripts.py" + --build-dir "${CMAKE_BINARY_DIR}/bin/$" + --game-dir "${CMAKE_SOURCE_DIR}/game/chaineddecos" + COMMENT "Syncing C# scripts to game assets" + VERBATIM +) + +# ── Packaging (CPack) ──────────────────────────────────────────────────────── +include(cmake/Packaging.cmake) \ No newline at end of file diff --git a/CMakePresets.json b/CMakePresets.json index 75d4e25be..f80e44ba7 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -15,50 +15,45 @@ "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", "CMAKE_CXX_EXTENSIONS": "OFF", "BUILD_TESTS": "ON", - "BUILD_MAP_EDITOR": "ON", + "CMAKE_SUPPRESS_DEVELOPER_WARNINGS": "OFF", "DISABLE_ALL_WARNINGS": "ON", "CH_ENGINE_SHARED": "OFF", "CH_ACTIVE_GAME": "chaineddecos", - "WINDOWS_SDK_VERSION": "10.0" + "WINDOWS_SDK_VERSION": "10.0", + "CMAKE_CONFIGURATION_TYPES": "Debug;Release;RelWithDebInfo" } }, { "name": "ninja-base", "hidden": true, "inherits": "base", - "generator": "Ninja", + "generator": "Ninja Multi-Config", "cacheVariables": { - "CMAKE_BUILD_TYPE": "Debug" - } - }, - { - "name": "linux-gcc", - "displayName": "Linux GCC", - "inherits": "ninja-base", - "condition": { - "type": "equals", - "lhs": "${hostSystemName}", - "rhs": "Linux" + "ENABLE_UNITY_BUILD": "OFF", + "CMAKE_SUPPRESS_REGENERATION": "ON" } }, { - "name": "linux-clang", - "displayName": "Linux Clang", + "name": "windows-clang", + "displayName": "Windows Ninja Clang", "inherits": "ninja-base", + "cacheVariables": { + "CMAKE_C_COMPILER": "clang", + "CMAKE_CXX_COMPILER": "clang++" + }, "condition": { "type": "equals", "lhs": "${hostSystemName}", - "rhs": "Linux" + "rhs": "Windows" } }, { - "name": "windows-ninja", - "displayName": "Windows Ninja", + "name": "windows-gcc", + "displayName": "Windows Ninja GCC (MinGW)", "inherits": "ninja-base", "cacheVariables": { - "ENABLE_UNITY_BUILD": "OFF", - "CMAKE_CXX_SCAN_FOR_MODULES": "OFF", - "CMAKE_SUPPRESS_REGENERATION": "ON" + "CMAKE_C_COMPILER": "gcc", + "CMAKE_CXX_COMPILER": "g++" }, "condition": { "type": "equals", @@ -67,72 +62,72 @@ } }, { - "name": "windows-mingw-base", - "hidden": true, + "name": "linux-clang", + "displayName": "Linux Ninja Clang", "inherits": "ninja-base", "cacheVariables": { - "ENABLE_UNITY_BUILD": "OFF", - "CMAKE_CXX_SCAN_FOR_MODULES": "OFF", - "CMAKE_SUPPRESS_REGENERATION": "ON" + "CMAKE_C_COMPILER": "clang", + "CMAKE_CXX_COMPILER": "clang++" }, "condition": { "type": "equals", "lhs": "${hostSystemName}", - "rhs": "Windows" + "rhs": "Linux" } }, { - "name": "windows-gcc", - "displayName": "Windows GCC (MSYS2)", - "inherits": "windows-mingw-base", + "name": "linux-gcc", + "displayName": "Linux Ninja GCC", + "inherits": "ninja-base", "cacheVariables": { "CMAKE_C_COMPILER": "gcc", "CMAKE_CXX_COMPILER": "g++" - } - }, - { - "name": "windows-clang", - "displayName": "Windows Clang (MSYS2)", - "inherits": "windows-mingw-base", - "cacheVariables": { - "CMAKE_C_COMPILER": "clang", - "CMAKE_CXX_COMPILER": "clang++" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" } }, { "name": "windows-msvc", - "displayName": "Windows MSVC", - "inherits": "windows-ninja", + "displayName": "Windows Ninja MSVC", + "inherits": "ninja-base", "cacheVariables": { "CMAKE_C_COMPILER": "cl", "CMAKE_CXX_COMPILER": "cl" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" } - } - ], - "buildPresets": [ - { - "name": "linux-gcc", - "configurePreset": "linux-gcc" }, { - "name": "linux-clang", - "configurePreset": "linux-clang" - }, - { - "name": "windows-ninja", - "configurePreset": "windows-ninja" - }, - { - "name": "windows-gcc", - "configurePreset": "windows-gcc" - }, - { - "name": "windows-clang", - "configurePreset": "windows-clang" - }, - { - "name": "windows-msvc", - "configurePreset": "windows-msvc" + "name": "windows-vs2026", + "displayName": "Windows Visual Studio 2026", + "inherits": "base", + "generator": "Visual Studio 18 2026", + "architecture": { "value": "x64", "strategy": "set" }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } } + ], + "buildPresets": [ + { "name": "windows-clang-debug", "configurePreset": "windows-clang", "configuration": "Debug" }, + { "name": "windows-clang-release", "configurePreset": "windows-clang", "configuration": "Release" }, + { "name": "windows-msvc-debug", "configurePreset": "windows-msvc", "configuration": "Debug" }, + { "name": "windows-msvc-release", "configurePreset": "windows-msvc", "configuration": "Release" }, + { "name": "windows-vs2026-debug", "configurePreset": "windows-vs2026", "configuration": "Debug" }, + { "name": "windows-vs2026-release", "configurePreset": "windows-vs2026", "configuration": "Release" }, + { "name": "windows-gcc-debug", "configurePreset": "windows-gcc", "configuration": "Debug" }, + { "name": "windows-gcc-release", "configurePreset": "windows-gcc", "configuration": "Release" }, + { "name": "linux-clang-debug", "configurePreset": "linux-clang", "configuration": "Debug" }, + { "name": "linux-clang-release", "configurePreset": "linux-clang", "configuration": "Release" }, + { "name": "linux-gcc-debug", "configurePreset": "linux-gcc", "configuration": "Debug" }, + { "name": "linux-gcc-release", "configurePreset": "linux-gcc", "configuration": "Release" } ] } \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 000000000..e3fb9a7fd --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,5 @@ + + + LatestMajor + + diff --git a/cmake/CompilerSettings.cmake b/cmake/CompilerSettings.cmake index 664586528..6e2863fc9 100644 --- a/cmake/CompilerSettings.cmake +++ b/cmake/CompilerSettings.cmake @@ -1,132 +1,36 @@ # Chained Engine - Compiler Settings # Extracted from root CMakeLists.txt for modularity -set(CMAKE_DEBUG_POSTFIX "") -# Compiler-specific settings -if(MSVC) - # MSVC-specific settings - add_compile_options( - $<$:/Od> $<$:/MTd> - $<$:/O2> $<$:/MT> $<$:/DNDEBUG> - /Zi /EHsc - /MP # Multi-processor compilation - /Zc:preprocessor # Modern preprocessor - /Gm- # Disable minimal rebuild (it's slower) - /utf-8 # Use UTF-8 character set - /bigobj # Allow large object files (required for many modules) - - # Dead Code Elimination: Function-Level Linking - $<$:/Gy> - ) - - - # Strip unused functions in Release - add_link_options($<$:/OPT:REF> $<$:/OPT:ICF>) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_C_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(CMAKE_DEBUG_POSTFIX "") - if(DISABLE_ALL_WARNINGS) - add_compile_options(/W0) - elseif(ENABLE_WARNINGS) - add_compile_options(/W4 /permissive-) - if(WARNINGS_AS_ERRORS) - add_compile_options(/WX) - endif() - else() - add_compile_options(/W1) - endif() +# Standardize output directories for all targets (Set BEFORE including dependencies) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") +set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") - if(ENABLE_SANITIZERS) - add_compile_options(/fsanitize=address) - endif() +set(CMAKE_EXPORT_COMPILE_COMMANDS ON CACHE BOOL "Generate compile_commands.json") +set(CMAKE_BUILD_WITH_INSTALL_RPATH ON) +if(UNIX AND NOT APPLE) + set(CMAKE_INSTALL_RPATH "$ORIGIN") +endif() - # Level 2 Security Hardening - add_compile_options(/guard:cf /GS) - add_link_options(/DYNAMICBASE /NXCOMPAT /guard:cf) +set(CMAKE_DEBUG_POSTFIX "") +# Compiler-specific settings +if(MSVC AND NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") + # MSVC (Standardcl.exe) + include(${CMAKE_CURRENT_LIST_DIR}/compilers/CompilerMSVC.cmake) elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Clang settings (includes AppleClang and clang-cl) - if(MSVC) - # clang-cl behaves like MSVC - add_compile_options(/Zc:preprocessor /utf-8 /bigobj) - else() - add_compile_options( - $<$:-O0> $<$:-g> - $<$:-O3> $<$:-DNDEBUG> - $<$:-ffunction-sections> - $<$:-fdata-sections> - ) - - if(MINGW) - add_compile_options(-Wa,-mbig-obj) - endif() - - # Dead Code Elimination linkage and binary stripping for Release build - add_link_options( - $<$:-Wl,--gc-sections> - $<$:-s> - ) - - if(DISABLE_ALL_WARNINGS) - add_compile_options(-w) - elseif(ENABLE_WARNINGS) - add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wmost -Wno-missing-braces -Wno-missing-field-initializers -Wno-attributes) - if(WARNINGS_AS_ERRORS) - add_compile_options(-Werror) - endif() - else() - add_compile_options(-Wno-all) - endif() - endif() - - if(ENABLE_SANITIZERS) - add_compile_options(-fsanitize=address -fsanitize=undefined) - add_link_options(-fsanitize=address -fsanitize=undefined) - endif() - - if(NOT WIN32) - add_link_options(-Wl,-z,relro -Wl,-z,now) - endif() - + include(${CMAKE_CURRENT_LIST_DIR}/compilers/CompilerClang.cmake) elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") # GCC settings - add_compile_options( - $<$:-O0> $<$:-g> - $<$:-O3> $<$:-DNDEBUG> - $<$:-ffunction-sections> - $<$:-fdata-sections> - ) - - if(MINGW) - add_compile_options(-Wa,-mbig-obj) - endif() - - # Suppress overly strict C++23 template body checks for third-party headers (GLM) - add_compile_options(-Wno-template-body) - - # Dead Code Elimination linkage and binary stripping for Release build - add_link_options( - $<$:-Wl,--gc-sections> - $<$:-s> - ) - - if(DISABLE_ALL_WARNINGS) - add_compile_options(-w) - elseif(ENABLE_WARNINGS) - add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wno-missing-field-initializers -Wno-attributes) - if(WARNINGS_AS_ERRORS) - add_compile_options(-Werror) - endif() - else() - add_compile_options(-Wno-all) - endif() - - if(ENABLE_SANITIZERS) - add_compile_options(-fsanitize=address -fsanitize=undefined) - add_link_options(-fsanitize=address -fsanitize=undefined) - endif() - - if(NOT WIN32) - add_link_options(-Wl,-z,relro -Wl,-z,now) - endif() + include(${CMAKE_CURRENT_LIST_DIR}/compilers/CompilerGCC.cmake) endif() # Platform-specific settings @@ -138,10 +42,15 @@ if(WIN32) endif() # Optimized Build Settings -option(ENABLE_UNITY_BUILD "Enable Unity Builds for faster compilation" ON) +option(ENABLE_UNITY_BUILD "Enable Unity Builds for faster compilation" OFF) option(ENABLE_PCH "Enable Precompiled Headers for faster compilation" ON) option(ENABLE_LTO "Enable Link-Time Optimization (IPO) for Release configurations" ON) option(ENABLE_COVERAGE "Enable Code Coverage (GCC/Clang only)" OFF) +# Warning Settings +option(DISABLE_ALL_WARNINGS "Disable all compiler warnings" OFF) +option(ENABLE_WARNINGS "Enable compiler warnings" OFF) +option(WARNINGS_AS_ERRORS "Treat warnings as errors" OFF) + if(ENABLE_COVERAGE) if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") @@ -155,41 +64,28 @@ if(ENABLE_UNITY_BUILD) set(CMAKE_UNITY_BUILD_BATCH_SIZE 16) endif() -# Function to apply common engine optimizations to a target -function(apply_engine_optimizations target_name) - if(ENABLE_PCH) - # Real PCH: faster local dev, but breaks sccache cache hit rates - target_precompile_headers(${target_name} PUBLIC "${PROJECT_SOURCE_DIR}/engine/engine_pch.h") +# ── Engine-wide PCH ────────────────────────────────────────────────────────── +# INTERFACE library that propagates the engine precompiled header to any target +# that links it privately. Each consumer creates its own .pch binary matching +# its own compiler flags, so there is no cross-TU contamination. +if(ENABLE_PCH) + add_library(engine_pch INTERFACE) + target_precompile_headers(engine_pch INTERFACE "${PROJECT_SOURCE_DIR}/engine/engine_pch.h") +else() + # Force-include: injects engine_pch.h into every TU via compiler flags. + # Gives the same include coverage as PCH but without a .pch binary, + # so ccache/sccache still achieves 100% hit rates in CI. + add_library(engine_pch INTERFACE) + set(_pch_path "${PROJECT_SOURCE_DIR}/engine/engine_pch.h") + if(MSVC) + target_compile_options(engine_pch INTERFACE "/FI${_pch_path}") else() - # Force-include: injects engine_pch.h into every TU via compiler flags. - # This gives the same include coverage as PCH but without a .pch binary, - # so ccache/sccache still achieves 100% hit rates in CI. - set(_pch_path "${PROJECT_SOURCE_DIR}/engine/engine_pch.h") - if(MSVC) - target_compile_options(${target_name} PRIVATE "/FI${_pch_path}") - else() - target_compile_options(${target_name} PRIVATE "-include" "${_pch_path}") - endif() - endif() - - if(ENABLE_LTO) - # Disable LTO for MinGW/GCC in Debug as it's extremely slow - if(MINGW OR (CMAKE_BUILD_TYPE STREQUAL "Debug")) - set(ipo_supported OFF) - else() - include(CheckIPOSupported) - check_ipo_supported(RESULT ipo_supported OUTPUT ipo_output) - endif() - - if(ipo_supported) - set_property(TARGET ${target_name} PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE ON) - elseif(ipo_output AND NOT CMAKE_BUILD_TYPE STREQUAL "Debug") - message(STATUS "IPO/LTO is not supported or disabled for this configuration: ${ipo_output}") - endif() + target_compile_options(engine_pch INTERFACE "-include" "${_pch_path}") endif() +endif() - # Enable Unity Build for the target if global option is ON - if(ENABLE_UNITY_BUILD) - set_target_properties(${target_name} PROPERTIES UNITY_BUILD ON) - endif() -endfunction() +# ── LTO (global) ──────────────────────────────────────────────────────────── +# Applied once here so individual targets don't need to set it. +if(ENABLE_LTO AND NOT MINGW) + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE ON) +endif() diff --git a/cmake/CopyIfDifferent.cmake b/cmake/CopyIfDifferent.cmake new file mode 100644 index 000000000..1258b30dd --- /dev/null +++ b/cmake/CopyIfDifferent.cmake @@ -0,0 +1,23 @@ +# CopyIfDifferent.cmake +# Usage: cmake -DSOURCE=src_dir -DDEST=dst_dir -P CopyIfDifferent.cmake + +if(NOT SOURCE OR NOT DEST) + message(FATAL_ERROR "SOURCE and DEST must be defined") +endif() + +# Gather all files from SOURCE recursively and copy each one individually. +# This way a single locked/permission-denied file does not abort the whole sync. +file(GLOB_RECURSE _all_files RELATIVE "${SOURCE}" "${SOURCE}/*") +foreach(_rel IN LISTS _all_files) + set(_src "${SOURCE}/${_rel}") + set(_dst "${DEST}/${_rel}") + # Only copy when the destination is missing or older than the source + if(NOT EXISTS "${_dst}" OR "${_src}" IS_NEWER_THAN "${_dst}") + get_filename_component(_dst_dir "${_dst}" DIRECTORY) + file(MAKE_DIRECTORY "${_dst_dir}") + file(COPY_FILE "${_src}" "${_dst}" ONLY_IF_DIFFERENT RESULT _copy_result) + if(NOT _copy_result EQUAL 0) + message(WARNING "CopyIfDifferent: Could not copy '${_rel}' (${_copy_result}) — file may be in use, skipping.") + endif() + endif() +endforeach() diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index 4beb54d48..8b5c21578 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -1,218 +1,62 @@ - -# Chained Engine - Dependencies -# Extracted from root CMakeLists.txt for modularity - -# yaml-cpp -set(YAML_CPP_BUILD_TESTS OFF CACHE BOOL "" FORCE) -set(YAML_CPP_BUILD_TOOLS OFF CACHE BOOL "" FORCE) -set(YAML_CPP_BUILD_CONTRIB OFF CACHE BOOL "" FORCE) - -if(EXISTS "${CMAKE_SOURCE_DIR}/include/yaml-cpp/CMakeLists.txt") - add_subdirectory(include/yaml-cpp) - set(yaml-cpp_SOURCE_DIR "${CMAKE_SOURCE_DIR}/include/yaml-cpp" CACHE INTERNAL "") -else() - message(FATAL_ERROR "yaml-cpp submodule not found! Run: git submodule update --init --recursive") -endif() - -# ImGuizmo (Manipulators) -set(imguizmo_SOURCE_DIR "${CMAKE_SOURCE_DIR}/include/imguizmo") - -# GLM -if(EXISTS "${CMAKE_SOURCE_DIR}/include/glm/CMakeLists.txt") - # GLM is header-only but provides CMake integration - add_subdirectory(include/glm) - set(glm_SOURCE_DIR "${CMAKE_SOURCE_DIR}/include/glm" CACHE INTERNAL "") -else() - message(FATAL_ERROR "glm submodule not found! Run: git submodule update --init --recursive") -endif() - -# Coral (for C# scripting integration) -if(EXISTS "${CMAKE_SOURCE_DIR}/include/coral/cmake/CMakeLists.txt") - add_subdirectory(include/coral/cmake) - set(coral_SOURCE_DIR "${CMAKE_SOURCE_DIR}/include/coral" CACHE INTERNAL "") - - # --- CI Fixes for Coral (Injection) --- - if(WIN32) - set(CORAL_FIX_DIR "${CMAKE_BINARY_DIR}/coral_fixes") - file(MAKE_DIRECTORY "${CORAL_FIX_DIR}") - - # 1. ShlObj_core.h shim (MinGW fix) - if(MINGW) - file(WRITE "${CORAL_FIX_DIR}/ShlObj_core.h" "#pragma once\n#include \n") - endif() - - # 2. MSVC wchar_t stream fix (C2280 fix) - file(WRITE "${CORAL_FIX_DIR}/StreamFix.hpp" - "#pragma once\n" - "#include \n" - "#include \n" - "// Standalone fix for deleted operator<< in Coral logging\n" - "inline std::ostream& operator<<(std::ostream& os, const wchar_t* str) { return os << \"[wide string]\"; }\n" - "inline std::ostream& operator<<(std::ostream& os, const std::wstring& str) { return os << \"[wide string]\"; }\n" - ) - - if(TARGET Coral.Native) - if(MINGW) - target_include_directories(Coral.Native PRIVATE "${CORAL_FIX_DIR}") - endif() - - if(MSVC) - target_compile_options(Coral.Native PRIVATE "/FI${CORAL_FIX_DIR}/StreamFix.hpp") - else() - target_compile_options(Coral.Native PRIVATE "-include${CORAL_FIX_DIR}/StreamFix.hpp") - endif() - endif() - endif() -else() - message(FATAL_ERROR "coral submodule not found! Run: git submodule update --init --recursive") -endif() - -# assimp (Asset Importer Library) -set(ASSIMP_BUILD_ASSIMP_TOOLS OFF CACHE BOOL "" FORCE) -set(ASSIMP_BUILD_TESTS OFF CACHE BOOL "" FORCE) -set(ASSIMP_INSTALL OFF CACHE BOOL "" FORCE) -set(ASSIMP_BUILD_ZLIB ON CACHE BOOL "" FORCE) -set(ASSIMP_BUILD_DRACO OFF CACHE BOOL "" FORCE) -set(ASSIMP_NO_EXPORT ON CACHE BOOL "" FORCE) - -# Enable ALL model format importers to ensure maximum compatibility -set(ASSIMP_BUILD_ALL_IMPORTERS_BY_DEFAULT ON CACHE BOOL "" FORCE) -# Specific overrides if needed (currently all on by default) - -# Ensure no unity build for assimp or its subprojects -set(CMAKE_UNITY_BUILD OFF) - -if(EXISTS "${CMAKE_SOURCE_DIR}/include/assimp/CMakeLists.txt") - add_subdirectory(include/assimp) - set(assimp_SOURCE_DIR "${CMAKE_SOURCE_DIR}/include/assimp" CACHE INTERNAL "") - set(assimp_BINARY_DIR "${CMAKE_CURRENT_BINARY_DIR}/include/assimp" CACHE INTERNAL "") - # Disable unity build for assimp to avoid header/namespace conflicts - if(TARGET assimp) - set_target_properties(assimp PROPERTIES UNITY_BUILD OFF) - if(NOT MSVC) - target_compile_options(assimp PRIVATE -Wno-error) - endif() - endif() -else() - message(FATAL_ERROR "assimp submodule not found! Run: git submodule update --init --recursive") -endif() - -# ============================================================================ -# GLFW (Standalone) -# ============================================================================ -if(EXISTS "${CMAKE_SOURCE_DIR}/include/glfw/CMakeLists.txt") - message(STATUS "Loading standalone GLFW...") - set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) - set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE) - set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE) - set(GLFW_INSTALL OFF CACHE BOOL "" FORCE) - add_subdirectory(include/glfw) - - if(UNIX AND NOT APPLE) - find_package(X11 REQUIRED) - target_link_libraries(glfw PUBLIC ${X11_LIBRARIES}) - endif() - - set(GLFW_SOURCE_DIR "${CMAKE_SOURCE_DIR}/include/glfw" CACHE INTERNAL "") -else() - message(FATAL_ERROR "Standalone GLFW not found in include/glfw") -endif() - # ============================================================================ -# GLAD (Standalone Generator) -# ============================================================================ -if(EXISTS "${CMAKE_SOURCE_DIR}/include/glad/cmake/CMakeLists.txt") - message(STATUS "Loading standalone GLAD generator...") - # Add the glad subdirectory which provides glad_add_library - add_subdirectory(include/glad/cmake EXCLUDE_FROM_ALL) - - # Generate GLAD for OpenGL 4.3 Core - glad_add_library(glad STATIC API gl:core=4.3) -else() - message(FATAL_ERROR "Standalone GLAD generator not found in include/glad") -endif() - -# ============================================================================ -# GoogleTest (for unit tests) -# ============================================================================ -if(BUILD_TESTS) - if(EXISTS "${CMAKE_SOURCE_DIR}/include/googletest/CMakeLists.txt") - message(STATUS "Loading GoogleTest from submodule...") - set(gtest_force_shared_crt OFF CACHE BOOL "" FORCE) - - if(CMAKE_BUILD_TYPE STREQUAL "Debug") - add_compile_definitions(_DEBUG) - else() - add_compile_definitions(NDEBUG) - endif() - - add_subdirectory(include/googletest) - message(STATUS "GoogleTest loaded from submodule") - else() - message(WARNING "GoogleTest submodule not found. Tests will be disabled.") - endif() -endif() - -# ============================================================================ -# EnTT (header-only library) -# ============================================================================ -if(EXISTS "${CMAKE_SOURCE_DIR}/include/entt/src") - message(STATUS "Loading EnTT from submodule...") - add_library(EnTT INTERFACE) - target_include_directories(EnTT INTERFACE ${CMAKE_SOURCE_DIR}/include/entt/src) - if(NOT TARGET EnTT::EnTT) - add_library(EnTT::EnTT ALIAS EnTT) - endif() - message(STATUS "EnTT loaded from submodule (header-only)") -else() - message(FATAL_ERROR "EnTT submodule not found. Run: git submodule update --init --recursive") -endif() - -# ============================================================================ -# ImGui Standalone (GLFW + OpenGL3) -# ============================================================================ -set(IMGUI_SOURCES - include/imgui/imgui.cpp - include/imgui/imgui_draw.cpp - include/imgui/imgui_widgets.cpp - include/imgui/imgui_tables.cpp - include/imgui/imgui_demo.cpp - include/imgui/misc/cpp/imgui_stdlib.cpp - include/imgui/backends/imgui_impl_glfw.cpp - include/imgui/backends/imgui_impl_glfw.h - include/imgui/backends/imgui_impl_opengl3.cpp - include/imgui/backends/imgui_impl_opengl3.h - ${imguizmo_SOURCE_DIR}/ImGuizmo.cpp - ${imguizmo_SOURCE_DIR}/ImGuizmo.h +# Chained Engine - Dependencies Configuration +# ============================================================================ + + +# Add external modules directory to search path +list(APPEND CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/cmake/external") + +# Core dependencies (independent) +include(yaml-cpp) +include(glm) +include(entt) +include(miniaudio) +include(cereal) +include(stb) +# zstd is provided by pack — no separate include needed. +include(spdlog) +include(jolt) +include(freetype) +include(freetype_gl) + +# Platform & Graphics (independent) +include(glfw) +include(glad) + +# UI (depends on GLFW and GLAD) +include(imgui) + +# Complex modules +include(assimp) + +include(coral) +include(external_gtest) +include(pack) +include(portable-file-dialogs) +# enet + sodium are the networking transport (added via engine/CMakeLists.txt) +include(reflect-cpp) +include(miniupnpc) + +# Disable unity builds for third-party libraries to avoid symbol redefinitions +# (e.g., zstd cover.h has no include guard, causing redefinition under unity build) +# Coral.Native is specifically excluded because MSVC's unity PCH in C++20 mode +# deletes operator<<(wchar_t*) which is used internally by Coral's cerr logging. +foreach(_ext_target + libzstd_static yaml-cpp + glm entt cereal stb spdlog miniaudio + imgui imguizmo + glfw glad + Jolt + GTest gmock + Coral.Native + assimp + freetype engine_freetype_gl ) + if(TARGET ${_ext_target}) + set_target_properties(${_ext_target} PROPERTIES UNITY_BUILD OFF) + endif() +endforeach() -if(NOT TARGET imguilib) - add_library(imguilib STATIC ${IMGUI_SOURCES}) - - target_include_directories(imguilib PUBLIC - ${CMAKE_SOURCE_DIR}/include/imgui - ${CMAKE_SOURCE_DIR}/include/glfw/include - ${imguizmo_SOURCE_DIR} - ) - target_link_libraries(imguilib PUBLIC glfw glad) - - # Define IMGUI math operators and GLFW settings - target_compile_definitions(imguilib PUBLIC - IMGUI_DEFINE_MATH_OPERATORS - GLFW_INCLUDE_NONE - IMGUI_IMPL_OPENGL_LOADER_GLAD - ) - - # Disable unity build for imguilib to avoid GLAD header conflicts - set_target_properties(imguilib PROPERTIES UNITY_BUILD OFF) -endif() - -# ============================================================================ -# Native File Dialog (nfd) -# ============================================================================ -if(EXISTS "${CMAKE_SOURCE_DIR}/include/nfd/CMakeLists.txt") - message(STATUS "Loading nfd from submodule...") - set(NFD_BUILD_TESTS OFF CACHE BOOL "" FORCE) - add_subdirectory(include/nfd) - message(STATUS "nfd loaded from submodule") +if(NOT TARGET libzstd_static AND NOT TARGET libzstd) + add_subdirectory(thirdparty/zstd/build/cmake/lib) endif() diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake new file mode 100644 index 000000000..56083b76f --- /dev/null +++ b/cmake/Packaging.cmake @@ -0,0 +1,67 @@ +# ── Packaging Configuration (CPack) ────────────────────────────────────────── + +set(CPACK_PACKAGE_NAME "ChainedEngine") +set(CPACK_PACKAGE_VENDOR "IOleg") +set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Chained Engine - 3D Game Engine, Editor & SDK") +set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/IOleg-crypto/Chained-Engine") +set(CPACK_PACKAGE_CONTACT "IOleg") + +if(NOT DEFINED CPACK_PACKAGE_VERSION) + set(CPACK_PACKAGE_VERSION_MAJOR 0) + set(CPACK_PACKAGE_VERSION_MINOR 1) + set(CPACK_PACKAGE_VERSION_PATCH 0) + set(CPACK_PACKAGE_VERSION "${CPACK_PACKAGE_VERSION_MAJOR}.${CPACK_PACKAGE_VERSION_MINOR}.${CPACK_PACKAGE_VERSION_PATCH}") +endif() + +# License & Readme files +if(EXISTS "${CMAKE_SOURCE_DIR}/license") + set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/license") +endif() +if(EXISTS "${CMAKE_SOURCE_DIR}/readme.md") + set(CPACK_RESOURCE_FILE_README "${CMAKE_SOURCE_DIR}/readme.md") +endif() + +# Component packaging support +set(CPACK_ARCHIVE_COMPONENT_INSTALL ON) + +# Component definitions & descriptions +set(CPACK_COMPONENT_EDITOR_DISPLAY_NAME "Chained Editor") +set(CPACK_COMPONENT_EDITOR_DESCRIPTION "Stand-alone editor for building 3D games and editing scenes.") +set(CPACK_COMPONENT_EDITOR_GROUP "Applications") + +set(CPACK_COMPONENT_GAME_DISPLAY_NAME "Chained Decos Game") +set(CPACK_COMPONENT_GAME_DESCRIPTION "Standalone game binary and assets.") +set(CPACK_COMPONENT_GAME_GROUP "Applications") + +set(CPACK_COMPONENT_RUNTIME_DISPLAY_NAME "Engine Runtime") +set(CPACK_COMPONENT_RUNTIME_DESCRIPTION "Core runtime libraries, assets, and engine resources.") +set(CPACK_COMPONENT_RUNTIME_GROUP "Runtime") + +set(CPACK_COMPONENT_SDK_DISPLAY_NAME "Chained Engine SDK") +set(CPACK_COMPONENT_SDK_DESCRIPTION "C++ headers, libraries, and scripting tools to develop with Chained Engine.") +set(CPACK_COMPONENT_SDK_GROUP "Development") + +# Platform-specific packaging generators +if(WIN32) + set(CPACK_GENERATOR "ZIP;NSIS") + set(CPACK_SOURCE_GENERATOR "ZIP") + + # NSIS Installer configuration + set(CPACK_NSIS_DISPLAY_NAME "Chained Engine") + set(CPACK_NSIS_PACKAGE_NAME "ChainedEngine") + set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL ON) + set(CPACK_NSIS_MODIFY_PATH OFF) +else() + set(CPACK_GENERATOR "TGZ;DEB") + set(CPACK_SOURCE_GENERATOR "TGZ") + + # Debian packaging metadata + set(CPACK_DEBIAN_PACKAGE_MAINTAINER "IOleg ") + set(CPACK_DEBIAN_PACKAGE_SECTION "games") + set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) +endif() + +# Package file naming pattern +set(CPACK_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}-${CMAKE_SYSTEM_NAME}") + +include(CPack) diff --git a/cmake/ProjectHelpers.cmake b/cmake/ProjectHelpers.cmake index 700aef512..0de6db425 100644 --- a/cmake/ProjectHelpers.cmake +++ b/cmake/ProjectHelpers.cmake @@ -12,29 +12,29 @@ function(chained_add_csharp_scripts TARGET_NAME CSHARP_PROJECT_PATH) return() endif() - set(CORAL_MANAGED_DIR "${CMAKE_BINARY_DIR}/include/coral/cmake") - set(SCRIPT_OUTPUT_DIR "${CMAKE_BINARY_DIR}/bin/scripts/${TARGET_NAME}") - - # Track only gameplay .cs files for incremental builds - file(GLOB_RECURSE CS_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cs") + set(CORAL_MANAGED_DIR "${CMAKE_BINARY_DIR}/vendor/coral") + set(SCRIPT_OUTPUT_DIR "${CMAKE_BINARY_DIR}/bin/$/scripts/${TARGET_NAME}") set(SCRIPT_DLL_PATH "${SCRIPT_OUTPUT_DIR}/${TARGET_NAME}.dll") - add_custom_command( - OUTPUT "${SCRIPT_DLL_PATH}" - COMMAND dotnet build "${FULL_CSPROJ_PATH}" - -c $,$>,Debug,Release> - --output "${SCRIPT_OUTPUT_DIR}" - -p:CoralManagedDir="${CORAL_MANAGED_DIR}" + add_custom_target(${SCRIPT_TARGET} + COMMAND "${CH_PYTHON_EXECUTABLE}" "${CMAKE_SOURCE_DIR}/tools/build_managed.py" + --project "${FULL_CSPROJ_PATH}" + --configuration $,$>,Debug,Release> + --output "${SCRIPT_OUTPUT_DIR}" + --coral-dir "${CORAL_MANAGED_DIR}" + --parallel + COMMAND "${CH_PYTHON_EXECUTABLE}" "${CMAKE_SOURCE_DIR}/tools/sync_scripts.py" + --build-dir "${CMAKE_BINARY_DIR}/bin/$" + --game-dir "${CMAKE_CURRENT_SOURCE_DIR}" WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" - DEPENDS ${FULL_CSPROJ_PATH} ${CS_SOURCES} COMMENT "Building C# Scripts for ${TARGET_NAME} (incremental)" + BYPRODUCTS "${SCRIPT_DLL_PATH}" + VERBATIM ) - - add_custom_target(${SCRIPT_TARGET} ALL DEPENDS "${SCRIPT_DLL_PATH}") - # Ensure scripts build AFTER CHEngine_Managed to avoid dotnet race condition - if(TARGET CHEngine_Managed) - add_dependencies(${SCRIPT_TARGET} CHEngine_Managed) + # Ensure scripts build AFTER Chained_Managed to avoid dotnet race condition + if(TARGET Chained_Managed) + add_dependencies(${SCRIPT_TARGET} Chained_Managed) endif() endfunction() @@ -44,7 +44,9 @@ macro(_chained_configure_game_target TGT) ${CMAKE_CURRENT_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR} ) - apply_engine_optimizations(${TGT}) + if(TARGET engine_pch) + target_link_libraries(${TGT} PRIVATE engine_pch) + endif() endmacro() # Main helper function to create a standalone game project @@ -60,136 +62,77 @@ function(chained_add_game TARGET_NAME) set(multiValueArgs SOURCES) cmake_parse_arguments(GAME "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) - # 1. Locate the entry point (main.cpp) + # 1. Locate the entry point (main.cpp) (OPTIONAL) set(ENTRY_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp") - if(NOT EXISTS "${ENTRY_SOURCE}") - message(FATAL_ERROR "chained_add_game: Could not find src/main.cpp for ${TARGET_NAME}") + set(HAS_ENTRY_POINT OFF) + if(EXISTS "${ENTRY_SOURCE}") + set(HAS_ENTRY_POINT ON) endif() - # 2. Create C++ libraries if native sources are provided + # 2. Compile game sources as a static library (saves RAM and compile time) if(GAME_SOURCES) - # Static library for logic/tests add_library(${TARGET_NAME} STATIC ${GAME_SOURCES}) - target_link_libraries(${TARGET_NAME} PUBLIC engine) + # Rename static lib so its .lib doesn't clash with the IMPLIB generated + # by the exe on MSVC (both would be ChainedDecos.lib otherwise → LNK1181). + set_target_properties(${TARGET_NAME} PROPERTIES OUTPUT_NAME "${TARGET_NAME}Game") + target_link_libraries(${TARGET_NAME} PUBLIC ChainedEngine::Framework) _chained_configure_game_target(${TARGET_NAME}) - - # Shared library for Hot Reload - add_library(${TARGET_NAME}Module SHARED ${GAME_SOURCES}) - target_link_libraries(${TARGET_NAME}Module PUBLIC engine) - target_compile_definitions(${TARGET_NAME}Module PRIVATE GAME_BUILD_DLL) - set_target_properties(${TARGET_NAME}Module PROPERTIES OUTPUT_NAME "${TARGET_NAME}") - _chained_configure_game_target(${TARGET_NAME}Module) endif() # 3. Create the EXECUTABLE target - add_executable(${TARGET_NAME}Exe ${ENTRY_SOURCE}) - target_link_libraries(${TARGET_NAME}Exe PRIVATE RuntimeCore) - - if(GAME_SOURCES) - target_link_libraries(${TARGET_NAME}Exe PRIVATE ${TARGET_NAME}) + if(HAS_ENTRY_POINT) + add_executable(${TARGET_NAME}Exe ${ENTRY_SOURCE}) + target_link_libraries(${TARGET_NAME}Exe PRIVATE engine_runtime_core) + + if(GAME_SOURCES) + target_link_libraries(${TARGET_NAME}Exe PRIVATE ${TARGET_NAME}) + endif() + + set_target_properties(${TARGET_NAME}Exe PROPERTIES + OUTPUT_NAME "${TARGET_NAME}" + VS_DEBUGGER_WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + ) + + target_compile_definitions(${TARGET_NAME}Exe PRIVATE + GAME_BUILD_EXE + ) + _chained_configure_game_target(${TARGET_NAME}Exe) endif() - - set_target_properties(${TARGET_NAME}Exe PROPERTIES - OUTPUT_NAME "${TARGET_NAME}" - VS_DEBUGGER_WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" - ) - - target_compile_definitions(${TARGET_NAME}Exe PRIVATE - GAME_BUILD_EXE - ) - _chained_configure_game_target(${TARGET_NAME}Exe) # 4. Handle C# Script Building if(GAME_CSHARP_PROJECT) chained_add_csharp_scripts(${TARGET_NAME} "${GAME_CSHARP_PROJECT}") - add_dependencies(${TARGET_NAME}Exe "BuildScripts_${TARGET_NAME}") + if(HAS_ENTRY_POINT) + add_dependencies(${TARGET_NAME}Exe "BuildScripts_${TARGET_NAME}") + endif() endif() # 5. Installation - set(INSTALL_TARGETS ${TARGET_NAME}Exe) - if(TARGET ${TARGET_NAME}) - list(APPEND INSTALL_TARGETS ${TARGET_NAME}) - endif() - if(TARGET ${TARGET_NAME}Module) - list(APPEND INSTALL_TARGETS ${TARGET_NAME}Module) + if(HAS_ENTRY_POINT) + set(INSTALL_TARGETS ${TARGET_NAME}Exe) + if(TARGET ${TARGET_NAME}) + list(APPEND INSTALL_TARGETS ${TARGET_NAME}) + endif() + + install(TARGETS ${INSTALL_TARGETS} + RUNTIME DESTINATION bin COMPONENT Game + ARCHIVE DESTINATION lib COMPONENT SDK + ) endif() - install(TARGETS ${INSTALL_TARGETS} - RUNTIME DESTINATION bin COMPONENT Runtime - ARCHIVE DESTINATION lib COMPONENT Runtime - LIBRARY DESTINATION lib COMPONENT Runtime - ) - - message(STATUS "Configured Project: ${GAME_PROJECT_GAME} (Exe=${TARGET_NAME}Exe, Output=${TARGET_NAME})") + message(STATUS "Configured Project: ${GAME_PROJECT_GAME} (Output=${TARGET_NAME})") endfunction() -# Generate a header with the build preset names from CMakePresets.json so C++ -# code does not need to duplicate or hardcode preset lists. -function(chained_generate_build_preset_header) - set(PRESETS_FILE "${CMAKE_SOURCE_DIR}/CMakePresets.json") - if(NOT EXISTS "${PRESETS_FILE}") - message(FATAL_ERROR "chained_generate_build_preset_header: Could not find ${PRESETS_FILE}") - endif() - - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${PRESETS_FILE}") - - file(READ "${PRESETS_FILE}" PRESETS_JSON) +# Copy engine resources (shaders, fonts, icons, config) to a target's output directory +function(ch_add_resource_sync TARGET) + set(RESOURCES_SRC "${CMAKE_SOURCE_DIR}/resources") + set(RESOURCES_DST "$/resources") - set(BUILD_PRESET_NAMES "") - string(JSON BUILD_PRESET_COUNT LENGTH "${PRESETS_JSON}" buildPresets) - if(BUILD_PRESET_COUNT GREATER 0) - math(EXPR LAST_INDEX "${BUILD_PRESET_COUNT} - 1") - foreach(INDEX RANGE 0 ${LAST_INDEX}) - string(JSON CONFIGURE_PRESET_NAME GET "${PRESETS_JSON}" buildPresets ${INDEX} configurePreset) - if(CONFIGURE_PRESET_NAME STREQUAL "") - string(JSON CONFIGURE_PRESET_NAME GET "${PRESETS_JSON}" buildPresets ${INDEX} name) - endif() - - if(NOT CONFIGURE_PRESET_NAME STREQUAL "") - list(APPEND BUILD_PRESET_NAMES "${CONFIGURE_PRESET_NAME}") - endif() - endforeach() - endif() - - list(REMOVE_DUPLICATES BUILD_PRESET_NAMES) - list(LENGTH BUILD_PRESET_NAMES BUILD_PRESET_COUNT) - - set(BUILD_PRESET_ENTRIES "") - foreach(PRESET_NAME IN LISTS BUILD_PRESET_NAMES) - string(APPEND BUILD_PRESET_ENTRIES " \"${PRESET_NAME}\",\n") - endforeach() - - set(GENERATED_INCLUDE_DIR "${CMAKE_BINARY_DIR}/generated/chaineddecos") - file(MAKE_DIRECTORY "${GENERATED_INCLUDE_DIR}") - - set(GENERATED_HEADER "${GENERATED_INCLUDE_DIR}/build_preset_names.h") - - set(CH_BUILD_PRESET_NAMES_INCLUDE_DIR "${GENERATED_INCLUDE_DIR}" PARENT_SCOPE) - set(CH_BUILD_PRESET_NAMES_HEADER "${GENERATED_HEADER}" PARENT_SCOPE) - set(CH_BUILD_PRESET_NAMES_COUNT "${BUILD_PRESET_COUNT}" PARENT_SCOPE) - - set(GENERATED_HEADER_CONTENT "#pragma once\n\n#include \n\nnamespace CHEngine::detail\n{\ninline constexpr std::array kBuildPresetNames = {\n${BUILD_PRESET_ENTRIES}};\n} // namespace CHEngine::detail\n") - file(GENERATE OUTPUT "${GENERATED_HEADER}" CONTENT "${GENERATED_HEADER_CONTENT}") + add_custom_command(TARGET ${TARGET} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${RESOURCES_DST}" + COMMAND ${CMAKE_COMMAND} -DSOURCE="${RESOURCES_SRC}" -DDEST="${RESOURCES_DST}" -P "${CMAKE_SOURCE_DIR}/cmake/CopyIfDifferent.cmake" + COMMENT "Syncing engine resources to ${RESOURCES_DST}..." + ) endfunction() -# Centralized target to copy engine resources to the binary directory. -# Making this a target prevents race conditions during parallel builds. -function(chained_add_engine_resources_copy) - if(TARGET EngineResources) - return() - endif() - - set(CH_RESOURCE_SRC_DIR "${CMAKE_SOURCE_DIR}/resources") - set(CH_RESOURCE_DST_DIR "${CMAKE_BINARY_DIR}/bin/resources") - add_custom_command( - OUTPUT "${CH_RESOURCE_DST_DIR}/.copied" - COMMAND ${CMAKE_COMMAND} -E make_directory "${CH_RESOURCE_DST_DIR}" - COMMAND ${CMAKE_COMMAND} -E copy_directory "${CH_RESOURCE_SRC_DIR}" "${CH_RESOURCE_DST_DIR}" - COMMAND ${CMAKE_COMMAND} -E touch "${CH_RESOURCE_DST_DIR}/.copied" - DEPENDS "${CH_RESOURCE_SRC_DIR}" - COMMENT "Copying global engine resources to ${CH_RESOURCE_DST_DIR}..." - ) - - add_custom_target(EngineResources ALL DEPENDS "${CH_RESOURCE_DST_DIR}/.copied") -endfunction() diff --git a/cmake/compilers/CompilerClang.cmake b/cmake/compilers/CompilerClang.cmake new file mode 100644 index 000000000..d64d694f8 --- /dev/null +++ b/cmake/compilers/CompilerClang.cmake @@ -0,0 +1,64 @@ +# Clang Compiler Settings (includes AppleClang and clang-cl) + +if(MSVC) + # clang-cl behaves like MSVC + add_compile_options(/Zc:preprocessor /utf-8 /bigobj) +else() + add_compile_options( + $<$:-O0> $<$:-g> + $<$:-O3> $<$:-DNDEBUG> + $<$:-ffunction-sections> + $<$:-fdata-sections> + ) + + # -mstackrealign: MinGW/ELF Clang at -O0 may misalign the stack, + # causing SSE/AVX faults inside CoreCLR/hostfxr (same as GCC). + if(MINGW OR (NOT WIN32)) + add_compile_options( + $<$:-mstackrealign> + ) + endif() + + if(MINGW) + add_compile_options(-Wa,-mbig-obj) + endif() + + # Dead Code Elimination linkage + if(NOT WIN32) + # Linux/macOS ELF — standard gc-sections + add_link_options( + $<$:-Wl,--gc-sections> + ) + endif() + + # Windows targeting lld-link — /OPT:REF /OPT:ICF replaces --gc-sections. + # Use -Wl, prefix to pass comma-separated options to the linker via the driver. + if(WIN32 AND NOT MINGW) + add_link_options( + $<$:-Wl,/OPT:REF,/OPT:ICF> + ) + endif() + + if(DISABLE_ALL_WARNINGS) + add_compile_options(-w) + elseif(ENABLE_WARNINGS) + add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wmost -Wno-missing-braces -Wno-missing-field-initializers -Wno-attributes) + if(WARNINGS_AS_ERRORS) + add_compile_options(-Werror) + endif() + else() + add_compile_options(-Wno-all) + endif() +endif() + +# LLD linker preference: only on non-Windows (Linux/macOS) where it's not the +# default. On Windows, the MSYS2/LLVM toolchain already sets -fuse-ld=lld-link. + +if(ENABLE_SANITIZERS) + add_compile_options(-fsanitize=address -fsanitize=undefined) + add_link_options(-fsanitize=address -fsanitize=undefined) +endif() + +if(NOT WIN32) + add_link_options(-Wl,-z,relro -Wl,-z,now) +endif() diff --git a/cmake/compilers/CompilerGCC.cmake b/cmake/compilers/CompilerGCC.cmake new file mode 100644 index 000000000..de6c68ddb --- /dev/null +++ b/cmake/compilers/CompilerGCC.cmake @@ -0,0 +1,68 @@ +add_compile_options( + # Debug: Plain -g. We use -mstackrealign because MinGW GCC at -O0 sometimes + # fails to maintain the 16-byte stack alignment required by the Windows x64 ABI. + # This causes Access Violations (AV) inside CoreCLR/hostfxr which uses SSE/AVX + # instructions heavily during initialization and callbacks. LLD handles the + # debug info memory pressure. + $<$:-O0> $<$:-g> $<$:-mstackrealign> + $<$:-O3> $<$:-DNDEBUG> + # Section-per-symbol in all configs so --gc-sections can drop unused code and + # shrink what the linker has to hold in memory. + -ffunction-sections + -fdata-sections +) + +if(MINGW) + add_compile_options(-Wa,-mbig-obj) +endif() + +if(CH_CI) + # CI only runs Debug binaries to execute tests, never steps through them. + # Full -g on GCC (especially MinGW) dominates compile and link time and + # object size; -g1 keeps line tables for readable backtraces. Listed after + # the -g above, so it wins (GCC takes the last debug-level flag). + add_compile_options($<$:-g1>) +endif() + +# Prefer LLD over the default BFD ld: it is dramatically more memory-efficient +# and faster, which is what actually fixes the MinGW Debug link failures. +# Guard on availability so the configure step never fails on toolchains that +# ship without lld (e.g. a minimal Linux CI runner) — there we silently fall +# back to the default linker. +find_program(CH_LLD_LINKER NAMES lld ld.lld lld-link) +if(CH_LLD_LINKER) + add_link_options(-fuse-ld=lld) + message(STATUS "GCC: using LLD linker (${CH_LLD_LINKER})") +else() + message(STATUS "GCC: lld not found, falling back to default linker (heavy Debug links may exhaust memory)") +endif() + +# Suppress overly strict C++23 template body checks for third-party headers (GLM) +add_compile_options(-Wno-template-body) + +# Dead Code Elimination linkage and binary stripping. +# Enabled in all configs now that -ffunction-sections/-fdata-sections are global: +# dropping unused sections also reduces linker memory pressure in Debug. +add_link_options( + -Wl,--gc-sections +) + +if(DISABLE_ALL_WARNINGS) + add_compile_options(-w) +elseif(ENABLE_WARNINGS) + add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wno-missing-field-initializers -Wno-attributes) + if(WARNINGS_AS_ERRORS) + add_compile_options(-Werror) + endif() +else() + add_compile_options(-Wno-all) +endif() + +if(ENABLE_SANITIZERS) + add_compile_options(-fsanitize=address -fsanitize=undefined) + add_link_options(-fsanitize=address -fsanitize=undefined) +endif() + +if(NOT WIN32) + add_link_options(-Wl,-z,relro -Wl,-z,now) +endif() diff --git a/cmake/compilers/CompilerMSVC.cmake b/cmake/compilers/CompilerMSVC.cmake new file mode 100644 index 000000000..14ccaa8dd --- /dev/null +++ b/cmake/compilers/CompilerMSVC.cmake @@ -0,0 +1,47 @@ +# MSVC Compiler Settings + +# MSVC-specific settings + add_compile_options( + $<$:/Od> + $<$:/O2> $<$:/DNDEBUG> + /Zi /EHsc + /MP # Multi-processor compilation + /Zc:preprocessor # Modern preprocessor + /Gm- # Disable minimal rebuild (it's slower) + /utf-8 # Use UTF-8 character set + /bigobj # Allow large object files (required for many modules) + + # Dead Code Elimination: Function-Level Linking + $<$:/Gy> + ) + +# Strip unused functions in Release +add_link_options($<$:/OPT:REF> $<$:/OPT:ICF>) + +# /DEBUG:FULL — FASTLINK is deprecated in VS 2022 toolchain +add_link_options($<$:/DEBUG:FULL>) + +if(DISABLE_ALL_WARNINGS) + add_compile_options(/W0) +elseif(ENABLE_WARNINGS) + add_compile_options(/W4 /permissive-) + if(WARNINGS_AS_ERRORS) + add_compile_options(/WX) + endif() +else() + add_compile_options(/W1) +endif() + +if(ENABLE_SANITIZERS) + add_compile_options(/fsanitize=address) +endif() + +# Level 2 Security Hardening +# Note: /guard:cf on the LINKER pulls in uwapi.lib (Windows App Cert Kit), +# which is absent on the GitHub Actions windows-latest runner. The compile +# flag is sufficient for CFG code-gen; the linker flag is not needed here. +add_compile_options(/guard:cf /GS) +add_link_options(/DYNAMICBASE /NXCOMPAT) + + + \ No newline at end of file diff --git a/cmake/external/assimp.cmake b/cmake/external/assimp.cmake new file mode 100644 index 000000000..8eb5714be --- /dev/null +++ b/cmake/external/assimp.cmake @@ -0,0 +1,29 @@ +# Assimp dependency +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/assimp/CMakeLists.txt") + set(ASSIMP_BUILD_ASSIMP_TOOLS OFF CACHE BOOL "" FORCE) + set(ASSIMP_NO_EXPORT ON CACHE BOOL "" FORCE) + set(ASSIMP_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(ASSIMP_INSTALL OFF CACHE BOOL "" FORCE) + set(ASSIMP_BUILD_ZLIB ON CACHE BOOL "" FORCE) + set(BUILD_SHARED_LIBS ON CACHE BOOL "" FORCE) # Build as shared to reduce dev link times (overrides project default for assimp only) + + # Set model importer formats + set(ASSIMP_BUILD_ALL_IMPORTERS_BY_DEFAULT OFF CACHE INTERNAL "") + + set(ASSIMP_BUILD_GLTF_IMPORTER ON CACHE INTERNAL "") + set(ASSIMP_BUILD_OBJ_IMPORTER ON CACHE INTERNAL "") + set(ASSIMP_BUILD_FBX_IMPORTER ON CACHE INTERNAL "") + + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/assimp" EXCLUDE_FROM_ALL) + + # CRITICAL: Assimp's bundled zlib (contrib/zlib/*.c) has headers without include + # guards (gzguts.h), causing 'typedef redefinition' errors when Unity Build merges + # translation units. Disable Unity Build for both assimp and its internal zlibstatic. + foreach(_assimp_target assimp zlibstatic) + if(TARGET ${_assimp_target}) + set_target_properties(${_assimp_target} PROPERTIES UNITY_BUILD OFF) + endif() + endforeach() +else() + message(FATAL_ERROR "assimp submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/assimp") +endif() diff --git a/cmake/external/cereal.cmake b/cmake/external/cereal.cmake new file mode 100644 index 000000000..ab3c096fe --- /dev/null +++ b/cmake/external/cereal.cmake @@ -0,0 +1,13 @@ +# Cereal dependency +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/cereal/CMakeLists.txt") + # Cereal is header-only, so we just need its include target + set(JUST_INSTALL_CEREAL ON CACHE BOOL "" FORCE) + set(BUILD_SANDBOX OFF CACHE BOOL "" FORCE) + set(SKIP_PERFORMANCE_COMPARISON ON CACHE BOOL "" FORCE) + set(BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(SKIP_PORTABILITY_TEST ON CACHE BOOL "" FORCE) + + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/cereal" "${CMAKE_BINARY_DIR}/vendor/cereal" EXCLUDE_FROM_ALL) +else() + message(FATAL_ERROR "cereal submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/cereal") +endif() diff --git a/cmake/external/coral.cmake b/cmake/external/coral.cmake new file mode 100644 index 000000000..d96126f20 --- /dev/null +++ b/cmake/external/coral.cmake @@ -0,0 +1,59 @@ +# Coral dependency (Scripting host) +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/coral/cmake/CMakeLists.txt") + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/coral/cmake" "${CMAKE_BINARY_DIR}/vendor/coral" EXCLUDE_FROM_ALL) + + # Coral.Native must be compiled in strict isolation from the engine's unity/PCH build. + # MSVC in C++20 mode deletes operator<<(const wchar_t*) on narrow ostreams (strict + # conformance change). Coral internally uses wchar_t conversion helpers that trip this + # when compiled inside a merged unity TU that includes the engine's C++20 PCH. + # Solution: force C++17, disable unity build for this target. + set_target_properties(Coral.Native PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF + UNITY_BUILD OFF + ) + + # MSVC-specific: suppress size-conversion warnings that are expected in Coral + if(MSVC) + target_compile_options(Coral.Native PRIVATE /wd4267 /wd4244 /wd4018) + endif() + + if(WIN32) + set(CORAL_FIX_DIR "${CMAKE_BINARY_DIR}/vendor/coral_fixes") + if(NOT EXISTS "${CORAL_FIX_DIR}") + file(MAKE_DIRECTORY "${CORAL_FIX_DIR}") + endif() + + # MinGW fixes for Coral + if(MINGW) + file(WRITE "${CORAL_FIX_DIR}/ShlObj_core.h" "#pragma once\n#include \n") + target_include_directories(Coral.Native PRIVATE "${CORAL_FIX_DIR}") + + # MinGW GCC at -O0 emits prologues that can leave the stack + # misaligned at the call boundary into hostfxr/coreclr.dll, whose + # SSE/AVX init code then faults with an access violation (CI-only, + # timing/layout dependent; -mstackrealign alone does not cover the + # cross-DLL call path). -Og keeps debuggability but restores the + # aligned prologue codegen, matching the Release behavior. + target_compile_options(Coral.Native PRIVATE $<$:-Og>) + endif() + + # CI environment LLVM Clang / Windows SDK fix for missing CoTaskMemAlloc + file(WRITE "${CORAL_FIX_DIR}/CombaseFix.hpp" + "#pragma once\n" + "#ifdef _WIN32\n" + "#include \n" + "#include \n" + "#include \n" + "#endif\n" + ) + if(MSVC) + target_compile_options(Coral.Native PRIVATE "/FI${CORAL_FIX_DIR}/CombaseFix.hpp") + else() + target_compile_options(Coral.Native PRIVATE "-include" "${CORAL_FIX_DIR}/CombaseFix.hpp") + endif() + endif() +else() + message(FATAL_ERROR "Coral submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/coral/cmake") +endif() diff --git a/cmake/external/enet.cmake b/cmake/external/enet.cmake new file mode 100644 index 000000000..3bde8168c --- /dev/null +++ b/cmake/external/enet.cmake @@ -0,0 +1,17 @@ +# ENet — Reliable UDP networking library +# Provides: client/server, reliable/unreliable channels, fragmentation. +# Single-header variant (enet.h with ENET_IMPLEMENTATION). +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/enet") + set(ENET_STATIC ON CACHE BOOL "" FORCE) + set(ENET_SHARED OFF CACHE BOOL "" FORCE) + set(ENET_TEST OFF CACHE BOOL "" FORCE) + # Force IPv4-only sockets: ENet's default AF_INET6 dual-stack fails to + # bind on some Windows systems. IPv4 is reliable everywhere we ship. + set(ENET_IPV4_ONLY ON CACHE BOOL "" FORCE) + + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/enet" "${CMAKE_BINARY_DIR}/enet") + + message(STATUS "enet: configured (static lib, IPv4-only)") +else() + message(FATAL_ERROR "enet missing at ${CMAKE_SOURCE_DIR}/thirdparty/enet") +endif() diff --git a/cmake/external/entt.cmake b/cmake/external/entt.cmake new file mode 100644 index 000000000..0c3033e34 --- /dev/null +++ b/cmake/external/entt.cmake @@ -0,0 +1,7 @@ +# EnTT dependency (Header-only) +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/entt/src") + add_library(engine_external_entt INTERFACE) + target_include_directories(engine_external_entt INTERFACE "${CMAKE_SOURCE_DIR}/thirdparty/entt/src") +else() + message(FATAL_ERROR "EnTT submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/entt/src") +endif() diff --git a/cmake/external/external_gtest.cmake b/cmake/external/external_gtest.cmake new file mode 100644 index 000000000..a3fcd1920 --- /dev/null +++ b/cmake/external/external_gtest.cmake @@ -0,0 +1,6 @@ +# GoogleTest dependency +if( EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/googletest/CMakeLists.txt") + set(gtest_force_shared_crt OFF CACHE BOOL "" FORCE) + set(BUILD_SHARED_LIBS OFF) + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/googletest" "${CMAKE_BINARY_DIR}/vendor/googletest" EXCLUDE_FROM_ALL) +endif() diff --git a/cmake/external/freetype.cmake b/cmake/external/freetype.cmake new file mode 100644 index 000000000..9d5e110ed --- /dev/null +++ b/cmake/external/freetype.cmake @@ -0,0 +1,16 @@ +# FreeType2 — configured as a static library with minimal options +if(TARGET freetype) + return() +endif() + +set(FT_DISABLE_HARFBUZZ ON CACHE BOOL "" FORCE) +set(FT_DISABLE_BROTLI ON CACHE BOOL "" FORCE) +set(FT_DISABLE_BZIP2 ON CACHE BOOL "" FORCE) +set(FT_DISABLE_PNG ON CACHE BOOL "" FORCE) +set(FT_DISABLE_LIBPNG ON CACHE BOOL "" FORCE) +set(FT_DISABLE_BROTLI ON CACHE BOOL "" FORCE) +set(FT_DISABLE_UNSYSCALL_HACK ON CACHE BOOL "" FORCE) +# Build as static lib with /MT on MSVC +set(FT_WITH_ZLIB OFF CACHE BOOL "" FORCE) + +add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/freetype" "${CMAKE_BINARY_DIR}/freetype" EXCLUDE_FROM_ALL) diff --git a/cmake/external/freetype_gl.cmake b/cmake/external/freetype_gl.cmake new file mode 100644 index 000000000..62f0a5145 --- /dev/null +++ b/cmake/external/freetype_gl.cmake @@ -0,0 +1,34 @@ +# freetype-gl — minimal build: only atlas + font (no vertex-buffer / text-buffer / OpenGL) +if(TARGET engine_freetype_gl) + return() +endif() + +set(FREETYPE_GL_DIR "${CMAKE_SOURCE_DIR}/thirdparty/freetype-gl") + +add_library(engine_freetype_gl STATIC + "${FREETYPE_GL_DIR}/texture-atlas.c" + "${FREETYPE_GL_DIR}/texture-font.c" + "${FREETYPE_GL_DIR}/vector.c" + "${FREETYPE_GL_DIR}/utf8-utils.c" + "${FREETYPE_GL_DIR}/ftgl-utils.c" + "${FREETYPE_GL_DIR}/distance-field.c" + "${FREETYPE_GL_DIR}/edtaa3func.c" +) + +target_include_directories(engine_freetype_gl PUBLIC "${FREETYPE_GL_DIR}") +target_link_libraries(engine_freetype_gl PUBLIC freetype) + +# freetype-gl headers declare ftgl namespace when compiled as C++ +# — suppress any warnings from its C code on MSVC +if(MSVC) + target_compile_options(engine_freetype_gl PRIVATE /W0) +else() + target_compile_options(engine_freetype_gl PRIVATE -w) +endif() + +# freetype-gl's texture-font.c has a broken fallback for MSVC that +# undefines inline and tries to define __builtin_bswap32 — define +# __GNUC__ for clang so it skips that code path. +if(CMAKE_C_COMPILER_ID MATCHES "Clang") + target_compile_definitions(engine_freetype_gl PRIVATE __GNUC__) +endif() diff --git a/cmake/external/glad.cmake b/cmake/external/glad.cmake new file mode 100644 index 000000000..d0d3f9b33 --- /dev/null +++ b/cmake/external/glad.cmake @@ -0,0 +1,7 @@ +# GLAD dependency +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/glad/cmake/CMakeLists.txt") + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/glad/cmake" "${CMAKE_BINARY_DIR}/vendor/glad" EXCLUDE_FROM_ALL) + glad_add_library(engine_external_glad STATIC API gl:core=4.3) +else() + message(FATAL_ERROR "GLAD submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/glad/cmake") +endif() diff --git a/cmake/external/glfw.cmake b/cmake/external/glfw.cmake new file mode 100644 index 000000000..8550b54b9 --- /dev/null +++ b/cmake/external/glfw.cmake @@ -0,0 +1,20 @@ +# GLFW dependency +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/glfw/CMakeLists.txt") + set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE) + set(GLFW_INSTALL OFF CACHE BOOL "" FORCE) + + # On Linux, GLFW builds both X11 and Wayland backends by default. The Wayland + # backend requires wayland-scanner + libwayland-dev at configure time, which we + # don't ship on CI (or most dev machines). X11 alone is sufficient for our + # OpenGL renderer, so disable Wayland to avoid a hard configure failure. + if(UNIX AND NOT APPLE) + set(GLFW_BUILD_X11 ON CACHE BOOL "" FORCE) + set(GLFW_BUILD_WAYLAND OFF CACHE BOOL "" FORCE) + endif() + + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/glfw" "${CMAKE_BINARY_DIR}/vendor/glfw" EXCLUDE_FROM_ALL) +else() + message(FATAL_ERROR "GLFW submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/glfw") +endif() diff --git a/cmake/external/glm.cmake b/cmake/external/glm.cmake new file mode 100644 index 000000000..072c4cbfb --- /dev/null +++ b/cmake/external/glm.cmake @@ -0,0 +1,6 @@ +# GLM dependency +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/glm/CMakeLists.txt") + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/glm" "${CMAKE_BINARY_DIR}/vendor/glm" EXCLUDE_FROM_ALL) +else() + message(FATAL_ERROR "glm submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/glm") +endif() diff --git a/cmake/external/imgui.cmake b/cmake/external/imgui.cmake new file mode 100644 index 000000000..f5c0085d5 --- /dev/null +++ b/cmake/external/imgui.cmake @@ -0,0 +1,47 @@ +# ImGui + ImGuizmo dependency +set(IMGUI_DIR "${CMAKE_SOURCE_DIR}/thirdparty/imgui") +set(IMGUIZMO_DIR "${CMAKE_SOURCE_DIR}/thirdparty/imguizmo") + +set(IMGUI_PLATFORM_SOURCES "${IMGUI_DIR}/backends/imgui_impl_glfw.cpp") +set(IMGUI_PLATFORM_DEPS glfw) + +add_library(engine_external_imgui STATIC + "${IMGUI_DIR}/imgui.cpp" + "${IMGUI_DIR}/imgui_draw.cpp" + "${IMGUI_DIR}/imgui_widgets.cpp" + "${IMGUI_DIR}/imgui_tables.cpp" + "${IMGUI_DIR}/imgui_demo.cpp" + "${IMGUI_DIR}/misc/cpp/imgui_stdlib.cpp" + ${IMGUI_PLATFORM_SOURCES} + "${IMGUI_DIR}/backends/imgui_impl_opengl3.cpp" + "${IMGUIZMO_DIR}/ImGuizmo.cpp" + "${IMGUIZMO_DIR}/GraphEditor.cpp" + "${IMGUIZMO_DIR}/ImCurveEdit.cpp" + "${IMGUIZMO_DIR}/ImGradient.cpp" + "${IMGUIZMO_DIR}/ImSequencer.cpp" +) + +target_include_directories(engine_external_imgui PUBLIC + "${IMGUI_DIR}" + "${IMGUIZMO_DIR}" + "${IMGUI_DIR}/backends" +) + +target_link_libraries(engine_external_imgui + PUBLIC + ${IMGUI_PLATFORM_DEPS} + engine_external_glad +) + +if(UNIX AND NOT APPLE) + target_link_libraries(engine_external_imgui PUBLIC X11) +endif() + +target_compile_definitions(engine_external_imgui PUBLIC + IMGUI_DEFINE_MATH_OPERATORS + IMGUI_IMPL_OPENGL_LOADER_GLAD +) + +target_compile_definitions(engine_external_imgui PUBLIC GLFW_INCLUDE_NONE CH_PLATFORM_BACKEND_GLFW) + +set_target_properties(engine_external_imgui PROPERTIES UNITY_BUILD OFF) diff --git a/cmake/external/jolt.cmake b/cmake/external/jolt.cmake new file mode 100644 index 000000000..476b59ab1 --- /dev/null +++ b/cmake/external/jolt.cmake @@ -0,0 +1,20 @@ +# Chained Engine - Jolt Physics Dependency + +set(INTERPROCEDURAL_OPTIMIZATION OFF CACHE BOOL "" FORCE) +set(FLOATING_POINT_EXCEPTIONS_ENABLED OFF CACHE BOOL "" FORCE) +set(USE_SSE4_2 ON CACHE BOOL "" FORCE) +set(USE_AVX2 ON CACHE BOOL "" FORCE) +set(USE_WERROR OFF CACHE BOOL "" FORCE) +set(CPP_RTTI_ENABLED ON CACHE BOOL "" FORCE) # Required for Linux CI - engine uses RTTI +set(TARGET_UNIT_TESTS OFF CACHE BOOL "" FORCE) +set(TARGET_HELLO_WORLD OFF CACHE BOOL "" FORCE) +set(TARGET_PERFORMANCE_TEST OFF CACHE BOOL "" FORCE) +set(TARGET_VIEWER OFF CACHE BOOL "" FORCE) + +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/JoltPhysics/Build/CMakeLists.txt") + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/JoltPhysics/Build" "${CMAKE_BINARY_DIR}/vendor/jolt" EXCLUDE_FROM_ALL) +else() + message(FATAL_ERROR "JoltPhysics submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/JoltPhysics") +endif() + + diff --git a/cmake/external/miniaudio.cmake b/cmake/external/miniaudio.cmake new file mode 100644 index 000000000..31d0c4383 --- /dev/null +++ b/cmake/external/miniaudio.cmake @@ -0,0 +1,7 @@ +# Miniaudio dependency (Header-only) +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/miniaudio") + add_library(engine_external_miniaudio INTERFACE) + target_include_directories(engine_external_miniaudio INTERFACE "${CMAKE_SOURCE_DIR}/thirdparty/miniaudio") +else() + message(FATAL_ERROR "Miniaudio submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/miniaudio") +endif() diff --git a/cmake/external/miniupnpc.cmake b/cmake/external/miniupnpc.cmake new file mode 100644 index 000000000..791676224 --- /dev/null +++ b/cmake/external/miniupnpc.cmake @@ -0,0 +1,55 @@ +# miniupnpc — lightweight UPnP IGD client +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/miniupnp/miniupnpc") + set(MINIUPNPC_DIR "${CMAKE_SOURCE_DIR}/thirdparty/miniupnp/miniupnpc") + + # Generate miniupnpcstrings.h (config header) + file(WRITE "${CMAKE_BINARY_DIR}/generated/miniupnpcstrings.h" + "#ifndef MINIUPNPCSTRINGS_H_INCLUDED\n" + "#define MINIUPNPCSTRINGS_H_INCLUDED\n" + "\n" + "#define OS_STRING \"${CMAKE_SYSTEM_NAME}\"\n" + "#define MINIUPNPC_VERSION_STRING \"2.3.0\"\n" + "\n" + "#define UPNP_VERSION_MAJOR 1\n" + "#define UPNP_VERSION_MINOR 1\n" + "#define UPNP_VERSION_STRING \"UPnP/1.1\"\n" + "\n" + "#endif\n" + ) + + add_library(engine_external_miniupnpc STATIC + ${MINIUPNPC_DIR}/src/miniupnpc.c + ${MINIUPNPC_DIR}/src/miniwget.c + ${MINIUPNPC_DIR}/src/minisoap.c + ${MINIUPNPC_DIR}/src/minixml.c + ${MINIUPNPC_DIR}/src/igd_desc_parse.c + ${MINIUPNPC_DIR}/src/upnpcommands.c + ${MINIUPNPC_DIR}/src/upnperrors.c + ${MINIUPNPC_DIR}/src/upnpreplyparse.c + ${MINIUPNPC_DIR}/src/portlistingparse.c + ${MINIUPNPC_DIR}/src/receivedata.c + ${MINIUPNPC_DIR}/src/connecthostport.c + ${MINIUPNPC_DIR}/src/addr_is_reserved.c + ${MINIUPNPC_DIR}/src/minissdpc.c + ${MINIUPNPC_DIR}/src/upnpdev.c + ) + + target_include_directories(engine_external_miniupnpc PUBLIC + ${MINIUPNPC_DIR}/include + ) + + target_include_directories(engine_external_miniupnpc PRIVATE + ${CMAKE_BINARY_DIR}/generated + ) + + # MINIUPNP_STATICLIB (2 N's) — this is what the header checks + target_compile_definitions(engine_external_miniupnpc PUBLIC + MINIUPNP_STATICLIB + ) + + if(WIN32) + target_link_libraries(engine_external_miniupnpc PUBLIC ws2_32 iphlpapi) + endif() +else() + message(FATAL_ERROR "miniupnpc submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/miniupnp") +endif() diff --git a/cmake/external/nfd.cmake b/cmake/external/nfd.cmake new file mode 100644 index 000000000..a03132963 --- /dev/null +++ b/cmake/external/nfd.cmake @@ -0,0 +1,5 @@ +# NFD dependency +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/nfd/CMakeLists.txt") + set(NFD_BUILD_TESTS OFF CACHE BOOL "" FORCE) + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/nfd" "${CMAKE_BINARY_DIR}/vendor/nfd" EXCLUDE_FROM_ALL) +endif() diff --git a/cmake/external/pack.cmake b/cmake/external/pack.cmake new file mode 100644 index 000000000..8b08a0465 --- /dev/null +++ b/cmake/external/pack.cmake @@ -0,0 +1,15 @@ +# pack dependency +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/pack/CMakeLists.txt") + set(gtest_force_shared_crt OFF CACHE BOOL "" FORCE) + set(BUILD_SHARED_LIBS OFF) + set(PACK_BUILD_TESTS OFF CACHE BOOL "" FORCE) + + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/pack" + "${CMAKE_BINARY_DIR}/vendor/pack" EXCLUDE_FROM_ALL) + + # GCC 14+ treats -Wincompatible-pointer-types as error. + # mpio/source/os.c passes char** to _spawvp() which expects const char* const*. + if(TARGET mpio-static AND CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(mpio-static PRIVATE -Wno-error=incompatible-pointer-types) + endif() +endif() \ No newline at end of file diff --git a/cmake/external/portable-file-dialogs.cmake b/cmake/external/portable-file-dialogs.cmake new file mode 100644 index 000000000..9e777a680 --- /dev/null +++ b/cmake/external/portable-file-dialogs.cmake @@ -0,0 +1,5 @@ +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/portable-file-dialogs/CMakeLists.txt") + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/portable-file-dialogs" "${CMAKE_BINARY_DIR}/vendor/portable-file-dialogs" EXCLUDE_FROM_ALL) +else() + message(FATAL_ERROR "portable-file-dialogs submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/portable-file-dialogs") +endif() diff --git a/cmake/external/reflect-cpp.cmake b/cmake/external/reflect-cpp.cmake new file mode 100644 index 000000000..f0771d9ba --- /dev/null +++ b/cmake/external/reflect-cpp.cmake @@ -0,0 +1,10 @@ +set(REFLECTCPP_YAML ON CACHE BOOL "" FORCE) +set(REFLECTCPP_JSON ON CACHE BOOL "" FORCE) +set(REFLECTCPP_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(REFLECTCPP_BUILD_SHARED OFF CACHE BOOL "" FORCE) + +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/reflect-cpp/CMakeLists.txt") + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/reflect-cpp" EXCLUDE_FROM_ALL) +else() + message(FATAL_ERROR "reflect-cpp submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/reflect-cpp") +endif() diff --git a/cmake/external/sodium.cmake b/cmake/external/sodium.cmake new file mode 100644 index 000000000..ec7c82b36 --- /dev/null +++ b/cmake/external/sodium.cmake @@ -0,0 +1,17 @@ +# sodium — minimal bundled subset (XChaCha20-Poly1305 AEAD only) +# Extracted from yojimbo's vendored sodium. Provides encryption primitives +# for the networking layer. +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/sodium") + add_library(sodium STATIC "${CMAKE_SOURCE_DIR}/thirdparty/sodium/sodium.c") + target_include_directories(sodium PUBLIC "${CMAKE_SOURCE_DIR}/thirdparty/sodium") + set_target_properties(sodium PROPERTIES POSITION_INDEPENDENT_CODE ON) + if(MSVC) + target_compile_options(sodium PRIVATE /w) + else() + target_compile_options(sodium PRIVATE -w) + endif() + + message(STATUS "sodium: configured (minimal bundled subset)") +else() + message(FATAL_ERROR "sodium missing at ${CMAKE_SOURCE_DIR}/thirdparty/sodium") +endif() diff --git a/cmake/external/spdlog.cmake b/cmake/external/spdlog.cmake new file mode 100644 index 000000000..58c2e67db --- /dev/null +++ b/cmake/external/spdlog.cmake @@ -0,0 +1,17 @@ +# Configuration for spdlog 1.14.x +set(SPDLOG_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(SPDLOG_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(SPDLOG_BUILD_BENCH OFF CACHE BOOL "" FORCE) +set(SPDLOG_FMT_EXTERNAL OFF CACHE BOOL "" FORCE) +set(SPDLOG_POSITION_INDEPENDENT_CODE ON CACHE BOOL "" FORCE) + +# Use C++20 std::format for spdlog to avoid bundled fmt library issues with C++23/Clang +# Since the project already uses std::format successfully, this is the most robust fix. +set(SPDLOG_USE_STD_FORMAT ON CACHE BOOL "" FORCE) + +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/spdlog/CMakeLists.txt") + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/spdlog" "${CMAKE_BINARY_DIR}/vendor/spdlog" EXCLUDE_FROM_ALL) +else() + message(FATAL_ERROR "Spdlog submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/spdlog") +endif() + diff --git a/cmake/external/stb.cmake b/cmake/external/stb.cmake new file mode 100644 index 000000000..2991c7616 --- /dev/null +++ b/cmake/external/stb.cmake @@ -0,0 +1,7 @@ +# STB dependency (Header-only) +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/stb") + add_library(engine_external_stb INTERFACE) + target_include_directories(engine_external_stb INTERFACE "${CMAKE_SOURCE_DIR}/thirdparty/stb") +else() + message(FATAL_ERROR "STB submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/stb") +endif() diff --git a/cmake/external/yaml-cpp.cmake b/cmake/external/yaml-cpp.cmake new file mode 100644 index 000000000..438cf900a --- /dev/null +++ b/cmake/external/yaml-cpp.cmake @@ -0,0 +1,23 @@ +# yaml-cpp dependency +if(EXISTS "${CMAKE_SOURCE_DIR}/thirdparty/yaml-cpp/CMakeLists.txt") + set(YAML_CPP_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(YAML_CPP_BUILD_TOOLS OFF CACHE BOOL "" FORCE) + set(YAML_CPP_BUILD_CONTRIB OFF CACHE BOOL "" FORCE) + set(YAML_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + set(YAML_MSVC_SHARED_RT OFF CACHE BOOL "" FORCE) + + add_subdirectory("${CMAKE_SOURCE_DIR}/thirdparty/yaml-cpp" "${CMAKE_BINARY_DIR}/vendor/yaml-cpp" EXCLUDE_FROM_ALL) + + # yaml-cpp 0.8.0's emitterutils.cpp uses uint16_t without including . + # It compiled only because older standard libraries leaked transitively. + # MinGW's libstdc++ (shared by BOTH Windows Clang and Windows GCC) no longer does, + # so those two toolchains fail with "'uint16_t' was not declared in this scope" + # while Linux and MSVC still build. Force-include the header for GCC/Clang instead + # of patching the submodule source (which would revert on submodule update). MSVC + # neither needs this nor understands -include, so it is excluded. + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(yaml-cpp PRIVATE -include cstdint) + endif() +else() + message(FATAL_ERROR "yaml-cpp submodule missing at ${CMAKE_SOURCE_DIR}/thirdparty/yaml-cpp") +endif() diff --git a/cmake/external/zstd.cmake b/cmake/external/zstd.cmake new file mode 100644 index 000000000..63e5e6f5e --- /dev/null +++ b/cmake/external/zstd.cmake @@ -0,0 +1,27 @@ +# Zstandard compression library +set(ZSTD_BUILD_PROGRAMS OFF CACHE BOOL "Build zstd programs" FORCE) +set(ZSTD_BUILD_TESTS OFF CACHE BOOL "Build zstd tests" FORCE) +set(ZSTD_BUILD_CONTRIB OFF CACHE BOOL "Build zstd contrib" FORCE) +set(ZSTD_BUILD_STATIC ON CACHE BOOL "Build zstd static library" FORCE) +set(ZSTD_BUILD_SHARED OFF CACHE BOOL "Build zstd shared library" FORCE) +set(ZSTD_LEGACY_SUPPORT OFF CACHE BOOL "Zstd legacy support" FORCE) + +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/zstd/build/cmake EXCLUDE_FROM_ALL) + +# Unity build causes redefinition errors in cover.h (no include guard) +if(TARGET libzstd_static) + set_target_properties(libzstd_static PROPERTIES UNITY_BUILD OFF) +endif() + +# --- Create zstd:: namespace aliases for downstream consumers (pak_archive) --- +# This runs immediately after zstd targets are created, so aliases are ready +# before pak_archive.cmake is included. +if(TARGET libzstd_static AND NOT TARGET zstd::libzstd_static) + add_library(zstd::libzstd_static ALIAS libzstd_static) +endif() +if(TARGET libzstd_shared AND NOT TARGET zstd::libzstd_shared) + add_library(zstd::libzstd_shared ALIAS libzstd_shared) +endif() + +set(ZSTD_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/zstd/lib CACHE PATH "Zstd include directory" FORCE) +set(ZSTD_LIBRARY libzstd_static CACHE STRING "Zstd library target" FORCE) diff --git a/docs/ANIMATION_GRAPHS.md b/docs/ANIMATION_GRAPHS.md new file mode 100644 index 000000000..ac481f12f --- /dev/null +++ b/docs/ANIMATION_GRAPHS.md @@ -0,0 +1,137 @@ +# Animation Graph Tutorial + +Chained Engine features a visual, data-driven Animation Graph system (`.chag`) for driving state machine animations with smooth blending. + +## Table of Contents + +- [Overview & Graph File Format](#1-overview--graph-file-format-chag) +- [Editor Workflow](#2-editor-workflow-animgraphpanel) +- [Driving from C# Scripts](#3-driving-animation-graph-from-c-scripts) +- [Transition Condition Operators](#4-transition-condition-comparison-operators) +- [Runtime vs. Edit Mode](#5-runtime-vs-edit-mode-execution) + +--- + +## 1. Overview & Graph File Format (`.chag`) + +Animation Graphs are node-based state machines serialized as YAML. A graph contains: +- **Default Variables**: Declares the schema of parameters (e.g., `speed`, `isMoving`, `isGrounded`). +- **Nodes (`AnimNode`)**: Define animation states, frame ranges, looping, and playback speed. +- **Transitions (`AnimTransition`)**: Define connections between states with blend durations and variable-based conditions. + +[![Znimok-ekrana-2026-08-01-173424.png](https://i.postimg.cc/sgHrQcjq/Znimok-ekrana-2026-08-01-173424.png)](https://postimg.cc/FYSqw0JV) + +[![Znimok-ekrana-2026-08-01-175555.png](https://i.postimg.cc/bNJ8zW8D/Znimok-ekrana-2026-08-01-175555.png)](https://postimg.cc/kDkLsj2q) + +Example `new_graph.chag` structure: + +```yaml +AnimationGraph: + EntryNodeID: 4 + NextNodeID: 5 + Variables: + speed: 0 + isMoving: 0 + isGrounded: 1 + Nodes: + - ID: 1 + Name: Walk + AnimationIndex: 0 + IsLooping: true + StartFrame: 690 + EndFrame: 780 + Speed: 1.0 + - ID: 2 + Name: Run + AnimationIndex: 0 + IsLooping: true + StartFrame: 1450 + EndFrame: 1500 + Speed: 1.0 + - ID: 4 + Name: Stop + AnimationIndex: 0 + IsLooping: true + StartFrame: 0 + EndFrame: 0 + Speed: 1.0 + Transitions: + - ID: 1 + SourceNodeID: 1 + TargetNodeID: 2 + BlendDuration: 0.2 + HasExitTime: false + Conditions: + - VariableName: speed + Op: 4 # GreaterThan (>) + Value: 0.9 +``` + +## 2. Editor Workflow (`AnimGraphPanel`) + +1. **Open the Animation Graph Panel:** In the editor top menu, open **Panels -> Animation Graph Editor**. +2. **Create/Load Graph:** Click **New Graph** or select an existing `.chag` file. +3. **Configure Nodes:** + - Add nodes for states like `Idle`/`Stop`, `Walk`, `Run`, `Jump`. + - Set `StartFrame` and `EndFrame` according to your model asset's animation tracks. +4. **Manage Variables:** + - Add variables (e.g., `speed`, `isMoving`, `isGrounded`). + - Use the type toggle in the editor to switch between `Float` and `Bool` parameter types. +5. **Create Transitions & Conditions:** + - Drag connections between nodes. + - Select a transition to edit `BlendDuration` (e.g., `0.2s` crossfade) and add conditions (e.g., `isMoving == 1`, `speed >= 0.9`). +6. **Assign to Entity:** + - Select an entity with an `AnimationComponent`. + - Set `GraphPath` to `assets/animations/new_graph.chag`. + +## 3. Driving Animation Graph from C# Scripts + +C# gameplay scripts pass parameters directly to `AnimationComponent`. The engine automatically seeds graph variables on entity initialization and updates state transitions at runtime during simulation. + +```csharp +using Chained; + +public class PlayerController : Script +{ + private AnimationComponent? m_Anim; + + public override void OnCreate() + { + m_Anim = GetComponent(); + } + + public override void OnUpdate(float ts) + { + Vector3 movement = GetInputVector(); + bool isMoving = movement.LengthSquared() > 0.01f; + bool isSprinting = Input.IsKeyDown(Key.LeftShift); + + float speed = 0.0f; + if (isMoving) + { + speed = isSprinting ? 1.0f : 0.5f; + } + + // Drive graph variables dynamically from script + m_Anim?.SetBool("isMoving", isMoving); + m_Anim?.SetFloat("speed", speed); + m_Anim?.SetBool("isGrounded", IsGrounded()); + } +} +``` + +## 4. Transition Condition Comparison Operators + +| Op Code | Operator | Description | +| :--- | :--- | :--- | +| `0` | `==` | Equal | +| `1` | `!=` | Not Equal | +| `2` | `<` | Less Than | +| `3` | `<=` | Less Than or Equal | +| `4` | `>` | Greater Than | +| `5` | `>=` | Greater Than or Equal | + +## 5. Runtime vs. Edit Mode Execution + +- **Simulation Mode (`Play`):** C# scripts update `AnimationComponent.Variables`, and `AnimationSystem` evaluates transitions frame-by-frame with smooth interpolation. +- **Edit Mode:** Animation transitions are paused to keep the editor viewport stable and prevent state jitter while authoring. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9867a78c6..de4e497f0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,39 +1,81 @@ # Chained Engine Architecture -This document provides a deep dive into the internal structure of Chained Engine, from the entry point to the main loop. +This document describes the current runtime structure of Chained Engine, from executable entry points to the main loop. ## 1. Bootstrapping Flow -The engine uses a modular bootstrapping system centered around the `ProjectLauncher` class. This allows the engine to be initialized in different modes (Editor, Runtime, or Headless) without duplicating initialization logic. +The engine uses a small `main` wrapper in `entry_point.h` and delegates application construction to a per-executable `CreateApplication` function. -### Entry Point (`main`) -The `main` function (found in `entry_point.h`) is minimalist. It delegages application creation to a project-specific `CreateApplication` function. +### Bootstrapping Diagram +```mermaid +graph TD + A[main entry_point.h] --> B[CreateApplication] + B --> C[ApplicationSpec] + C --> D[Initialize Core Services] + D --> E[LayerStack Update Loop] + E --> F[Shutdown] +``` -### ProjectLauncher -The `ProjectLauncher` utility is responsible for: -1. **Parsing Command Line Arguments**: Determining the project path and window overrides. -2. **Loading Project Metadata**: Reading the `.chproject` YAML file. -3. **Preparing Application Specifications**: Setting VSync, window dimensions, and titles based on project data. +### Entry Point Code Example +```cpp +// game/src/main.cpp +#include "engine/core/application.h" +#include "engine/core/entry_point.h" -## 2. System Initialization (SRP) +namespace Chained { + Application* CreateApplication(ApplicationCommandLineArgs args) { + ApplicationSpecification spec; + spec.Name = "Chained Game"; + spec.WindowWidth = 1600; + spec.WindowHeight = 900; + + return new Application(spec); + } +} +``` -We adhere to the **Single Responsibility Principle**. Instead of a monolithic initialization block, each engine system is responsible for its own setup. +### Project Selection and Discovery +Startup usually follows this path: +1. `CreateApplication` fills `ApplicationSpecification` from CLI args and default window settings. +2. `Application` is constructed and initializes core services. +3. Runtime-specific startup may discover or load a project through `Project::Discover` and `Project::Load`. +4. The executable attaches either `EditorLayer` or `RuntimeLayer`. -### Decentralized Asset Loaders -Asset loaders are registered during the `Init()` phase of the relevant subsystem: -* **Renderer**: Registers `TextureLoader`, `ModelLoader`, `ShaderLoader`, and `EnvironmentLoader`. -* **UIRenderer**: Registers `FontLoader`. +## 2. System Initialization + +Initialization is centralized in `Application`. That keeps startup predictable, but it also makes `Application` a coupling point for unrelated systems. + +### What `Application` Owns +`Application` currently creates and coordinates the window, `ThreadPool`, `ComponentSerializer`, `AssetManager`, `Renderer`, `TextureSystem`, `Audio`, `PhysicsSystem`, `UIRenderer`, and `ScriptEngine`. + +### Current Tradeoff +This is not a pure SRP split. The benefit is that bootstrap order is explicit. The cost is that changes to service lifecycle, headless mode, or renderer setup tend to ripple through `Application`. ## 3. Layer Stack Model -The engine handles functionality through a `LayerStack`. Layers are processed in the following order: -1. **Engine Layers** (e.g., `RuntimeLayer` or `EditorLayer`): Handle the main logic and viewport rendering. -2. **Overlays** (e.g., `ImGuiLayer`): Render UI and debugging information on top of the layers. +The engine uses a `LayerStack` for gameplay and editor/runtime behavior. + +1. `EditorLayer` or `RuntimeLayer` owns the primary experience. +2. `ImGuiLayer` is pushed as an overlay in non-headless runs. +3. Layers rely on shared process-wide services through `ServiceLocator` and `Application::Get()`. ## 4. Main Loop -The `Application::Run()` method is the heart of the engine: -1. **Timing**: Calculates `Timestep` (delta time). -2. **Updates**: Iterates through the `LayerStack`, calling `OnUpdate` for each layer. -3. **UI Rendering**: Calls `Begin()` on `ImGuiLayer`, then `OnImGuiRender` for all layers, and finally `End()`. -4. **Events**: Dispatches platform events (input, window resize) through the stack. +`Application::Run()` drives the frame loop: +1. Update timing and frame delta. +2. Poll input and platform events. +3. Tick engine services. +4. Run fixed-step updates on the layer stack. +5. Run per-frame layer updates. +6. Render scene layers, then render ImGui, then present the frame. + +## 5. Architectural Pressure Points + +The current shape works, but it has a few clear friction points: +1. Service lifecycle order is encoded in registration order, which is easy to break. +2. Runtime and editor logic both reach back into global state instead of depending on explicit interfaces. +3. Entry-point setup and runtime project loading both interpret CLI and project configuration, which duplicates startup policy. +4. `Application` owns too many unrelated concerns, so it is the main place where startup regressions accumulate. + +> [!NOTE] +> Recent architectural improvements include the move of core gameplay logic (like Scene Transitions) into native C++ systems to reduce managed overhead and improve predictability. diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 7507cfeea..9bb6ed354 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -1,55 +1,287 @@ # Component Reference -Chained Engine uses a pure ECS (Entity Component System) architecture powered by **EnTT**. Below is a list of the most commonly used components. +Chained Engine uses a pure ECS (Entity Component System) architecture powered by **EnTT**. Below is a list of the most commonly used components with examples. ## Core Components ### `IDComponent` -* **Description**: Stores the globally unique identifier (UUID) for an entity. -* **Properties**: `ID` (UUID). +- **Description**: Stores the globally unique identifier (UUID) for an entity. +- **Properties**: `ID` (UUID) +- **YAML Example**: + + ```yaml + IDComponent: + ID: 13765047633248252361 + ``` + +- **C++ Example**: + + ```cpp + auto uuid = entity.GetComponent().ID; + ``` ### `TagComponent` -* **Description**: A human-readable name for searching and identification in the editor. -* **Properties**: `Tag` (string). +- **Description**: A human-readable name for searching and identification. +- **Properties**: `Tag` (string) +- **YAML Example**: + + ```yaml + TagComponent: + Tag: Main Player + ``` + +- **C++ Example**: + + ```cpp + entity.GetComponent().Tag = "New Name"; + ``` ### `TransformComponent` -* **Description**: Defines the position, rotation, and scale of the entity in 3D space. -* **Properties**: `Translation`, `Rotation` (Euler angles), `Scale`. +- **Description**: Defines the position, rotation, and scale. +- **Properties**: `Translation`, `Rotation` (Euler), `Scale` +- **YAML Example**: + + ```yaml + TransformComponent: + Translation: [0, 5, -10] + Rotation: [0, 90, 0] + Scale: [1, 1, 1] + ``` -### `HierarchyComponent` -* **Description**: Manages parent-child relationships between entities. -* **Properties**: `Parent` (UUID), `Children` (list of UUIDs). +- **C++ Example**: + + ```cpp + auto& transform = entity.GetComponent(); + transform.Translation.y += 1.0f; + ``` ## Rendering Components ### `MeshComponent` -* **Description**: Attaches a 3D model (static or dynamic) to the entity. -* **Properties**: `AssetHandle` (UUID), `MaterialOverrides`. +- **Description**: Attaches a 3D model to the entity. +- **Properties**: `AssetHandle` (UUID), `MaterialOverrides` +- **YAML Example**: + + ```yaml + MeshComponent: + AssetHandle: models/player.glb + ``` + +- **C++ Example**: + + ```cpp + entity.AddComponent("path/to/model.glb"); + ``` ### `CameraComponent` -* **Description**: Defines a viewpoint for rendering. -* **Properties**: `ProjectionType` (Perspective/Orthographic), `FOV`, `Near/Far Clips`. +- **Description**: Defines a viewpoint for rendering. +- **Properties**: `ProjectionType`, `FOV`, `Near/Far Clips` +- **YAML Example**: -### `LightComponent` -* **Description**: Emits light into the scene. -* **Properties**: `Type` (Point, Spot, Directional), `Color`, `Intensity`, `Radius`. + ```yaml + CameraComponent: + ProjectionType: 0 + PerspectiveFOV: 45 + PerspectiveNear: 0.1 + PerspectiveFar: 1000 + ``` -## Physics Components +## UI Components -### `RigidBodyComponent` -* **Description**: Subjects the entity to physics simulation. -* **Properties**: `BodyType` (Static, Dynamic, Kinematic), `Mass`, `Linear/Angular Damping`. +### `WidgetComponent` +- **Description**: The base for all in-game UI (buttons, text, images). +- **Properties**: `WidgetType`, `Label`, `BoxStyle`, `TextStyle` +- **YAML Example**: -### `BoxColliderComponent` / `SphereColliderComponent` -* **Description**: Defines the physical shape for collisions. -* **Properties**: `Size`, `Offset`, `IsTrigger`, `Friction`. + ```yaml + WidgetComponent: + Widget Type: 1 # Button + Label: Start Game + Box Style: + BG Color: [40, 40, 40, 255] + Rounding: 4 + ``` ## Gameplay & Logic +### `SceneTransitionComponent` +- **Description**: Triggers a scene change. Automatically detects clicks if a `WidgetComponent` is present on the same entity. +- **Properties**: `TargetScenePath` (string), `Triggered` (bool) +- **YAML Example**: + + ```yaml + Scene TransitionComponent: + Target Scene Path: scenes/level1.chscene + Triggered: false + ``` + +- **C++ Example**: + + ```cpp + // Manual trigger + entity.GetComponent().Triggered = true; + ``` + ### `ManagedScriptComponent` -* **Description**: Links the entity to one or more C# scripts. -* **Properties**: `ClassName` (Fully qualified, e.g., `MyGame.Player`). +- **Description**: Links the entity to C# scripts. +- **Properties**: `ClassName` +- **YAML Example**: + + ```yaml + ManagedScriptComponent: + Scripts: + - ClassName: ChainedDecos.PlayerController + ``` ### `AudioComponent` -* **Description**: Handles 3D spatialized sound. -* **Properties**: `AssetHandle` (UUID), `Volume`, `Pitch`, `PlayOnAwake`, `Looping`. +- **Description**: Handles 3D spatialized sound. +- **Properties**: `AssetHandle`, `Volume`, `Looping` +- **YAML Example**: + + ```yaml + AudioComponent: + AssetHandle: audio/ambient.wav + Volume: 0.5 + Looping: true + ``` + +--- + +## Adding a Native Component (C++) + +Need performance that scripting can't provide, or want to create a brand new foundational component? Here is the full workflow: + +### 1. Define the Component + +Add a fast `struct` in `engine/scene/components/`. We use `EnTT`, so components are plain structs — no methods on the component itself. + +```cpp +// engine/scene/components/parkour_component.h +#pragma once +#include "engine/reflection/reflection_rfl.h" + +namespace Chained +{ +struct ParkourComponent +{ + float Stamina = 100.0f; + bool IsWallRunning = false; + + static const char* GetStaticName() { return "ParkourComponent"; } + + struct UI + { + UIMeta Stamina = {.Min = 0.0f, .Max = 100.0f, .Speed = 1.0f}; + UIMeta IsWallRunning = {.ReadOnly = true, .Transient = true}; + }; +}; +CH_MARK_RFL(ParkourComponent); +} // namespace Chained +``` + +### 2. Register the Component + +Add one line in `engine/scene/component_registry.cpp` inside `RegisterEngineComponents()`: + +```cpp +RegisterReflective("Parkour", nullptr, "Gameplay"); +``` + +Serialization and the editor inspector work automatically through the reflection system. + +### 3. Create a System + +Implement the logic as a free function in a namespace. **Always use `TransformSystem::` functions** to read/write transforms — never access `TransformComponent` fields directly. + +```cpp +// engine/scene/systems/parkour_system.h +#pragma once +#include "engine/scene/scene_fwd.h" + +namespace Chained::Parkour +{ + void Update(entt::registry& reg, Timestep ts); +} + +// engine/scene/systems/parkour_system.cpp +#include "parkour_system.h" +#include "engine/scene/components.h" +#include "engine/scene/entity.h" +#include "engine/scene/systems/transform_system.h" + +namespace Chained::Parkour +{ + void Update(entt::registry& reg, Timestep ts) + { + auto view = reg.view(); + for (auto entity : view) + { + auto& parkour = view.get(entity); + auto& tc = view.get(entity); + + if (parkour.IsWallRunning) + { + // Use TransformSystem — never write tc.Translation directly + auto pos = TransformSystem::GetTranslation(tc); + pos.y += 2.0f * ts; + TransformSystem::SetTranslation(tc, pos); + } + } + } +} +``` + +> **Note**: `component_utils.h` was removed. All transform utilities live in `TransformSystem::` — see `engine/scene/systems/transform_system.h` for the full list (ComputeLocalMatrix, GetTranslation, GetRotation, GetScale, SetTranslation, SetRotation, SetScale, etc.). + +### 4. Hook into the Scene + +Add one call in `engine/scene/scene.cpp` in the appropriate update method: + +```cpp +#include "engine/scene/systems/parkour_system.h" + +void Scene::OnUpdateRuntime(Timestep ts) +{ + // ...existing systems... + Parkour::Update(*m_Registry, ts); +} +``` + +### 5. Expose to Scripting (C# Glue) + +To make the component's fields accessible from C# scripts, add a `[NativeProperty]` attribute on the C# wrapper class. The C++ glue code is generated automatically. + +**Step A — Create the C# wrapper** (if it doesn't exist): + +```csharp +// engine/scripting/managed/src/Components/ParkourComponent.cs +using System; + +namespace Chained +{ + [NativeProperty("Stamina", "float", "ParkourComponent_GetStamina", "ParkourComponent_SetStamina")] + [NativeProperty("IsWallRunning", "bool", "ParkourComponent_IsWallRunning", "ParkourComponent_SetIsWallRunning")] + public partial class ParkourComponent : Component + { + } +} +``` + +**Step B — Register the class for glue generation** in `engine/scripting/CMakeLists.txt`: + +```cmake +--classes PlayerComponent SpawnComponent NetworkIdentityComponent ParkourComponent +``` + +**Step C — Build**: + +```bash +cmake --build --preset windows-clang-debug --parallel +``` + +The `tools/generate_glue.py` script runs automatically during the build and generates: +- `engine/scripting/generated/script_glue_generated.h` — function declarations +- `engine/scripting/generated/script_glue_generated.cpp` — getter/setter implementations +- `engine/scripting/generated/script_glue_generated_reg.cpp` — `AddInternalCall` registrations + +No manual C++ glue code is needed for simple property get/set. For complex logic (physics sync, string conversion, etc.), use `[NativeCall]` instead and write hand-written glue in `script_glue_*.cpp` — see [Scripting Interop](SCRIPTING_INTEROP.md). diff --git a/docs/EXPORT.md b/docs/EXPORT.md new file mode 100644 index 000000000..f029388e2 --- /dev/null +++ b/docs/EXPORT.md @@ -0,0 +1,128 @@ +# Project Export / Packaging + +The engine includes a project exporter that packages game assets into a single compressed archive for distribution. + +## Table of Contents + +- [Scene Serialization Format](#scene-serialization-format) +- [Debug Settings in Scene](#debug-settings-in-scene) +- [How to Export](#how-to-export) +- [Export Settings](#export-settings) +- [Runtime Loading](#runtime-loading) + +--- + +## Scene Serialization Format + +Scenes are stored as YAML (`.chscene` files). Each entity is serialized with its UUID, and components are nested under their serialization keys. + +```yaml +Scene: + Entities: + - Entity: 1234567890 # UUID as uint64 + TagComponent: + Tag: "Player" + NameComponent: + Name: "Player Entity" + TransformComponent: + Translation: [0.0, 5.0, 0.0] + Rotation: [0.0, 0.0, 0.0] + Scale: [1.0, 1.0, 1.0] + RigidBodyComponent: + Type: 1 # 0=Static, 1=Dynamic, 2=Kinematic + Mass: 1.0 + UseGravity: true + ColliderComponent: + Type: 0 # 0=Box, 1=Sphere, 2=Capsule, 3=Mesh + Size: [0.5, 0.5, 0.5] + Friction: 0.5 + ModelComponent: + ModelPath: "assets/models/player.glb" + AudioComponent: + SoundPath: "assets/sounds/footstep.wav" + Spatialized: true + Volume: 0.8 + - Entity: 9876543210 + TagComponent: + Tag: "SpawnPoint" + SpawnComponent: + IsActive: true + SpawnPoint: [0.0, 1.0, 0.0] + ZoneSize: [5.0, 2.0, 5.0] + Hierarchy: + Parent: 0 # 0 = root entity + Children: [] +``` + +## Debug Settings in Scene + +Scene-level debug rendering is serialized under `DebugSettings`: + +```yaml +DebugSettings: + DiagnosticMode: 0 # 0=Full, 1=Normals, 2=Lighting, 3=Albedo + DrawColliders: false + DrawHierarchy: false + DrawGrid: false + DrawSelection: true + DrawLights: true + DrawSpawnZones: true + CollisionWireframeMode: 0 # 0=Wireframe, 1=Solid, 2=Solid+Wireframe +Grid: + Spacing: 1.0 +``` + +## How to Export + +1. In the editor, go to **File > Export Project**. +2. Choose a **Pack Mode**: + - **Fast (LZ4)** — Quick export, larger file. + - **Balanced (ZSTD)** — Slower export, smaller file. + - **Raw** — No packing. Game files are copied directly into `assets/` and `resources/` subfolders. +3. Adjust **Compression Threshold** if needed. +4. Click **Browse Output Folder** and select a destination. +5. The export starts automatically. Progress is shown in the overlay. + +The exporter packages `.chproject`, `assets/`, and `resources/` into `resources.pack` using compression (via [cfnptr/pack](https://github.com/cfnptr/pack)). The executable, DLLs, and subdirectories are copied alongside the pack. + +## Export Settings + +| Setting | Default | Description | +| :--- | :--- | :--- | +| `Pack Mode` | Balanced | Compression algorithm: **Fast** (LZ4 HC), **Balanced** (ZSTD), or **Raw** (no pack — files copied directly) | +| `ZipThreshold` | 0.05 | Files above this ratio of compressed/original size are stored uncompressed | +| `DataVersion` | 0 | Increment to invalidate cached packs at runtime | + +## Runtime Loading + +At startup, `AssetManager::OpenPack()` automatically looks for `resources.pack` next to the executable. When found, all asset loading (textures, shaders, fonts) reads from the pack first, falling back to the filesystem if not found. + +In **Raw** mode no pack file is created. The runtime detects the absence of `resources.pack` and reads files directly from the `assets/` and `resources/` folders. This makes Raw mode ideal for rapid iteration and debugging. + +``` +# Fast / Balanced output structure: +MyGame/ + MyGame.exe + resources.pack + engine.dll + ... + +# Raw output structure: +MyGame/ + MyGame.exe + assets/ + scenes/ + textures/ + ... + resources/ + shaders/ + fonts/ + ... + engine.dll + ... +``` + +--- + +For build instructions, see [Build](../readme.md#build). +For the User Guide, see [User Guide](USER_GUIDE.md). diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 000000000..f8b0c2365 --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,195 @@ +# FAQ / Common Patterns + +Frequently asked questions and common patterns for ChainedEngine. + +## Table of Contents + +- [Scripting](#scripting) +- [Editor](#editor) +- [Build & Setup](#build--setup) +- [Components](#components) + +--- + +## Scripting + +### How do I teleport a player to a spawn point? + +Use `ForceSetVelocity` (not `Velocity =`) to avoid Jolt's Dynamic body Y-velocity override: + +```csharp +public void TeleportToSpawn(Vector3 spawnPos) { + TransformComponent? transform = GetComponent(); + RigidBodyComponent? rb = GetComponent(); + if (transform != null) transform.Translation = spawnPos; + if (rb != null) rb.ForceSetVelocity(Vector3.Zero); // Not rb.Velocity = ... +} +``` + +### How do I add a component from a C# script? + +```csharp +public override void OnCreate() { + RigidBodyComponent rb = entity.AddComponent(); + rb.Type = RigidBodyComponent.BodyType.Dynamic; + rb.Mass = 1.5f; +} +``` + +### How do I respond to collisions? + +Override `OnCollisionEnter` in your script. The engine passes a raw entity ID, not an `Entity` wrapper: + +```csharp +public override void OnCollisionEnter(ulong otherEntityId) { + Entity other = new Entity(otherEntityId); + TagComponent? tag = other.GetComponent(); + if (tag != null && tag.Tag == "Pickup") { + Log.Info("Collected a pickup!"); + } +} +``` + +### How do I trigger a scene transition? + +Add a `SceneTransitionComponent` to your entity and set `TargetScenePath`. When `Triggered` is set to `true` (by a script or collision), the engine loads the target scene automatically. + +### How do I play spatial audio? + +Add an `AudioComponent` with `Spatialized = true` and `PlayOnStart = true`. The audio system syncs the listener with the primary camera automatically. + +### How do I switch between scenes from a script? + +```csharp +Scene.LoadScene("assets/scenes/level2.chscene"); +``` + +### How do I find an entity by name/tag? + +```csharp +Entity? player = Scene.FindEntityByTag("Player"); +if (player != null) +{ + TransformComponent? t = player.GetComponent(); +} +``` + +### How do I copy/duplicate an entity? + +```csharp +Entity? clone = Scene.CopyEntity(original); +``` + +### How do I exit the game from a script? + +```csharp +Application.Close(); +``` + +### How do I get the FPS or delta time? + +```csharp +int fps = Time.FPS; +float dt = Time.DeltaTime; +``` + +### How do I change the window size at runtime? + +```csharp +AppWindow.SetSize(1920, 1080); +AppWindow.SetFullscreen(true); +``` + +Or configure it in the `.chproject` file: + +```yaml +Project: + Window: + Width: 1920 + Height: 1080 + VSync: true +``` + +### How do I add sound/music? + +1. Place your `.wav` or `.mp3` files in the assets folder. +2. Create an entity, add **AudioComponent**. +3. Set the audio file path in the Inspector. +4. Enable **PlayOnStart** for automatic playback, or call `Audio.Play("path/to/sound.wav")` from a script. + +For background music, enable **Loop** on the component. + +--- + +## Editor + +### My script doesn't compile. What's wrong? + +Most C# script errors come from: + +1. **Wrong namespace.** Your script must be in a namespace inside the game assembly (e.g., `namespace ChainedDecos`). +2. **Missing `using Chained;`** — Required for `Script`, `Entity`, `Input`, `Log`, etc. +3. **.NET SDK not found.** Install .NET SDK 10.0.x and verify it's on PATH: `dotnet --version`. +4. **Script not in the right folder.** Place `.cs` files in `game//assets/scripts/src/`. + +### The editor doesn't see my script. What do I do? + +After adding a new script file, the script assembly needs to rebuild. The engine auto-rebuilds when you press **Play**, but if it doesn't: + +1. Save the script file. +2. In the editor, go to **File > Reload Scripts** (or press Ctrl+R). +3. Check the **Console** panel for compilation errors. + +--- + +## Build & Setup + +### How do I switch between game projects? + +Set `CH_ACTIVE_GAME` at CMake configure time: + +```bash +cmake -S . -B build/windows-clang -DCH_ACTIVE_GAME=testproject +``` + +The executable name changes automatically. The `.chproject` file determines what scene the runtime opens. + +### What build presets are available? + +| Preset | Compiler | Platform | +|---|---|---| +| `windows-clang-debug` | Clang | Windows | +| `windows-clang-release` | Clang | Windows | +| `windows-msvc-debug` | MSVC | Windows | +| `windows-msvc-release` | MSVC | Windows | +| `windows-gcc-debug` | GCC | Windows (MinGW) | +| `windows-gcc-release` | GCC | Windows (MinGW) | +| `linux-clang-debug` | Clang | Linux | +| `linux-clang-release` | Clang | Linux | +| `linux-gcc-debug` | GCC | Linux | +| `linux-gcc-release` | GCC | Linux | + +### How do I run tests? + +```bash +cmake --build --preset windows-clang-debug --target EngineTests --parallel +ctest --test-dir build/windows-clang-debug --output-on-failure +``` + +### The build fails with submodule errors + +Run this before building: + +```bash +git submodule update --init --recursive +``` + +### How do I add a new component in C++? + +See the [Component Reference](COMPONENTS.md) for the full guide. In short: + +1. Define a struct in `engine/scene/components/`. +2. Register it in `component_registry.cpp`. +3. Add a system in `engine/scene/systems/`. +4. Hook the system into the scene update loop. +5. Add `[NativeProperty]` attributes on the C# wrapper and register in `CMakeLists.txt --classes` to expose fields to scripts (auto-generated glue). diff --git a/docs/SCRIPTING_API.md b/docs/SCRIPTING_API.md index bad164d2f..8cd73a840 100644 --- a/docs/SCRIPTING_API.md +++ b/docs/SCRIPTING_API.md @@ -1,54 +1,412 @@ # Scripting API Reference (C#) -This document covers the managed API surface available to C# scripts in Chained Engine. +This document is the reference for the managed C# API available to gameplay scripts +in Chained Engine. Every signature and example here is taken directly from the +sources under `scripting/managed/src/` and is kept in sync with them. When in doubt, +those files are the source of truth. -## 1. Script Lifecycle +The managed layer is a thin wrapper over the native engine. C# holds no game state of +its own: each component wrapper forwards to a C++ function pointer bound at startup +through Coral (the .NET/CoreCLR host). A wrapper is therefore only valid while its +entity is valid. -All gameplay scripts must inherit from `CHEngine.Script`. +## Contents + +0. [Quick reference](#0-quick-reference) +1. [Script lifecycle](#1-script-lifecycle) +2. [Entities and components](#2-entities-and-components) +3. [Components](#3-components) +4. [Input](#4-input) +5. [Scene, application, and services](#5-scene-application-and-services) +6. [Logging](#6-logging) +7. [In-game UI](#7-in-game-ui) +8. [Worked example](#8-worked-example) + +--- + +## 0. Quick reference + +| Namespace | Class | Purpose | +|---|---|---| +| `Chained` | `Script` | Base class for all gameplay scripts | +| `Chained` | `Entity` | Wraps a native entity, provides component access | +| `Chained` | `Input` | Keyboard and mouse input queries | +| `Chained` | `Scene` | Find entities, load scenes, copy entities | +| `Chained` | `Audio` | Play/stop sounds | +| `Chained` | `Application` | Application lifecycle (close) | +| `Chained` | `Time` | FPS and delta time | +| `Chained` | `AppWindow` | Window size, fullscreen, vsync, AA | +| `Chained` | `Physics` | Gravity query | +| `Chained` | `UI` | Minimal in-game text rendering | +| `Chained` | `Log` | Console logging (Info, Warn, Error) | + +**Key enums** (`Chained` namespace, defined in `Math.cs`): + +| Enum | Values | +|---|---| +| `Key` | `A`–`Z`, `D0`–`D9`, `Space`, `Escape`, `Enter`, `Tab`, `LeftShift`, `LeftControl`, `LeftAlt`, arrows, `F1`–`F12`, etc. | +| `MouseButton` | `Left`, `Right`, `Middle` | + +--- + +## 1. Script lifecycle + +Every gameplay script derives from `Chained.Script` (`scripting/managed/src/Script.cs`). +The lifecycle methods are `public virtual` — override the ones you need. They are not +`protected`; the engine invokes them across the interop boundary. + +```csharp +using Chained; + +namespace MyGame +{ + public class Example : Script + { + // Called once, one frame after the script is instantiated. + // Entity is already assigned here. + public override void OnCreate() { } + + // Called once, on the first Update frame after OnCreate. + public override void OnStart() { } + + // Called every frame while the simulation runs. + public override void OnUpdate(float deltaTime) { } + + // Called during the UI pass. Use the UI helper here (see section 7). + public override void OnGUI() { } + + // Called when the physics system reports a contact. The argument is the + // other entity's raw id, not an Entity wrapper — construct one if needed. + public override void OnCollisionEnter(ulong otherEntityId) { } + + // Called when a native engine event is forwarded. eventType is the + // integer value of the native EventType enum. + public override void OnEvent(int eventType) { } + + // Called once when the script is torn down. + public override void OnDestroy() { } + } +} +``` + +Ordering guarantees: `OnCreate` runs one frame after instantiation, `OnStart` runs the +frame after `OnCreate`, and `OnUpdate` only begins once `OnStart` has completed. This +staging is deliberate — it prevents `OnUpdate` from running on the same frame the +script was created. + +### Accessing the owning entity + +The base class exposes the entity the script is attached to, plus shortcuts: + +```csharp +public Entity Entity { get; } // the owning entity +public T? GetComponent() where T : Component, new(); // shortcut to Entity.GetComponent +public bool HasComponent() where T : Component, new(); // shortcut to Entity.HasComponent +``` + +--- + +## 2. Entities and components + +`Entity` (`scripting/managed/src/Entity.cs`) wraps a native entity id and provides +component access. Component lookups are cached per entity, so repeated +`GetComponent()` calls do not re-allocate. + +```csharp +public ulong ID { get; } +public bool IsValid { get; } // ID != 0 +public TransformComponent? Transform { get; } // shortcut, null if absent + +public bool HasComponent() where T : Component, new(); +public T? GetComponent() where T : Component, new(); // null if the entity lacks T +public T AddComponent() where T : Component, new(); +public static ulong[] FindAllWithComponent() where T : Component, new(); +public void InvalidateComponentCache(); // clears cached component wrappers +``` + +`GetComponent()` returns `null` when the component is absent, so always null-check +before use: ```csharp -public class MyScript : Script +public override void OnUpdate(float deltaTime) { - protected override void OnCreate() { /* Called once when the entity is instantiated */ } - protected override void OnUpdate(float deltaTime) { /* Called every frame */ } - protected override void OnDestroy() { /* Called when the entity is removed */ } - protected override void OnCollisionEnter(Entity other) { /* Physics callback */ } - protected override void OnGUI() { /* Immediate-mode UI layout */ } + RigidBodyComponent? rb = GetComponent(); + if (rb == null) + return; + + rb.Velocity = new Vector3(0.0f, 0.0f, -5.0f); } ``` -## 2. Entity & Component Access +> Note: the C# type name is what the native side matches against. `GetComponent()` +> sends `typeof(T).Name` (for example `"RigidBodyComponent"`) to the engine, which +> resolves it against the component registry. If a lookup unexpectedly returns null, +> confirm the component is actually present on the entity. + +--- + +## 3. Components + +Component wrappers live in `scripting/managed/src/Components/`. All derive from +`Component`, which itself exposes `Entity` and a `Transform` shortcut. + +> 💡 **Native Binding Architecture**: Components use Roslyn Source Generation (`[NativeProperty]` and `[NativeCall]` attributes). Function pointers (`_Ptr` fields) and C# getters/setters are auto-generated at compile time. See [SCRIPTING_INTEROP.md](SCRIPTING_INTEROP.md) for details on extending components and adding native calls. + +The wrappers available to scripts are listed below with their public members. + +### TransformComponent + +```csharp +public Vector3 Translation { get; set; } +public Vector3 Rotation { get; set; } // Euler angles (radians) +public Vector3 Scale { get; set; } +``` + +### RigidBodyComponent + +```csharp +public Vector3 Velocity { get; set; } +public bool IsGrounded { get; } // read-only, driven by the physics world +public bool IsKinematic { get; set; } +public float Mass { get; set; } +public void ForceSetVelocity(Vector3 velocity); // bypasses Jolt's internal override +``` + +### CameraComponent + +```csharp +public Vector3 Forward { get; } // derived from the entity transform +public Vector3 Right { get; } +public bool Primary { get; set; } +public bool IsOrbitCamera { get; set; } +public string TargetEntityTag { get; set; } + +public void GetOrbit(out float yaw, out float pitch, out float distance); +public void SetOrbit(float yaw, float pitch, float distance); +``` + +### Other wrappers + +| Wrapper | Key members | +| :--- | :--- | +| `ModelComponent` | `string ModelPath { get; set; }` | +| `TagComponent` | `string Tag { get; }` | +| `AudioComponent` | `float Volume { set; }`, `bool Loop { set; }`, `bool IsPlaying { get; }`, `string SoundPath { get; }`, `Play()`, `Stop()` | +| `SpriteComponent` | `string TexturePath { get; set; }`, `Vector4 Tint { get; set; }`, `bool FlipX { get; set; }`, `bool FlipY { get; set; }`, `int ZOrder { get; set; }` | +| `ShaderComponent` | `bool Enabled { get; set; }`, `SetFloat(name, value)`, `SetVector3(name, value)` | +| `PlayerComponent` | `float MovementSpeed { get; set; }`, `float JumpForce { get; set; }`, `float LookSensitivity { get; set; }` | +| `SpawnComponent` | `bool IsActive { get; set; }`, `bool IsCheckpoint { get; set; }`, `Vector3 SpawnPoint { get; set; }`, `bool RenderSpawnZoneInScene { get; }`, `Vector3 ZoneSize { get; }` | +| `AnimationComponent` | `int CurrentAnimationIndex { get; set; }`, `bool IsPlaying { get; set; }`, `bool IsLooping { get; set; }`, `bool IsFinished { get; }`, `float Duration { get; }`, `float NormalizedTime { get; }`, `float BlendDuration { get; set; }`, `Play()`, `Pause()`, `CrossFade(int index, float duration)`, `SetFloat(name, value)`, `SetBool(name, value)`, `GetFloat(name)` | -### Local Access -* `Entity self`: Access the entity this script is attached to. -* `T GetComponent()`: Retrieves a component from the current entity. -* `bool HasComponent()`: Checks if the entity has a specific component. +--- -### Global Search -* `Entity Scene.FindEntityByTag(string tag)`: Find an entity by its name/tag. -* `Entity Scene.CreateEntity(string name)`: Spawn a new entity. -* `void Scene.DestroyEntity(Entity entity)`: Remove an entity. +## 4. Input -## 3. Input Handling +`Input` (`scripting/managed/src/Input.cs`) is a static class. Keyboard queries take the +`Key` enum; mouse-button queries take the `MouseButton` enum (both in +`scripting/managed/src/Math.cs`). Note the enum is `Key`, not `KeyCode`. -Use the static `Input` class to query hardware state: -* `bool Input.IsKeyDown(KeyCode code)`: Check keyboard. -* `bool Input.IsMouseButtonPressed(MouseButton button)`: Check mouse. -* `Vector2 Input.GetMousePosition()`: Screen-space coordinates. +```csharp +public static bool IsKeyDown(Key key); // held this frame +public static bool IsKeyPressed(Key key); // went down this frame +public static bool IsKeyReleased(Key key); // went up this frame +public static bool IsMouseButtonDown(MouseButton button); +public static bool IsMouseButtonPressed(MouseButton button); +public static float GetMouseWheelMove(); // scroll delta this frame +public static Vector3 MouseDelta { get; } // (dx, dy, 0) since last frame +``` + +`Key` covers `A`–`Z`, `Space`, `Escape`, `Enter`, `Tab`, arrows, function keys, +modifiers such as `LeftShift`/`LeftControl`, and the digit keys `D0`–`D9`. +`MouseButton` is `Left`, `Right`, or `Middle`. + +```csharp +public override void OnUpdate(float deltaTime) +{ + if (Input.IsKeyDown(Key.W)) + { + // move forward + } -## 4. Mathematics + if (Input.IsMouseButtonDown(MouseButton.Right)) + { + Vector3 delta = Input.MouseDelta; // look around + } +} +``` -The engine uses custom wrappers for GLM types: -* `Vector2`, `Vector3`, `Vector4`: Standard coordinate and color containers. -* `Quaternion`: For stable rotation math. -* `Math.Lerp`, `Math.Clamp`: Common scalar utilities. +--- -## 5. UI & Logging +## 5. Scene, application, and services -### UI (In-Game HUD) -Draw simple debug or gameplay UI inside `OnGUI`: -* `void UI.DrawText(string text, Vector2 pos, Color color)` -* `bool UI.DrawButton(string label, Vector2 pos)` +These static classes live in `scripting/managed/src/SceneAndApplication.cs`. -### Logging -* `Log.Trace`, `Log.Info`, `Log.Warn`, `Log.Error`: Print messages to the engine console and editor log. +### Scene + +```csharp +public static Entity? FindEntityByTag(string tag); // null if not found +public static void LoadScene(string path); // e.g. "scenes/level1.chscene" +public static Entity? GetMainCamera(); +public static Entity? CopyEntity(Entity entity); // null on failure +``` + +### Audio + +```csharp +public static void Play(string path, float volume = 1.0f, float pitch = 1.0f, bool loop = false); +public static void Stop(string path); +public static void StopAll(); +``` + +### Application + +```csharp +public static void Close(); // request application shutdown +``` + +### Time + +```csharp +public static int FPS { get; } +public static float DeltaTime { get; } +``` + +### Physics + +```csharp +public static float GetGravity(); // world gravity from project settings +``` + +### AppWindow + +```csharp +public static void SetSize(int width, int height); +public static void SetFullscreen(bool enabled); +public static void SetVSync(bool enabled); +public static void SetAntialiasing(bool enabled); +public static void SetAntiAliasingSamples(int samples); +public static string GetSupportedResolutions(); +``` + +--- + +## 6. Logging + +`Log` (`scripting/managed/src/Log.cs`) writes to the same buffered console the editor +displays. Messages are plain strings — format them yourself with interpolation. + +```csharp +public static void Info(string message); +public static void Warn(string message); +public static void Error(string message); +public static void ClearHistory(); +public static IReadOnlyList History { get; } +``` + +```csharp +Log.Info($"Player spawned at {transform.Translation}"); +Log.Warn("No camera tagged 'Main' in scene"); +Log.Error("Failed to load save file"); +``` + +--- + +## 7. In-game UI + +The managed UI surface (`scripting/managed/src/UI.cs`) is intentionally minimal today. +It exposes a single call, used from `OnGUI`: + +```csharp +public static void Text(string text); +``` + +```csharp +public override void OnGUI() +{ + UI.Text($"Score: {_score}"); + UI.Text($"FPS: {Time.FPS}"); +} +``` + +For richer player-facing UI, prefer the native declarative path (`WidgetComponent` + +`SceneTransitionComponent`) described in the README. `UI.Text` is meant for lightweight +HUD readouts, not full menus. + +--- + +## 8. Worked example + +A camera-relative movement controller that reads WASD, moves a rigid body, and jumps. +This mirrors the shape of the real `PlayerController` in +`game/chaineddecos/assets/scripts/src/`. + +```csharp +using Chained; + +namespace MyGame +{ + public class Mover : Script + { + public float Speed = 15.0f; + public float JumpForce = 15.0f; + + public override void OnCreate() + { + Log.Info("Mover ready"); + } + + public override void OnUpdate(float deltaTime) + { + // Camera-relative ground directions. + Vector3 forward = Vector3.Zero; + Vector3 right = Vector3.Zero; + + Entity? camEntity = Scene.GetMainCamera(); + CameraComponent? camera = camEntity?.GetComponent(); + if (camera != null) + { + forward = Vector3.Normalize(new Vector3(camera.Forward.X, 0.0f, camera.Forward.Z)); + right = Vector3.Normalize(new Vector3(camera.Right.X, 0.0f, camera.Right.Z)); + } + + Vector3 dir = Vector3.Zero; + if (Input.IsKeyDown(Key.W)) dir += forward; + if (Input.IsKeyDown(Key.S)) dir -= forward; + if (Input.IsKeyDown(Key.A)) dir -= right; + if (Input.IsKeyDown(Key.D)) dir += right; + + RigidBodyComponent? rb = GetComponent(); + if (rb == null) + return; + + Vector3 velocity = rb.Velocity; + + if (dir.LengthSquared() > 0.0001f) + { + dir = Vector3.Normalize(dir); + velocity.X = dir.X * Speed; + velocity.Z = dir.Z * Speed; + } + else + { + velocity.X = 0.0f; + velocity.Z = 0.0f; + } + + // Jump: physics owns the vertical axis for dynamic bodies. + if (Input.IsKeyPressed(Key.Space) && rb.IsGrounded) + velocity.Y = JumpForce; + + rb.Velocity = velocity; + } + + public override void OnCollisionEnter(ulong otherEntityId) + { + Entity other = new Entity(otherEntityId); + TagComponent? tag = other.GetComponent(); + if (tag != null && tag.Tag == "Hazard") + Log.Info("Hit a hazard"); + } + } +} +``` diff --git a/docs/SCRIPTING_INTEROP.md b/docs/SCRIPTING_INTEROP.md new file mode 100644 index 000000000..604787ae4 --- /dev/null +++ b/docs/SCRIPTING_INTEROP.md @@ -0,0 +1,223 @@ +# Scripting Interop & Roslyn Source Generator Architecture + +This document describes how the C++/C# interop bridge works in Chained Engine, how the Roslyn Source Generator (`Chained.Managed.Generator`) automates native bindings, and how to expose new native C++ component functions to C# gameplay scripts. + +--- + +## 1. Overview & Architecture + +Chained Engine uses **[Coral](https://github.com/StudioCherno/Coral)** — a C++ wrapper around .NET CoreCLR — for C++/C# interoperability. + +``` +┌────────────────────────────────┐ ┌────────────────────────────────┐ +│ Native C++ Engine │ │ Managed C# Scripts │ +│ │ │ │ +│ script_glue_*.cpp │ │ src/Components/*.cs │ +│ extern "C" C++ functions │ │ [NativeProperty] / │ +│ │ │ │ [NativeCall] attributes │ +│ │ │ │ │ │ +│ │ (Function Pointers) │ │ │ (Roslyn Code Gen) │ +│ ▼ │ │ ▼ │ +│ Coral::Assembly │ │ .g.cs (Generated File) │ +│ AddInternalCall("Class", │ ═══════>│ internal static unsafe │ +│ "Method_Ptr", &C++Fn) │ Writes │ delegate* unmanaged<...> │ +│ UploadInternalCalls() │ Pointer │ Method_Ptr; │ +└────────────────────────────────┘ └────────────────────────────────┘ +``` + +### High-level data flow: +1. **C++ Glue Functions**: Declared as `extern "C"` (using macro `CH_SCRIPT_FUNC`). +2. **C++ Registration**: `ScriptGlue::RegisterInternalCalls()` binds C++ function pointers to string names (`ClassName.MethodName_Ptr`). +3. **C# Roslyn Generator**: `NativeCallGenerator` scans `[NativeCall]` and `[NativeProperty]` attributes at compile time and auto-generates `delegate* unmanaged<...>` function pointer fields and C# property getters/setters. +4. **Coral Binding**: At assembly load time, Coral matches string names against static `_Ptr` fields in C# assemblies and writes C++ function pointers directly into those fields. + +--- + +## 2. Roslyn Source Generator (`Chained.Managed.Generator`) + +The generator project lives in `scripting/managed/Chained.Managed.Generator/` and compiles to a Roslyn analyzer DLL (`Chained.Managed.Generator.dll`). + +### Attributes + +#### `[NativeCall]` +Declares a single native function binding. +```csharp +[NativeCall("Chained.AnimationComponent", "AnimationComponent_CrossFade", "void", "ulong", "int", "float")] +public partial class AnimationComponent : Component { ... } +``` +- **Signature format**: `[returnType, param1, param2, ...]` +- **First parameter**: Always entity ID (`ulong`) for component accessors. +- **Auto-generates**: + ```csharp + internal static unsafe delegate* unmanaged AnimationComponent_CrossFade_Ptr; + ``` + +#### `[NativeProperty]` +Declares a full C# property getter/setter **AND** generates the corresponding `_Ptr` fields. +```csharp +[NativeProperty("MovementSpeed", "float", "PlayerComponent_GetMovementSpeed", "PlayerComponent_SetMovementSpeed")] +[NativeProperty("IsKinematic", "bool", "RigidBody_IsKinematic", "RigidBody_SetKinematic")] +[NativeProperty("Translation", "Vector3", "Transform_GetTranslation", "Transform_SetTranslation")] +public partial class PlayerComponent : Component { ... } +``` +- **Auto-generates**: + 1. Both `Get_Ptr` and `Set_Ptr` unmanaged function pointer fields. + 2. The full C# property getter/setter with null-checks, `unsafe` blocks, and type marshaling (`bool` $\leftrightarrow$ `byte`, `Vector3*` out-pointer). + +--- + +## 3. Type Mapping Convention (ABI) + +The C++ glue functions and C# generator follow strict ABI type mapping rules: + +| Logical Type | C++ Type (`script_glue_*.cpp`) | C# Attribute String | Generated C# Type / Pointer | +|---|---|---|---| +| Entity ID | `uint64_t` | `"ulong"` | `ulong` | +| Boolean | `uint8_t` | `"bool"` / `"byte"` | `byte` (`(byte)(value ? 1 : 0)`) | +| Integer | `int32_t` / `int` | `"int"` | `int` | +| Unsigned Int | `uint32_t` | `"uint"` | `uint` | +| Float | `float` | `"float"` | `float` | +| Double | `double` | `"double"` | `double` | +| UTF-16 String | `Coral::UCChar*` / `char16_t*` | `"char*"` | `char*` | +| Vector2 Struct | `glm::vec2*` | `"Vector2"` / `"Vector2*"` | `Chained.Vector2*` (out-pointer) | +| Vector3 Struct | `glm::vec3*` | `"Vector3"` / `"Vector3*"` | `Chained.Vector3*` (out-pointer) | +| Vector4 Struct | `glm::vec4*` | `"Vector4"` / `"Vector4*"` | `Chained.Vector4*` (out-pointer) | + +> ⚠️ **Important**: Structs (`Vector2`, `Vector3`, `Vector4`) are **always passed by pointer** across the ABI boundary to ensure stack alignment and prevent platform ABI discrepancies. + +--- + +## 4. Glue Generation Pipeline + +For simple property get/set, you don't need to write any C++ glue code. The `tools/generate_glue.py` script scans C# `[NativeProperty]` attributes and generates everything automatically. + +### How it works + +``` +C# [NativeProperty] Python generator C++ build + attribute ───> tools/generate_glue.py ───> auto-compiled + │ +CMakeLists.txt --classes ──────────────────────────────┘ +``` + +1. **`tools/generate_glue.py`** reads `engine/scripting/managed/src/Components/*.cs` +2. Filters by `--classes` parameter (e.g. `PlayerComponent SpawnComponent`) +3. Generates three files in `engine/scripting/generated/`: + - `script_glue_generated.h` — `CH_SCRIPT_FUNC` declarations + - `script_glue_generated.cpp` — getter/setter implementations + - `script_glue_generated_reg.inl` — `AddInternalCall` registrations (included by `script_glue.cpp`) +4. CMake custom command runs the generator automatically when C# sources change + +### Usage + +Add your class to the `--classes` list in `engine/scripting/CMakeLists.txt`: + +```cmake +COMMAND Python3::Interpreter "${GLUE_GENERATOR_SCRIPT}" + --cs-dir "${MANAGED_PROJECT_DIR}/src/Components" + --output-dir "${GLUE_OUTPUT_DIR}" + --classes PlayerComponent SpawnComponent NetworkIdentityComponent YourComponent +``` + +Then build normally — the generator runs as part of the build. + +### Limitations + +The generator handles **simple field access only**: reading/writing a single field on a component. It does NOT handle: +- Physics synchronization (e.g. updating Jolt body when translation changes) +- String conversion (e.g. `Coral::UCChar*` ↔ `std::string`) +- Complex logic (e.g. finding entities by tag, conditional behavior) + +For these cases, use `[NativeCall]` and write hand-written glue in `script_glue_*.cpp` — see the manual tutorial below. + +--- + +## 5. Step-by-Step Tutorial: Adding a New Native Property + +### Automatic (recommended for simple properties) + +Suppose you want to expose a new `Stamina` property on `PlayerComponent`. + +**Step 1: Add `[NativeProperty]` to C# Component** + +In `scripting/managed/src/Components/PlayerComponent.cs`: + +```csharp +namespace Chained +{ + [NativeProperty("MovementSpeed", "float", "PlayerComponent_GetMovementSpeed", "PlayerComponent_SetMovementSpeed")] + [NativeProperty("Stamina", "float", "PlayerComponent_GetStamina", "PlayerComponent_SetStamina")] + public partial class PlayerComponent : Component + { + } +} +``` + +**Step 2: Register the class** in `engine/scripting/CMakeLists.txt`: + +```cmake +--classes PlayerComponent SpawnComponent NetworkIdentityComponent +``` + +**Step 3: Build**: + +```bash +cmake --build --preset windows-clang-debug --parallel +``` + +Done. The generator creates the C++ getter, setter, and registration automatically. The Roslyn Source Generator on the C# side creates the `_Ptr` fields and property body. + +### Manual (for complex glue logic) + +If your property needs physics sync, string conversion, or other complex behavior, write the C++ glue by hand. + +**Step 1: Implement C++ Glue Function** + +In `scripting/script_glue_player.cpp`: + +```cpp +CH_SCRIPT_FUNC float PlayerComponent_GetStamina(uint64_t entityID) +{ + Entity entity = GetEntity(entityID); + if (entity && entity.HasComponent()) + return entity.GetComponent().Stamina; + return 0.0f; +} + +CH_SCRIPT_FUNC void PlayerComponent_SetStamina(uint64_t entityID, float stamina) +{ + Entity entity = GetEntity(entityID); + if (entity && entity.HasComponent()) + entity.GetComponent().Stamina = stamina; +} +``` + +**Step 2: Register in `script_glue.cpp`** + +In `scripting/script_glue.cpp` under `ScriptGlue::RegisterInternalCalls()`: + +```cpp +assembly.AddInternalCall("Chained.PlayerComponent", "PlayerComponent_GetStamina_Ptr", (void*)&PlayerComponent_GetStamina); +assembly.AddInternalCall("Chained.PlayerComponent", "PlayerComponent_SetStamina_Ptr", (void*)&PlayerComponent_SetStamina); +``` + +**Step 3: Declare in header** + +In `scripting/script_glue_entity.h` (or the relevant `script_glue_*.h`): + +```cpp +CH_SCRIPT_FUNC float PlayerComponent_GetStamina(uint64_t entityID); +CH_SCRIPT_FUNC void PlayerComponent_SetStamina(uint64_t entityID, float stamina); +``` + +**Step 4: Add `[NativeProperty]` to C# Component** + +Same as the automatic path — the C# Roslyn generator needs the attribute to create the `_Ptr` fields and property body. + +--- + +## 6. Troubleshooting & Generated Files + +- **Viewing Generated Code**: During compilation with MSBuild (`/p:EmitCompilerGeneratedFiles=true`), generated C# files are saved under `scripting/managed/obj/GeneratedFiles/Chained.Managed.Generator/`. +- **Component Must Be `partial`**: Any C# class decorated with `[NativeCall]` or `[NativeProperty]` **must** have the `partial` keyword. +- **Null Safety**: All generated properties contain defensive `!= null` checks on the `_Ptr` fields. If a function is not registered on the C++ side, the property getter will return `default` instead of crashing with a `NullReferenceException`. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 000000000..c460165a9 --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,322 @@ +# User Guide + +Step-by-step guide for getting started with ChainedEngine. + +## Table of Contents + +- [Requirements](#requirements) +- [Building the Engine](#building-the-engine) +- [Running the Editor](#running-the-editor) +- [Editor Overview](#editor-overview) +- [Creating Your First Scene](#creating-your-first-scene) +- [Writing Your First Script](#writing-your-first-script) +- [Running the Game](#running-the-game) +- [Exporting Your Project](#exporting-your-project) +- [Common Tasks](#common-tasks) + +--- + +## Requirements + +| Component | Version | +|---|---| +| C++ Compiler | MSVC 17+, Clang 18+, or GCC 14+ | +| CMake | 3.28+ | +| .NET SDK | 10.0.x (for C# scripting) | +| Git | With submodule support | + +**Windows:** Visual Studio 2022 or Clang from LLVM. +**Linux:** `build-essential`, `libx11-dev`, `libxrandr-dev`, `libxinerama-dev`, `libxcursor-dev`, `libxi-dev`, `mesa-common-dev`, `libgl1-mesa-dev`. + +## Building the Engine + +1. **Clone the repository with submodules:** + +```bash +git clone --recurse-submodules https://github.com/IOleg-crypto/Chained-Engine.git +cd Chained-Engine +``` + +If you already cloned without submodules: + +```bash +git submodule update --init --recursive +``` + +2. **Configure with CMake:** + +```bash +# Windows (Clang) +cmake --preset windows-clang-debug + +# Windows (MSVC) +cmake --preset windows-msvc-debug + +# Linux (Clang) +cmake --preset linux-clang-debug +``` + +3. **Build:** + +```bash +cmake --build --preset windows-clang-debug --parallel +``` + +4. **Binaries appear in:** + +``` +build//bin/Debug/ + ChainedEditor.exe # The editor + ChainedRuntime.exe # Headless game runner + ChainedDecos.exe # Game executable +``` + +## Running the Editor + +```bash +build/windows-clang/bin/Debug/ChainedEditor.exe +``` + +The editor opens with a default scene. You can open any `.chproject` file through **File > Open Project**. + +## Editor Overview + +The editor has these main areas: + +| Area | Description | +|---|---| +| **Viewport** | 3D preview of the scene. Click to select objects. Gizmos for move/rotate/scale. | +| **Hierarchy** | Tree view of all entities in the scene. Right-click to add/delete. | +| **Inspector** | Properties of the selected entity. Edit components here. | +| **Content Browser** | File browser for assets (models, textures, scripts, scenes). | +| **Console** | Engine logs and errors. | +| **Animation Graph** | Visual state machine editor for animations. | + +### Simulation Controls + +- **Play** — Runs the game inside the editor. Physics and scripts execute. +- **Simulate** — Runs physics only, no scripts. +- **Stop** — Returns to edit mode. +- **Escape** — Leaves simulation and returns to editor interaction. + +## Creating Your First Scene + +1. **Create a new scene:** File > New Scene (or Ctrl+N). + +2. **Add an entity:** Right-click in the Hierarchy > Create Empty. + +3. **Rename it:** Double-click the entity in Hierarchy, type a name (e.g., "Player"). + +4. **Add a Transform:** The entity already has one by default. Adjust Position/Rotation/Scale in the Inspector. + +5. **Add a Model:** In the Inspector, click "Add Component" > ModelComponent. Browse to a `.gltf` or `.obj` file. + +6. **Add a Camera:** Create another entity, add CameraComponent. Set it as the main camera. + +7. **Add Lighting:** Create an entity, add LightComponent. Choose Point, Spot, or Directional. + +8. **Save:** File > Save Scene (Ctrl+S). + +### Adding Physics + +1. Select your entity. +2. Add **RigidBodyComponent** — choose Static, Dynamic, or Kinematic. +3. Add **ColliderComponent** — choose Box, Sphere, Capsule, or Mesh shape. +4. Press **Play** to see it fall under gravity. + +## Writing Your First Script + +Scripts are written in C# and live in your game's `assets/scripts/src/` folder. + +### 1. Create the script file + +Create `assets/scripts/src/MyScript.cs`: + +```csharp +using Chained; + +namespace MyGame +{ + public class MyScript : Script + { + public float Speed = 5.0f; + + public override void OnCreate() + { + Log.Info("MyScript created!"); + } + + public override void OnUpdate(float deltaTime) + { + if (Input.IsKeyDown(Key.W)) + { + TransformComponent? transform = GetComponent(); + if (transform != null) + { + transform.Translation.Z -= Speed * deltaTime; + } + } + } + } +} +``` + +### 2. Attach the script to an entity + +1. Select the entity in the Hierarchy. +2. In the Inspector, click "Add Component" > ManagedScriptComponent. +3. Browse to your compiled script (the engine auto-discovers scripts in the assembly). + +### 3. Set public fields + +Public fields (like `Speed`) appear in the Inspector. You can edit them without recompiling. + +### Script Lifecycle + +| Method | When it runs | +|---|---| +| `OnCreate()` | Once, when the script is first attached. | +| `OnStart()` | Once, on the first frame after OnCreate. | +| `OnUpdate(float dt)` | Every frame while the game runs. | +| `OnGUI()` | Every frame for in-game UI drawing. | +| `OnCollisionEnter(ulong id)` | When a physics collision starts. | +| `OnDestroy()` | When the script or scene is destroyed. | + +### Common Patterns + +**Move toward a target:** +```csharp +Vector3 direction = target - transform.Translation; +transform.Translation += Vector3.Normalize(direction) * Speed * deltaTime; +``` + +**Check collision with a tag:** +```csharp +public override void OnCollisionEnter(ulong otherEntityId) +{ + Entity other = new Entity(otherEntityId); + TagComponent? tag = other.GetComponent(); + if (tag?.Tag == "Pickup") + { + Log.Info("Collected!"); + } +} +``` + +**Teleport (use ForceSetVelocity for dynamic bodies):** +```csharp +RigidBodyComponent? rb = GetComponent(); +rb?.ForceSetVelocity(Vector3.Zero); +transform.Translation = spawnPoint; +``` + +## Running the Game + +### In the Editor + +Press **Play** in the toolbar. The game runs inside the viewport. Press **Stop** to return to edit mode. + +### With ChainedRuntime + +```bash +ChainedRuntime.exe --project path/to/mygame.chproject --width 1920 --height 1080 +``` + +| Flag | Description | +|---|---| +| `--project` | Path to the `.chproject` file. | +| `--name` | Window title (default: project name). | +| `--width` | Window width in pixels. | +| `--height` | Window height in pixels. | + +### From Command Line + +```bash +ChainedDecos.exe +``` + +Opens the default project defined in the `.chproject` file. + +## Exporting Your Project + +1. In the editor, go to **File > Export Project**. +2. Choose a **Pack Mode**: + - **Fast (LZ4)** — Quick export, larger file. + - **Balanced (ZSTD)** — Slower export, smaller file. + - **Raw** — No compression. +3. Adjust **Compression Threshold** if needed (0.0 = compress everything, 1.0 = compress nothing). +4. Click **Browse Output Folder** and select a destination. +5. The export starts automatically. Progress is shown in the overlay. +6. Distribute the exported folder — it contains everything needed to run the game with ChainedRuntime. + +## Common Tasks + +### Add a Light + +1. Create an entity. +2. Add **LightComponent**. +3. Choose type: Point (omnidirectional), Spot (cone), or Directional (sun). +4. Adjust color, intensity, and range in the Inspector. + +### Add Audio + +1. Create an entity. +2. Add **AudioComponent**. +3. Set the audio file path, volume, pitch. +4. Enable **Spatialized** for 3D positional audio. +5. Enable **PlayOnStart** to play automatically. + +### Create a Scene Transition + +1. Create an entity with **SceneTransitionComponent**. +2. Set **TargetScenePath** to the destination scene. +3. From a script, set `Triggered = true` when the player reaches the exit. + +### Change Window Settings at Runtime + +```csharp +AppWindow.SetSize(1920, 1080); +AppWindow.SetFullscreen(true); +AppWindow.SetVSync(false); +``` + +### Play a Sound from Script + +```csharp +Audio.Play("assets/sounds/jump.wav", volume: 0.8f, pitch: 1.0f); +Audio.Stop("assets/sounds/jump.wav"); +Audio.StopAll(); +``` + +### Find an Entity by Tag + +```csharp +Entity? enemy = Scene.FindEntityByTag("Enemy"); +if (enemy != null) +{ + TransformComponent? t = enemy.GetComponent(); +} +``` + +### Copy an Entity + +```csharp +Entity? clone = Scene.CopyEntity(original); +``` + +### Switch Scenes from Script + +```csharp +Scene.LoadScene("assets/scenes/level2.chscene"); +``` + +### Exit the Game + +```csharp +Application.Close(); +``` + +--- + +For the full C# API reference, see [Scripting API Reference](SCRIPTING_API.md). +For component details, see [Component Reference](COMPONENTS.md). diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 7b092aab7..6a30cd6af 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1,109 +1,70 @@ -add_library(editor_core STATIC) - -target_sources(editor_core PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/editor_layer.h - ${CMAKE_CURRENT_SOURCE_DIR}/editor_layer.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/editor_context.h - ${CMAKE_CURRENT_SOURCE_DIR}/editor_context.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/editor_panels.h - ${CMAKE_CURRENT_SOURCE_DIR}/editor_panels.cpp - - ${CMAKE_CURRENT_SOURCE_DIR}/editor_project_manager.h - ${CMAKE_CURRENT_SOURCE_DIR}/editor_project_manager.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/editor_scene_manager.h - ${CMAKE_CURRENT_SOURCE_DIR}/editor_scene_manager.cpp - - # Viewport - ${CMAKE_CURRENT_SOURCE_DIR}/viewport/editor_gizmo.h - ${CMAKE_CURRENT_SOURCE_DIR}/viewport/editor_gizmo.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/viewport/editor_camera.h - ${CMAKE_CURRENT_SOURCE_DIR}/viewport/editor_camera.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/viewport/ui_manipulator.h - ${CMAKE_CURRENT_SOURCE_DIR}/viewport/ui_manipulator.h - ${CMAKE_CURRENT_SOURCE_DIR}/viewport/ui_manipulator.cpp - - ${CMAKE_CURRENT_SOURCE_DIR}/launcher/editor_launcher.h - ${CMAKE_CURRENT_SOURCE_DIR}/launcher/editor_launcher.cpp - - # Panels - ${CMAKE_CURRENT_SOURCE_DIR}/panels/scene_hierarchy_panel.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/scene_hierarchy_panel.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/inspector_panel.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/property_editor.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/property_editor.cpp - - ${CMAKE_CURRENT_SOURCE_DIR}/panels/content_browser_panel.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/content_browser_panel.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/console_panel.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/console_panel.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/profiler_panel.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/profiler_panel.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/viewport_panel.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/project_browser_panel.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/project_settings_panel.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/project_settings_panel.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/world_panel.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/world_panel.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/effects_panel.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/effects_panel.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/material_panel.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/material_panel.cpp - - # Undo - ${CMAKE_CURRENT_SOURCE_DIR}/undo/command_history.h - ${CMAKE_CURRENT_SOURCE_DIR}/undo/command_history.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/undo/editor_command.h - ${CMAKE_CURRENT_SOURCE_DIR}/undo/modify_component_command.h - ${CMAKE_CURRENT_SOURCE_DIR}/undo/lambda_command.h - ${CMAKE_CURRENT_SOURCE_DIR}/undo/entity_commands.h - - # UI - ${CMAKE_CURRENT_SOURCE_DIR}/editor_gui.h - ${CMAKE_CURRENT_SOURCE_DIR}/editor_gui.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/editor_layout.h - ${CMAKE_CURRENT_SOURCE_DIR}/editor_layout.cpp +file(GLOB EDITOR_CORE_SOURCES CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/*.h" + "${CMAKE_CURRENT_SOURCE_DIR}/panels/*.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/panels/*.h" + "${CMAKE_CURRENT_SOURCE_DIR}/project/*.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/project/*.h" + "${CMAKE_CURRENT_SOURCE_DIR}/undo/*.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/undo/*.h" + "${CMAKE_CURRENT_SOURCE_DIR}/ui/*.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ui/*.h" + "${CMAKE_CURRENT_SOURCE_DIR}/viewport/*.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/viewport/*.h" ) +# NetworkPanel is only compiled when the networking module is enabled +list(REMOVE_ITEM EDITOR_CORE_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/panels/network_panel.cpp") +# The editor executable entry point is built separately +list(REMOVE_ITEM EDITOR_CORE_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/main.cpp") +if(CH_NETWORKING) + list(APPEND EDITOR_CORE_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/panels/network_panel.cpp") +endif() +add_library(editor_core STATIC ${EDITOR_CORE_SOURCES}) target_link_libraries(editor_core PUBLIC - engine - scripting - imguilib - nfd + ChainedEngine::Framework ) -target_compile_definitions(editor_core PUBLIC IMGUI_DEFINE_MATH_OPERATORS YAML_CPP_STATIC_DEFINE) + +# Pack library — asset packing for shipped builds +if(TARGET pack-static) + target_link_libraries(editor_core PUBLIC pack-static) +endif() + +# WinHTTP — used by NetworkPanel to fetch the public IP from api.ipify.org. +# Only needed when the networking module is enabled. +if(WIN32 AND CH_NETWORKING) + target_link_libraries(editor_core PUBLIC winhttp) +endif() + target_include_directories(editor_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} - ${CMAKE_SOURCE_DIR} - ${CMAKE_SOURCE_DIR}/engine - ${CMAKE_SOURCE_DIR}/include - ${imguizmo_SOURCE_DIR} ) # Define the Editor executable -add_executable(ChainedEditor ${CMAKE_CURRENT_SOURCE_DIR}/editor_main.cpp) +add_executable(ChainedEditor ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp) + target_link_libraries(ChainedEditor PRIVATE editor_core) -target_include_directories(ChainedEditor PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR} - ${CMAKE_SOURCE_DIR} -) -# Use centralized engine resources -chained_add_engine_resources_copy() -add_dependencies(ChainedEditor EngineResources) +# Packer utility — build-time tool, not linked to the editor +if(TARGET packer) + add_dependencies(ChainedEditor packer) +endif() + +if(TARGET engine_pch) + target_link_libraries(editor_core PRIVATE engine_pch) + target_link_libraries(ChainedEditor PRIVATE engine_pch) +endif() -# Apply optimizations -if(COMMAND apply_engine_optimizations) - apply_engine_optimizations(editor_core) - apply_engine_optimizations(ChainedEditor) +# Automatically sync resources/ into the correct bin// directory after each build +if(COMMAND ch_add_resource_sync) + ch_add_resource_sync(ChainedEditor) endif() # Installation -install(TARGETS ChainedEditor editor_core - RUNTIME DESTINATION bin COMPONENT Runtime - ARCHIVE DESTINATION lib COMPONENT Runtime - LIBRARY DESTINATION lib COMPONENT Runtime +install(TARGETS ChainedEditor + RUNTIME DESTINATION bin COMPONENT Editor ) # Copy global assets to the install directory if they exist diff --git a/editor/action_commands.cpp b/editor/action_commands.cpp new file mode 100644 index 000000000..799dd029f --- /dev/null +++ b/editor/action_commands.cpp @@ -0,0 +1,80 @@ +#include "action_commands.h" +#include "engine/core/log.h" +#include + +namespace Chained +{ + + void EditorActionCommands::RenameAsset(const std::filesystem::path& path, const std::string& newName) + { + if (!std::filesystem::exists(path)) + { + CH_CORE_WARN("RenameAsset: source does not exist: {}", path.string()); + return; + } + + std::filesystem::path newPath = path.parent_path() / newName; + if (std::filesystem::exists(newPath)) + { + CH_CORE_ERROR("RenameAsset: destination already exists: {}", newPath.string()); + return; + } + + std::error_code ec; + std::filesystem::rename(path, newPath, ec); + if (ec) + { + CH_CORE_ERROR("RenameAsset: failed to rename '{}' -> '{}': {}", path.string(), newPath.string(), + ec.message()); + } + } + + void EditorActionCommands::DeleteAsset(const std::filesystem::path& path) + { + if (!std::filesystem::exists(path)) + { + CH_CORE_WARN("DeleteAsset: path does not exist: {}", path.string()); + return; + } + + std::error_code ec; + std::filesystem::remove_all(path, ec); + if (ec) + { + CH_CORE_ERROR("DeleteAsset: failed to delete '{}': {}", path.string(), ec.message()); + } + } + + void EditorActionCommands::CreateFolder(const std::filesystem::path& parentPath, const std::string& name) + { + if (!std::filesystem::exists(parentPath)) + { + CH_CORE_ERROR("CreateFolder: parent does not exist: {}", parentPath.string()); + return; + } + + std::filesystem::path newDir = parentPath / name; + if (std::filesystem::exists(newDir)) + { + int i = 1; + constexpr int kMaxAttempts = 1000; + do + { + newDir = parentPath / (name + " " + std::to_string(i++)); + if (i > kMaxAttempts) + { + CH_CORE_ERROR("CreateFolder: too many conflicts for '{}' in {}", name, parentPath.string()); + return; + } + } while (std::filesystem::exists(newDir)); + } + + std::error_code ec; + std::filesystem::create_directory(newDir, ec); + if (ec) + { + CH_CORE_ERROR("CreateFolder: failed to create '{}': {}", newDir.string(), ec.message()); + } + } + +} // namespace Chained diff --git a/editor/action_commands.h b/editor/action_commands.h new file mode 100644 index 000000000..1ebef6b1b --- /dev/null +++ b/editor/action_commands.h @@ -0,0 +1,17 @@ +#ifndef CH_EDITOR_ACTION_COMMANDS_H +#define CH_EDITOR_ACTION_COMMANDS_H + +#include +#include + +namespace Chained +{ + namespace EditorActionCommands + { + void RenameAsset(const std::filesystem::path& path, const std::string& newName); + void DeleteAsset(const std::filesystem::path& path); + void CreateFolder(const std::filesystem::path& parentPath, const std::string& name = "New Folder"); + }; // namespace EditorActionCommands +} // namespace Chained + +#endif // CH_EDITOR_ACTION_COMMANDS_Hs diff --git a/editor/asset_types.h b/editor/asset_types.h new file mode 100644 index 000000000..d0f590ea6 --- /dev/null +++ b/editor/asset_types.h @@ -0,0 +1,35 @@ +#ifndef CH_EDITOR_ASSET_TYPES_H +#define CH_EDITOR_ASSET_TYPES_H + +#include +#include +#include + +namespace Chained +{ + + enum class EditorAssetType + { + Directory, + Scene, + Script, + Model, + Texture, + Audio, + Prefab, + Shader, + Other + }; + + struct AssetEntry + { + std::string name; + std::filesystem::path path; + EditorAssetType type; + uint32_t icon = 0; + bool isDirectory = false; + }; + +} // namespace Chained + +#endif // CH_EDITOR_ASSET_TYPES_H diff --git a/editor/editor_colors.h b/editor/editor_colors.h new file mode 100644 index 000000000..4596b3581 --- /dev/null +++ b/editor/editor_colors.h @@ -0,0 +1,49 @@ +#ifndef CH_EDITOR_COLORS_H +#define CH_EDITOR_COLORS_H + +#include "imgui.h" + +namespace Chained +{ + namespace EditorColors + { + // Toolbar & buttons + inline constexpr ImVec4 TransparentButton = {0.1f, 0.1f, 0.1f, 0.0f}; + inline constexpr ImVec4 ActiveToolOrange = {0.9f, 0.45f, 0.0f, 1.0f}; + inline constexpr ImVec4 PlayGreen = {0.3f, 1.0f, 0.3f, 1.0f}; + inline constexpr ImVec4 SimulateOrange = {1.0f, 0.64f, 0.0f, 1.0f}; + inline constexpr ImVec4 ActiveSnapBlue = {0.3f, 0.8f, 1.0f, 1.0f}; + + // Panel backgrounds + inline constexpr ImVec4 ToolbarBg = {0.1f, 0.1f, 0.12f, 0.8f}; + inline constexpr ImVec4 FloatingToolbarBg = {0.1f, 0.1f, 0.12f, 0.0f}; + + // Selection & highlights + inline constexpr ImVec4 SelectionYellow = {1.0f, 1.0f, 0.0f, 1.0f}; + inline constexpr ImVec4 SelectionGreen = {0.0f, 1.0f, 0.0f, 1.0f}; + + // Loading overlay + inline constexpr ImVec4 LoadingOverlayBg = {0.02f, 0.02f, 0.02f, 0.92f}; + + // Content browser / project selector + inline constexpr ImVec4 SidebarBg = {0.15f, 0.15f, 0.18f, 1.0f}; + inline constexpr ImVec4 DarkPanelBg = {0.02f, 0.02f, 0.02f, 1.0f}; + inline constexpr ImVec4 ProjectCardBg = {0.12f, 0.12f, 0.13f, 1.0f}; + inline constexpr ImVec4 ProjectCardHover = {0.18f, 0.18f, 0.19f, 1.0f}; + inline constexpr ImVec4 ProjectCardActive = {0.1f, 0.1f, 0.1f, 1.0f}; + inline constexpr ImVec4 ProjectCardBorder = {0.18f, 0.18f, 0.20f, 1.0f}; + inline constexpr ImVec4 ProjectCardBorderHover = {0.26f, 0.26f, 0.28f, 1.0f}; + inline constexpr ImVec4 ProjectCardBorderActive = {0.14f, 0.14f, 0.16f, 1.0f}; + inline constexpr ImVec4 SubCardBg = {0.06f, 0.06f, 0.07f, 1.0f}; + inline constexpr ImVec4 SubCardBorder = {0.14f, 0.14f, 0.16f, 1.0f}; + inline constexpr ImVec4 SubCardBorderHover = {0.22f, 0.22f, 0.24f, 1.0f}; + inline constexpr ImVec4 SubCardBorderActive = {0.10f, 0.10f, 0.12f, 1.0f}; + inline constexpr ImVec4 PrimaryButton = {0.13f, 0.45f, 0.80f, 1.0f}; + inline constexpr ImVec4 PrimaryButtonHover = {0.20f, 0.55f, 0.92f, 1.0f}; + inline constexpr ImVec4 PrimaryButtonActive = {0.10f, 0.38f, 0.70f, 1.0f}; + inline constexpr ImVec4 MutedText = {0.5f, 0.5f, 0.5f, 1.0f}; + inline constexpr ImVec4 BrightText = {0.9f, 0.9f, 0.9f, 1.0f}; + } // namespace EditorColors +} // namespace Chained + +#endif // CH_EDITOR_COLORS_H diff --git a/editor/editor_context.cpp b/editor/editor_context.cpp deleted file mode 100644 index ecff9039c..000000000 --- a/editor/editor_context.cpp +++ /dev/null @@ -1,19 +0,0 @@ -#include "editor_context.h" - -namespace CHEngine -{ -EditorState EditorContext::s_State; -SceneState EditorContext::s_SceneState = SceneState::Edit; - -void EditorContext::Init() -{ - s_State.DebugRenderFlags.DrawColliders = true; - s_State.DebugRenderFlags.DrawLights = true; - s_State.DebugRenderFlags.DrawSpawnZones = true; -} - -void EditorContext::Shutdown() -{ - s_State.SelectedEntity = {}; -} -} // namespace CHEngine diff --git a/editor/editor_context.h b/editor/editor_context.h deleted file mode 100644 index 411e7c88f..000000000 --- a/editor/editor_context.h +++ /dev/null @@ -1,70 +0,0 @@ -#ifndef CH_EDITOR_CONTEXT_H -#define CH_EDITOR_CONTEXT_H - -#include "engine/graphics/pipeline/renderer.h" -#include "engine/scene/scene.h" -#include - -namespace CHEngine -{ -enum class SceneState : uint8_t -{ - Edit = 0, // Editor-only update mode. - Play = 1 // Runtime simulation mode. -}; - -// Mutable editor session state shared across panels and the editor layer. -struct EditorState -{ - Entity SelectedEntity; - bool FullscreenGame = false; - bool StandaloneActive = false; - bool NeedsLayoutReset = false; - int LastHitMeshIndex = -1; - DebugRenderFlags DebugRenderFlags; - bool IsLoading = false; - std::string LoadingStatus = ""; -}; - -// EditorContext stores global editor state such as the selected entity, -// scene mode, and debug flags so panels do not need direct EditorLayer access. -class EditorContext -{ -public: - static void Init(); - static void Shutdown(); - - static Entity GetSelectedEntity() - { - return s_State.SelectedEntity; - } - static void SetSelectedEntity(Entity entity) - { - s_State.SelectedEntity = entity; - } - - static SceneState GetSceneState() - { - return s_SceneState; - } - static void SetSceneState(SceneState state) - { - s_SceneState = state; - } - - static DebugRenderFlags& GetDebugRenderFlags() - { - return s_State.DebugRenderFlags; - } - static EditorState& GetState() - { - return s_State; - } - -private: - static EditorState s_State; - static SceneState s_SceneState; -}; -} // namespace CHEngine - -#endif // CH_EDITOR_CONTEXT_H diff --git a/editor/editor_events.h b/editor/editor_events.h deleted file mode 100644 index f039e965f..000000000 --- a/editor/editor_events.h +++ /dev/null @@ -1,68 +0,0 @@ -#ifndef CH_EDITOR_EVENTS_H -#define CH_EDITOR_EVENTS_H - -#include "engine/core/events.h" -#include "engine/scene/entity.h" - -namespace CHEngine -{ -// Event to trigger an layout reset. -class AppResetLayoutEvent : public Event -{ -public: - AppResetLayoutEvent() = default; - EVENT_CLASS_TYPE(AppResetLayout) - EVENT_CLASS_CATEGORY(EventCategoryApplication) -}; - -// Event to save current window layout. -class AppSaveLayoutEvent : public Event -{ -public: - AppSaveLayoutEvent() = default; - EVENT_CLASS_TYPE(AppSaveLayout) - EVENT_CLASS_CATEGORY(EventCategoryApplication) -}; - -// Event to trigger launching the game in runtime mode. -class AppLaunchRuntimeEvent : public Event -{ -public: - AppLaunchRuntimeEvent() = default; - EVENT_CLASS_TYPE(AppLaunchRuntime) - EVENT_CLASS_CATEGORY(EventCategoryApplication) -}; - -// Event to signal focusing on a specific entity in the viewport -class ViewportFocusEntityEvent : public Event -{ -public: - ViewportFocusEntityEvent(Entity entity) : m_Entity(entity) {} - Entity GetEntity() const { return m_Entity; } - - EVENT_CLASS_TYPE(ViewportFocusEntity) - EVENT_CLASS_CATEGORY(EventCategoryApplication) -private: - Entity m_Entity; -}; - -// Undo system events -class UndoEvent : public Event -{ -public: - UndoEvent() = default; - EVENT_CLASS_TYPE(Undo) - EVENT_CLASS_CATEGORY(EventCategoryApplication) -}; - -class RedoEvent : public Event -{ -public: - RedoEvent() = default; - EVENT_CLASS_TYPE(Redo) - EVENT_CLASS_CATEGORY(EventCategoryApplication) -}; - -} // namespace CHEngine - -#endif // CH_EDITOR_EVENTS_H diff --git a/editor/editor_gui.cpp b/editor/editor_gui.cpp deleted file mode 100644 index 473bfec96..000000000 --- a/editor/editor_gui.cpp +++ /dev/null @@ -1,605 +0,0 @@ -#include "editor_gui.h" -#include "editor/editor_layer.h" -#include "editor/panels/panel.h" -#include "editor/panels/viewport_panel.h" -#include "editor_events.h" -#include "engine/core/application.h" -#include "engine/scene/components.h" -#include "engine/scene/project.h" -#include "IconsFontAwesome6.h" -#include "scripting/scriptengine.h" - -#define IMGUI_DEFINE_MATH_OPERATORS -#include "engine/platform/utils/dialogs.h" -#include "engine/scene/scene_picking.h" -#include "imgui.h" -#include "imgui_internal.h" -#include -#include -#include - -namespace CHEngine -{ -// --- Internal Helpers --- - -static void DrawPropertyLabel(const char* label) -{ - if (ImGui::GetCurrentTable() != nullptr) - { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImGui::AlignTextToFramePadding(); - ImGui::Text(label); - ImGui::TableSetColumnIndex(1); - } - else - { - ImGui::Text(label); - ImGui::SameLine(ImGui::GetContentRegionAvail().x * 0.4f); - } -} - -// --- Menu System Implementation --- - -void EditorGUI::DrawMenuBar(EditorPanels& panels) -{ - if (!ImGui::BeginMenuBar()) - { - return; - } - - // File Menu - if (ImGui::BeginMenu("File")) - { - if (ImGui::MenuItem(ICON_FA_FILE " New Project", "Ctrl+Shift+N")) - { - auto newScene = std::make_shared(); - - // Ensure every scene starts with a Main Camera - Entity camera = newScene->CreateEntity("Main Camera"); - auto& cc = camera.AddComponent(); - cc.Primary = true; - camera.GetComponent().Translation = {0, 5, 10}; - - EditorLayer::Get().GetSceneManager().SetScene(newScene); - } - if (ImGui::MenuItem(ICON_FA_FOLDER_OPEN " Open Project", "Ctrl+O")) - { - std::vector filters = {{"Chained Scene", "chscene"}}; - auto result = Dialogs::OpenFile(filters); - if (result) - { - EditorLayer::Get().GetSceneManager().OpenScene(*result); - } - } - if (ImGui::MenuItem(ICON_FA_FLOPPY_DISK " Save Project")) - { - EditorLayer::Get().GetSceneManager().SaveScene(); - } - if (ImGui::MenuItem(ICON_FA_XMARK " Close Project")) - { - Project::SetActive(nullptr); - } - ImGui::Separator(); - if (ImGui::MenuItem(ICON_FA_FILE_CODE " New Scene", "Ctrl+N")) - { - EditorLayer::Get().GetSceneManager().NewScene(); - } - if (ImGui::MenuItem(ICON_FA_FLOPPY_DISK " Save Scene", "Ctrl+S")) - { - EditorLayer::Get().GetSceneManager().SaveScene(); - } - if (ImGui::MenuItem(ICON_FA_FILE_EXPORT " Save Scene As...", "Ctrl+Shift+S")) - { - EditorLayer::Get().GetSceneManager().SaveSceneAs(); - } - if (ImGui::MenuItem(ICON_FA_FOLDER_OPEN " Load Scene", "Ctrl+L")) - { - EditorLayer::Get().GetSceneManager().OpenScene(); - } - ImGui::Separator(); - if (ImGui::MenuItem(ICON_FA_POWER_OFF " Exit")) - { - Application::Get().Close(); - } - ImGui::EndMenu(); - } - - // View Menu - if (ImGui::BeginMenu("View")) - { - panels.ForEach([](std::shared_ptr panel) { - if (panel->GetName() != "Viewport" && panel->GetName() != "Project Browser") - { - ImGui::MenuItem(panel->GetName().c_str(), nullptr, &panel->IsOpen()); - } - }); - ImGui::Separator(); - if (ImGui::MenuItem(ICON_FA_EXPAND " Fullscreen", "F11")) - { - Application::Get().GetWindow().ToggleFullscreen(); - } - if (ImGui::MenuItem(ICON_FA_ARROWS_ROTATE " Reset Layout")) - { - AppResetLayoutEvent e; - Application::Get().OnEvent(e); - } - if (ImGui::MenuItem(ICON_FA_FLOPPY_DISK " Save Layout")) - { - AppSaveLayoutEvent e; - Application::Get().OnEvent(e); - } - ImGui::EndMenu(); - } - - // Project Menu - if (ImGui::BeginMenu("Project")) - { - if (ImGui::MenuItem(ICON_FA_GEARS " Settings")) - { - if (auto p = panels.Get("Project Settings")) - { - p->IsOpen() = true; - } - } - if (ImGui::MenuItem(ICON_FA_ROCKET " Build & Run")) - { - AppLaunchRuntimeEvent e; - Application::Get().OnEvent(e); - } - ImGui::Separator(); - if (ImGui::MenuItem(ICON_FA_ARROWS_ROTATE " Reload Shaders")) - { - Renderer::Get().GetShaderLibrary().ReloadAll(); - } - if (ImGui::MenuItem(ICON_FA_FILE_CODE " Reload Scripts", "Ctrl+R")) - { - auto& scriptEngine = ScriptEngine::Get(); - scriptEngine.RequestAssemblyReload("EditorGUI"); - } - ImGui::EndMenu(); - } - - ImGui::EndMenuBar(); -} - -void EditorGUI::BeginPropertyGrid() -{ - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 4)); - ImGui::BeginTable("PropertyGrid", 2, ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchSame); - ImGui::TableSetupColumn("Label", ImGuiTableColumnFlags_WidthFixed, 100.0f); - ImGui::TableSetupColumn("Control", ImGuiTableColumnFlags_WidthStretch); -} - -void EditorGUI::EndPropertyGrid() -{ - ImGui::EndTable(); - ImGui::PopStyleVar(); -} - -void EditorGUI::BeginProperty(const char* label) -{ - DrawPropertyLabel(label); - ImGui::PushID(label); - ImGui::PushItemWidth(-1); -} - -void EditorGUI::EndProperty() -{ - ImGui::PopItemWidth(); - ImGui::PopID(); -} - -// --- Property Widgets Implementation (New Unified Style) --- - -bool EditorGUI::Property(const char* label, bool& value) -{ - DrawPropertyLabel(label); - ImGui::PushID(label); - bool changed = ImGui::Checkbox("##prop", &value); - ImGui::PopID(); - return changed; -} - -bool EditorGUI::Property(const char* label, float& value, float speed, float min, float max) -{ - DrawPropertyLabel(label); - ImGui::PushID(label); - bool changed = ImGui::DragFloat("##prop", &value, speed, min, max); - ImGui::PopID(); - return changed; -} - -bool EditorGUI::Property(const char* label, int& value, int min, int max) -{ - DrawPropertyLabel(label); - ImGui::PushID(label); - bool changed = ImGui::DragInt("##prop", &value, 1.0f, min, max); - ImGui::PopID(); - return changed; -} - -bool EditorGUI::Property(const char* label, uint64_t& value) -{ - DrawPropertyLabel(label); - ImGui::PushID(label); - bool changed = ImGui::InputScalar("##prop", ImGuiDataType_U64, &value); - ImGui::PopID(); - return changed; -} - -bool EditorGUI::Property(const char* label, std::string& value, bool multiline) -{ - DrawPropertyLabel(label); - ImGui::PushID(label); - char buffer[1024]; - memset(buffer, 0, sizeof(buffer)); - strncpy(buffer, value.c_str(), sizeof(buffer) - 1); - bool changed = false; - if (multiline) - { - if (ImGui::InputTextMultiline("##prop", buffer, sizeof(buffer), - ImVec2(0, ImGui::GetTextLineHeightWithSpacing() * 3))) - { - value = buffer; - changed = true; - } - } - else - { - if (ImGui::InputText("##prop", buffer, sizeof(buffer))) - { - value = buffer; - changed = true; - } - } - ImGui::PopID(); - return changed; -} - -bool EditorGUI::Property(const char* label, Color& value) -{ - DrawPropertyLabel(label); - ImGui::PushID(label); - float c[4] = {value.r / 255.0f, value.g / 255.0f, value.b / 255.0f, value.a / 255.0f}; - bool changed = ImGui::ColorEdit4("##prop", c); - if (changed) - { - value = {(unsigned char)(c[0] * 255), (unsigned char)(c[1] * 255), (unsigned char)(c[2] * 255), - (unsigned char)(c[3] * 255)}; - } - ImGui::PopID(); - return changed; -} - -bool EditorGUI::Property(const char* label, glm::vec2& value, float speed, float min, float max) -{ - return DrawVec2(label, value, 0.0f); -} -bool EditorGUI::Property(const char* label, glm::vec3& value, float speed, float min, float max) -{ - return DrawVec3(label, value, 0.0f); -} -bool EditorGUI::Property(const char* label, glm::vec4& value, float speed, float min, float max) -{ - return DrawVec4(label, value, 0.0f); -} - -bool EditorGUI::Property(const char* label, int& value, const char** items, int itemCount) -{ - DrawPropertyLabel(label); - ImGui::PushID(label); - bool changed = ImGui::Combo("##prop", &value, items, itemCount); - ImGui::PopID(); - return changed; -} - -bool EditorGUI::FileProperty(const char* label, std::string& value, const char* filter) -{ - DrawPropertyLabel(label); - ImGui::PushID(label); - float width = ImGui::GetContentRegionAvail().x; - float buttonSize = ImGui::GetFontSize() + ImGui::GetStyle().FramePadding.y * 2.0f; - ImGui::PushItemWidth(width - buttonSize - 5.0f); - std::string displayPath = Project::GetRelativePath(value); - char buffer[256]; - memset(buffer, 0, sizeof(buffer)); - strncpy(buffer, displayPath.c_str(), sizeof(buffer) - 1); - - bool changed = false; - if (ImGui::InputText("##prop", buffer, sizeof(buffer))) - { - value = Project::GetAbsolutePath(buffer).string(); - changed = true; - } - if (ImGui::BeginDragDropTarget()) - { - if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("CONTENT_BROWSER_ITEM")) - { - const char* dropPath = (const char*)payload->Data; - value = Project::GetRelativePath(dropPath); - changed = true; - } - ImGui::EndDragDropTarget(); - } - ImGui::PopItemWidth(); - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_FOLDER_OPEN, {buttonSize, buttonSize})) - { - std::vector filters; - if (filter != nullptr && filter[0] != '\0') - { - filters.push_back({"Files", filter}); - } - auto result = Dialogs::OpenFile(filters); - if (result) - { - value = Project::GetRelativePath(*result); - changed = true; - } - } - ImGui::PopID(); - return changed; -} - -bool EditorGUI::FileProperty(const char* label, std::string& path, uint32_t textureId, const char* filter) -{ - DrawPropertyLabel(label); - ImGui::PushID(label); - float width = ImGui::GetContentRegionAvail().x; - float buttonSize = ImGui::GetFontSize() + ImGui::GetStyle().FramePadding.y * 2.0f; - float thumbnailSize = buttonSize * 1.5f; - if (textureId > 0) - { - ImGui::Image((void*)(intptr_t)textureId, {thumbnailSize, thumbnailSize}, {0, 1}, {1, 0}); - } - else - { - ImGui::Button("##empty", {thumbnailSize, thumbnailSize}); - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("No texture loaded"); - } - } - ImGui::SameLine(); - ImGui::PushItemWidth(width - buttonSize - thumbnailSize - 10.0f); - - std::string displayPath = Project::GetRelativePath(path); - char buffer[256]; - memset(buffer, 0, sizeof(buffer)); - strncpy(buffer, displayPath.c_str(), sizeof(buffer) - 1); - - bool changed = false; - if (ImGui::InputText("##prop", buffer, sizeof(buffer))) - { - path = Project::GetAbsolutePath(buffer).string(); - changed = true; - } - if (ImGui::BeginDragDropTarget()) - { - if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("CONTENT_BROWSER_ITEM")) - { - const char* dropPath = (const char*)payload->Data; - path = Project::GetRelativePath(dropPath); - changed = true; - } - ImGui::EndDragDropTarget(); - } - ImGui::PopItemWidth(); - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_FOLDER_OPEN, {buttonSize, buttonSize})) - { - std::vector filters; - if (filter != nullptr && filter[0] != '\0') - { - filters.push_back({"Files", filter}); - } - auto result = Dialogs::OpenFile(filters); - if (result) - { - path = Project::GetRelativePath(*result); - changed = true; - } - } - ImGui::PopID(); - return changed; -} - -bool EditorGUI::ActionButton(const char* icon, const char* label) -{ - std::string text = std::string(icon) + " " + label; - return ImGui::Button(text.c_str()); -} - -static void DrawPropertyControl(const char* id, float& val, ImVec4 color, const char* label, float resetValue, - float width, bool& changed) -{ - ImGuiIO& io = ImGui::GetIO(); - ImGui::PushID(label); - - float lineHeight = ImGui::GetFontSize() + ImGui::GetStyle().FramePadding.y * 2.0f; - ImVec2 buttonSize = {lineHeight, lineHeight}; - - // Label with background color - ImGui::PushStyleColor(ImGuiCol_Button, color); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, color); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, color); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 3.0f); - // Use a button as a colored label - if (ImGui::Button(label, buttonSize)) - { - val = resetValue; - changed = true; - } - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("Click to reset to %.2f", resetValue); - } - - ImGui::PopStyleVar(); - ImGui::PopStyleColor(3); - - ImGui::SameLine(0, 0); // No spacing between label and input - - ImGui::SetNextItemWidth(width - buttonSize.x); - char buf[32]; - sprintf(buf, "##%s_%s", label, id); - if (ImGui::DragFloat(buf, &val, 0.1f, 0.0f, 0.0f, "%.2f")) - { - changed = true; - } - - ImGui::PopID(); -} - -bool EditorGUI::DrawVec3(const char* label, glm::vec3& values, float resetValue) -{ - DrawPropertyLabel(label); - ImGui::PushID(label); - - bool changed = false; - - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2{4, 0}); - float width = ImGui::GetContentRegionAvail().x; - float itemWidth = (width - 8.0f) / 3.0f; // 4px spacing * 2 - - ImGui::BeginGroup(); - - ImGui::SetNextItemWidth(itemWidth); - DrawPropertyControl("x", values.x, {0.8f, 0.1f, 0.15f, 1.0f}, "X", resetValue, itemWidth, changed); - ImGui::SameLine(); - - ImGui::SetNextItemWidth(itemWidth); - DrawPropertyControl("y", values.y, {0.2f, 0.7f, 0.2f, 1.0f}, "Y", resetValue, itemWidth, changed); - ImGui::SameLine(); - - ImGui::SetNextItemWidth(itemWidth); - DrawPropertyControl("z", values.z, {0.1f, 0.25f, 0.8f, 1.0f}, "Z", resetValue, itemWidth, changed); - - ImGui::EndGroup(); - - ImGui::PopStyleVar(); - ImGui::PopID(); - return changed; -} - -bool EditorGUI::DrawVec2(const char* label, glm::vec2& values, float resetValue) -{ - DrawPropertyLabel(label); - ImGui::PushID(label); - - bool changed = false; - - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2{4, 0}); - float width = ImGui::GetContentRegionAvail().x; - float itemWidth = (width - 4.0f) / 2.0f; - - ImGui::BeginGroup(); - - ImGui::SetNextItemWidth(itemWidth); - DrawPropertyControl("x", values.x, {0.8f, 0.1f, 0.15f, 1.0f}, "X", resetValue, itemWidth, changed); - ImGui::SameLine(); - - ImGui::SetNextItemWidth(itemWidth); - DrawPropertyControl("y", values.y, {0.2f, 0.7f, 0.2f, 1.0f}, "Y", resetValue, itemWidth, changed); - - ImGui::EndGroup(); - - ImGui::PopStyleVar(); - ImGui::PopID(); - return changed; -} - -bool EditorGUI::DrawVec4(const char* label, glm::vec4& values, float resetValue) -{ - DrawPropertyLabel(label); - ImGui::PushID(label); - - bool changed = false; - - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2{4, 0}); - float width = ImGui::GetContentRegionAvail().x; - float itemWidth = (width - 12.0f) / 4.0f; - - ImGui::BeginGroup(); - - ImGui::SetNextItemWidth(itemWidth); - DrawPropertyControl("x", values.x, {0.8f, 0.1f, 0.15f, 1.0f}, "X", resetValue, itemWidth, changed); - ImGui::SameLine(); - - ImGui::SetNextItemWidth(itemWidth); - DrawPropertyControl("y", values.y, {0.2f, 0.7f, 0.2f, 1.0f}, "Y", resetValue, itemWidth, changed); - ImGui::SameLine(); - - ImGui::SetNextItemWidth(itemWidth); - DrawPropertyControl("z", values.z, {0.1f, 0.25f, 0.8f, 1.0f}, "Z", resetValue, itemWidth, changed); - ImGui::SameLine(); - - ImGui::SetNextItemWidth(itemWidth); - DrawPropertyControl("w", values.w, {0.5f, 0.5f, 0.5f, 1.0f}, "W", resetValue, itemWidth, changed); - - ImGui::EndGroup(); - - ImGui::PopStyleVar(); - ImGui::PopID(); - return changed; -} - -void EditorGUI::ApplyTheme() -{ - ImGuiStyle& style = ImGui::GetStyle(); - style.WindowRounding = 5.0f; - style.FrameRounding = 4.0f; - style.PopupRounding = 4.0f; - style.ScrollbarRounding = 12.0f; - style.GrabRounding = 4.0f; - style.TabRounding = 4.0f; - - ImVec4* colors = style.Colors; - colors[ImGuiCol_Text] = ImVec4(0.95f, 0.96f, 0.98f, 1.00f); - colors[ImGuiCol_TextDisabled] = ImVec4(0.36f, 0.42f, 0.47f, 1.00f); - colors[ImGuiCol_WindowBg] = ImVec4(0.10f, 0.12f, 0.14f, 1.00f); - colors[ImGuiCol_ChildBg] = ImVec4(0.12f, 0.14f, 0.16f, 1.00f); - colors[ImGuiCol_PopupBg] = ImVec4(0.08f, 0.10f, 0.12f, 0.94f); - colors[ImGuiCol_Border] = ImVec4(0.20f, 0.22f, 0.25f, 0.50f); - colors[ImGuiCol_FrameBg] = ImVec4(0.18f, 0.20f, 0.22f, 1.00f); - colors[ImGuiCol_FrameBgHovered] = ImVec4(0.25f, 0.28f, 0.32f, 1.00f); - colors[ImGuiCol_FrameBgActive] = ImVec4(0.22f, 0.24f, 0.26f, 1.00f); - colors[ImGuiCol_TitleBg] = ImVec4(0.08f, 0.10f, 0.12f, 1.00f); - colors[ImGuiCol_TitleBgActive] = ImVec4(0.06f, 0.08f, 0.10f, 1.00f); - - colors[ImGuiCol_Header] = ImVec4(0.20f, 0.25f, 0.35f, 0.60f); - colors[ImGuiCol_HeaderHovered] = ImVec4(0.25f, 0.35f, 0.50f, 0.80f); - colors[ImGuiCol_HeaderActive] = ImVec4(0.30f, 0.40f, 0.60f, 1.00f); - - colors[ImGuiCol_Separator] = ImVec4(0.20f, 0.22f, 0.25f, 1.00f); - colors[ImGuiCol_CheckMark] = ImVec4(0.40f, 0.60f, 0.90f, 1.00f); - colors[ImGuiCol_SliderGrab] = ImVec4(0.40f, 0.60f, 0.90f, 1.00f); - colors[ImGuiCol_SliderGrabActive] = ImVec4(0.50f, 0.70f, 1.00f, 1.00f); - colors[ImGuiCol_Button] = ImVec4(0.18f, 0.20f, 0.22f, 1.00f); - colors[ImGuiCol_ButtonHovered] = ImVec4(0.25f, 0.35f, 0.50f, 1.00f); - colors[ImGuiCol_ButtonActive] = ImVec4(0.30f, 0.45f, 0.70f, 1.00f); - - colors[ImGuiCol_Tab] = ImVec4(0.08f, 0.10f, 0.12f, 1.00f); - colors[ImGuiCol_TabHovered] = ImVec4(0.25f, 0.35f, 0.50f, 0.80f); - colors[ImGuiCol_TabActive] = ImVec4(0.12f, 0.14f, 0.16f, 1.00f); - colors[ImGuiCol_TabUnfocused] = ImVec4(0.08f, 0.10f, 0.12f, 1.00f); - colors[ImGuiCol_TabUnfocusedActive] = ImVec4(0.10f, 0.12f, 0.14f, 1.00f); - colors[ImGuiCol_PlotLines] = ImVec4(0.61f, 0.61f, 0.61f, 1.00f); - colors[ImGuiCol_PlotLinesHovered] = ImVec4(1.00f, 0.43f, 0.35f, 1.00f); - colors[ImGuiCol_PlotHistogram] = ImVec4(0.90f, 0.70f, 0.00f, 1.00f); - colors[ImGuiCol_PlotHistogramHovered] = ImVec4(1.00f, 0.60f, 0.00f, 1.00f); - colors[ImGuiCol_TextSelectedBg] = ImVec4(0.26f, 0.59f, 0.98f, 0.35f); - colors[ImGuiCol_DragDropTarget] = ImVec4(1.00f, 1.00f, 0.00f, 0.90f); - colors[ImGuiCol_NavHighlight] = ImVec4(0.26f, 0.59f, 0.98f, 1.00f); - colors[ImGuiCol_NavWindowingHighlight] = ImVec4(1.00f, 1.00f, 1.00f, 0.70f); - colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.80f, 0.80f, 0.80f, 0.20f); - colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.80f, 0.80f, 0.80f, 0.35f); -} - -Ray EditorGUI::GetMouseRay(const Camera3D& camera, const glm::vec2& mousePosition, const glm::vec2& viewportSize) -{ - return ScenePicker::CreateRayFromViewport(camera, mousePosition, viewportSize); -} - -} // namespace CHEngine diff --git a/editor/editor_gui.h b/editor/editor_gui.h deleted file mode 100644 index 2914a46b9..000000000 --- a/editor/editor_gui.h +++ /dev/null @@ -1,61 +0,0 @@ -#ifndef CH_EDITOR_GUI_H -#define CH_EDITOR_GUI_H - -#include -#include -#include -#include - -#include "editor_layer.h" -#include "editor_panels.h" -#include "engine/core/ch_math.h" -#include "engine/graphics/pipeline/renderer.h" - - -namespace CHEngine -{ -// Immediate-mode GUI helpers shared by editor panels and property inspectors. -class EditorGUI -{ -public: - // Draws the main editor menu bar for the provided panel set. - static void DrawMenuBar(EditorPanels& panels); - - // Property layout helpers. - static void BeginPropertyGrid(); - static void EndPropertyGrid(); - static void BeginProperty(const char* label); - static void EndProperty(); - - // Simple declarative property widgets that do not use columns. - static bool Property(const char* label, bool& value); - static bool Property(const char* label, int& value, int min = 0, int max = 0); - static bool Property(const char* label, float& value, float speed = 0.1f, float min = 0.0f, float max = 0.0f); - static bool Property(const char* label, std::string& value, bool multiline = false); - static bool Property(const char* label, CHEngine::Color& value); - static bool Property(const char* label, glm::vec2& value, float speed = 0.1f, float min = 0.0f, float max = 0.0f); - static bool Property(const char* label, glm::vec3& value, float speed = 0.1f, float min = 0.0f, float max = 0.0f); - static bool Property(const char* label, glm::vec4& value, float speed = 0.1f, float min = 0.0f, float max = 0.0f); - static bool Property(const char* label, uint64_t& value); - - static bool Property(const char* label, int& value, const char** items, int itemCount); - - // Action widgets. - static bool ActionButton(const char* icon, const char* label); - - // File property widgets. - static bool FileProperty(const char* label, std::string& value, const char* filter = nullptr); - static bool FileProperty(const char* label, std::string& path, uint32_t textureId, const char* filter = nullptr); - - static bool DrawVec2(const char* label, glm::vec2& values, float resetValue = 0.0f); - static bool DrawVec3(const char* label, glm::vec3& values, float resetValue = 0.0f); - static bool DrawVec4(const char* label, glm::vec4& values, float resetValue = 0.0f); - // Applies the editor-wide ImGui style. - static void ApplyTheme(); - // Builds a world ray from screen-space mouse coordinates. - static Ray GetMouseRay(const struct Camera3D& camera, const glm::vec2& mousePosition, const glm::vec2& viewportSize); -}; - -} // namespace CHEngine - -#endif // CH_EDITOR_GUI_H diff --git a/editor/editor_layer.cpp b/editor/editor_layer.cpp deleted file mode 100644 index cad96069c..000000000 --- a/editor/editor_layer.cpp +++ /dev/null @@ -1,510 +0,0 @@ -#include "editor_layer.h" -#include "editor_panels.h" -#include "editor_layout.h" -#include "editor_events.h" -#include "editor_gui.h" -#include "engine/core/imgui_layer.h" -#include "engine/core/input.h" -#include "launcher/editor_launcher.h" - -#include "IconsFontAwesome6.h" -#include "engine/core/assets/asset_manager.h" -#include "engine/core/profiler.h" -#include "engine/core/thread_pool.h" -#include "engine/graphics/pipeline/render_command.h" -#include "engine/graphics/pipeline/ui_renderer.h" -#include "engine/physics/physics.h" -#include "engine/platform/utils/dialogs.h" -#include "engine/scene/project.h" -#include "engine/scene/project_serializer.h" -#include "engine/scene/scene_serializer.h" -#include "panels/console_panel.h" -#include "panels/content_browser_panel.h" -#include "panels/project_browser_panel.h" -#include "panels/property_editor.h" -#include "panels/viewport_panel.h" -#include "scripting/scene_scripting.h" -#include "scripting/scriptengine.h" -#include -#include -#include - - -namespace CHEngine -{ -void EditorLayer::DrawLoadingOverlay(const char* title, const char* status) -{ - ImGuiViewport* viewport = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos(viewport->WorkPos); - ImGui::SetNextWindowSize(viewport->WorkSize); - ImGui::SetNextWindowViewport(viewport->ID); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoDocking | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | - ImGuiWindowFlags_NoInputs; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.02f, 0.02f, 0.02f, 0.92f)); - - if (ImGui::Begin("##EditorLoadingOverlay", nullptr, flags)) - { - const size_t loadingCount = CHEngine::AssetManager::Get().GetLoadingAssetCount(); - const size_t pendingFinalizeCount = CHEngine::AssetManager::Get().GetPendingFinalizeCount(); - const size_t totalPending = loadingCount + pendingFinalizeCount; - - ImGui::SetCursorPosY(ImGui::GetWindowHeight() * 0.45f); - - ImVec2 titleSize = ImGui::CalcTextSize(title); - ImGui::SetCursorPosX((ImGui::GetWindowWidth() - titleSize.x) * 0.5f); - ImGui::TextUnformatted(title); - - ImVec2 statusSize = ImGui::CalcTextSize(status); - ImGui::SetCursorPosX((ImGui::GetWindowWidth() - statusSize.x) * 0.5f); - ImGui::TextUnformatted(status); - - std::string pendingLine = "Pending assets: " + std::to_string(totalPending); - ImVec2 pendingSize = ImGui::CalcTextSize(pendingLine.c_str()); - ImGui::SetCursorPosX((ImGui::GetWindowWidth() - pendingSize.x) * 0.5f); - ImGui::TextUnformatted(pendingLine.c_str()); - } - - ImGui::End(); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(); -} - -EditorLayer* EditorLayer::s_Instance = nullptr; - -EditorLayer::EditorLayer() - : Layer("EditorLayer") -{ - // Ensure the engine DLL uses the same ImGui context as the Editor - ImGuiLayer::SetContext(ImGui::GetCurrentContext()); - - s_Instance = this; - EditorContext::Init(); - - m_ProjectManager = std::make_unique(); - m_SceneManager = std::make_unique(); - m_Layout = std::make_unique(); - m_Panels = std::make_unique(); - - LoadConfig(); -} - -EditorLayer::~EditorLayer() -{ -} - -void EditorLayer::LoadConfig() -{ - std::filesystem::path configPath = std::filesystem::current_path() / "editor_settings.yaml"; - if (!std::filesystem::exists(configPath)) - { - return; - } - - try - { - YAML::Node data = YAML::LoadFile(configPath.string()); - if (data["Editor"]) - { - auto node = data["Editor"]; - if (node["LastProjectPath"]) - { - std::string lastProj = node["LastProjectPath"].as(""); - m_ProjectManager->SetLastProjectPath(lastProj); - m_Config.LastProjectPath = lastProj; - } - if (node["LastScenePath"]) - { - m_Config.LastScenePath = node["LastScenePath"].as(""); - } - if (node["LoadLastProjectOnStartup"]) - { - m_Config.LoadLastProjectOnStartup = node["LoadLastProjectOnStartup"].as(false); - } - if (node["AutoSaveEnabled"]) - { - m_Config.AutoSaveEnabled = node["AutoSaveEnabled"].as(true); - } - if (node["AutoSaveInterval"]) - { - m_Config.AutoSaveInterval = node["AutoSaveInterval"].as(300.0f); - } - if (node["RecentProjects"]) - { - m_Config.RecentProjects.clear(); - for (const auto& entry : node["RecentProjects"]) - { - m_Config.RecentProjects.push_back(entry.as()); - } - } - } - } catch (const std::exception& e) - { - CH_CORE_ERROR("EditorLayer: Failed to load editor settings: {}", e.what()); - } -} - -void EditorLayer::SaveConfig() -{ - YAML::Emitter out; - out << YAML::BeginMap; - out << YAML::Key << "Editor" << YAML::Value << YAML::BeginMap; - out << YAML::Key << "LastProjectPath" << YAML::Value << m_ProjectManager->GetLastProjectPath(); - m_Config.LastProjectPath = m_ProjectManager->GetLastProjectPath(); - out << YAML::Key << "LastScenePath" << YAML::Value << m_Config.LastScenePath; - out << YAML::Key << "LoadLastProjectOnStartup" << YAML::Value << m_Config.LoadLastProjectOnStartup; - out << YAML::Key << "AutoSaveEnabled" << YAML::Value << m_Config.AutoSaveEnabled; - out << YAML::Key << "AutoSaveInterval" << YAML::Value << m_Config.AutoSaveInterval; - - out << YAML::Key << "RecentProjects" << YAML::Value << YAML::BeginSeq; - for (const auto& path : m_Config.RecentProjects) - { - out << path; - } - out << YAML::EndSeq; - - out << YAML::EndMap; - out << YAML::EndMap; - - std::filesystem::path configPath = std::filesystem::current_path() / "editor_settings.yaml"; - std::ofstream fout(configPath); - fout << out.c_str(); -} - -void EditorLayer::OnAttach() -{ - // SetTraceLogCallback removed - now using engine logging - - EditorGUI::ApplyTheme(); - Log::SetLogCallback(ConsolePanel::AddLog); - PropertyEditor::Init(); - m_Panels->Init(); - - m_CommandHistory.SetNotifyCallback( - []() { CH_CORE_TRACE("CommandHistory: Scene state changed, notifying editor..."); }); - - // Auto-load last project/scene - const auto& config = GetConfig(); - - if (config.LoadLastProjectOnStartup && !m_ProjectManager->GetLastProjectPath().empty() && - std::filesystem::exists(m_ProjectManager->GetLastProjectPath())) - { - CH_CORE_INFO("Auto-loading last project: {}", m_ProjectManager->GetLastProjectPath()); - m_ProjectManager->OpenProject(m_ProjectManager->GetLastProjectPath()); - - if (!config.LastScenePath.empty() && std::filesystem::exists(config.LastScenePath)) - { - CH_CORE_INFO("Auto-loading last scene: {}", config.LastScenePath); - m_SceneManager->OpenScene(config.LastScenePath); - } - } - else - { - Project::SetActive(nullptr); - } - - // Ensure layout is initialized - const char* iniPath = ImGui::GetIO().IniFilename; - if (iniPath && !std::filesystem::exists(iniPath)) - { - CH_CORE_INFO("OnAttach: Layout file '{}' not found, will be reset on first frame", iniPath); - EditorContext::GetState().NeedsLayoutReset = true; - } - - std::string iconPath = AssetManager::Get().ResolvePath("engine/resources/icons/chaineddecosmapeditor.jpg"); - if (std::filesystem::exists(iconPath)) - { - Application::Get().GetWindow().SetWindowIcon(iconPath); - } - else - { - CH_CORE_WARN("Editor icon not found at: {}", iconPath); - } - CH_CORE_INFO("EditorLayer Attached with modular panels."); - - LoadEditorFonts(); -} - -void EditorLayer::LoadEditorFonts() -{ - - ImGuiIO& io = ImGui::GetIO(); - float fontSize = 16.0f; - auto& assetManager = AssetManager::Get(); - - // --- Default UI Font (Lato) --- - std::string fontPath = assetManager.ResolvePath("engine/resources/font/lato/lato-bold.ttf"); - if (std::filesystem::exists(fontPath)) - { - io.Fonts->AddFontFromFileTTF(fontPath.c_str(), fontSize); - CH_CORE_INFO("Loaded editor font: {}", fontPath); - } - else - { - CH_CORE_WARN("Editor font not found: {}. Using default ImGui font.", fontPath); - io.Fonts->AddFontDefault(); - } - - // --- Icon Font (FontAwesome) --- - std::string faPath = assetManager.ResolvePath("engine/resources/font/fa-solid-900.ttf"); - if (std::filesystem::exists(faPath)) - { - static const ImWchar icons_ranges[] = {ICON_MIN_FA, ICON_MAX_16_FA, 0}; - ImFontConfig icons_config; - icons_config.MergeMode = true; - icons_config.PixelSnapH = true; - io.Fonts->AddFontFromFileTTF(faPath.c_str(), fontSize, &icons_config, icons_ranges); - CH_CORE_INFO("Loaded and merged FontAwesome for editor: {}", faPath); - } - - unsigned char* pixels; - int width, height; - io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height); -} - -void EditorLayer::OnDetach() -{ - SaveConfig(); - EditorContext::Shutdown(); -} - -void EditorLayer::OnUpdate(Timestep ts) -{ - CH_PROFILE_FUNCTION(); - - m_SceneManager->OnUpdate(ts); - - // Sync context to panels - m_Panels->SetContext(GetActiveScene()); - - // Update all panels (includes viewport camera controller) - m_Panels->OnUpdate(ts); - - if (auto scene = GetActiveScene()) - { - if (EditorContext::GetSceneState() == SceneState::Play) - { - auto& scriptEngine = ScriptEngine::Get(); - - if (scriptEngine.CanExecuteFrameScripts()) - { - SceneScripting::Update(scene.get(), ts); - } - scene->OnUpdateRuntime(ts); - - // Handle deferred scene loading requested from C# scripts - std::string pendingPath; - if (scriptEngine.TryConsumeRequestedScene(pendingPath)) - { - SceneChangeRequestEvent ev(pendingPath); - OnEvent(ev); - } - } - else - { - scene->OnUpdateEditor(ts); - - // Auto-save logic (delegated to SceneManager) - if (m_Config.AutoSaveEnabled) - { - m_SceneManager->AutoSave(m_Config.AutoSaveInterval, ts); - } - } - - if (Input::IsKeyPressed(Key::F5)) - { - AppLaunchRuntimeEvent e; - OnEvent(e); - } - - if (Input::IsKeyDown(Key::LeftControl) && Input::IsKeyPressed(Key::R)) - { - auto& scriptEngine = ScriptEngine::Get(); - scriptEngine.RequestAssemblyReload("EditorLayer"); - } - } -} - -void EditorLayer::OnRender(Timestep ts) -{ - RenderCommand::Clear({25, 25, 25, 255}); -} - -void EditorLayer::OnImGuiRender() -{ - ImGuizmo::SetImGuiContext(ImGui::GetCurrentContext()); - ImGuizmo::BeginFrame(); - - if (EditorContext::GetState().NeedsLayoutReset) - { - ResetLayout(); - EditorContext::GetState().NeedsLayoutReset = false; - } - - if (Project::GetActive()) - { - if (EditorContext::GetState().FullscreenGame) - { - if (auto viewportPanel = m_Panels->Get()) - { - viewportPanel->OnImGuiRender(true); - } - } - else - { - DrawDockSpace(); - } - } - else - { - if (auto projectBrowser = m_Panels->Get()) - { - projectBrowser->OnImGuiRender(); - } - } - - if (EditorContext::GetState().IsLoading) - { - DrawLoadingOverlay("Editor Busy", EditorContext::GetState().LoadingStatus.c_str()); - } -} - -void EditorLayer::ResetLayout() -{ - m_Layout->ResetLayout(); -} - -void EditorLayer::DrawDockSpace() -{ - m_Layout->BeginWorkspace(); - m_Layout->DrawInterface(); - m_Layout->EndWorkspace(); -} - -// Project and Scene event handlers are now managed by EditorProjectManager and EditorSceneManager. - -std::shared_ptr EditorLayer::GetActiveScene() const -{ - return m_SceneManager->GetActiveScene(); -} - -void EditorLayer::OnEvent(Event& e) -{ - if (auto scene = GetActiveScene()) - { - SceneScripting::DispatchEvent(scene.get(), e); - } - - // Dispatch events to all editor panels - m_Panels->OnEvent(e); - - EventDispatcher dispatcher(e); - - // 1. Scene Management - dispatcher.Dispatch([this](auto& e) { return m_SceneManager->OnSceneOpened(e); }); - dispatcher.Dispatch([this](auto& e) { - m_SceneManager->SetSceneState(SceneState::Play); - return true; - }); - dispatcher.Dispatch([this](auto& e) { - m_SceneManager->SetSceneState(SceneState::Edit); - return true; - }); - - // 2. Project Management - dispatcher.Dispatch([this](auto& e) { return m_ProjectManager->OnProjectOpened(e); }); - dispatcher.Dispatch([this](auto& e) { - LaunchStandalone(); - return true; - }); - - // 3. Command/Undo - dispatcher.Dispatch([this](auto& e) { - m_CommandHistory.Undo(); - return true; - }); - dispatcher.Dispatch([this](auto& e) { - m_CommandHistory.Redo(); - return true; - }); - - // 4. Input - dispatcher.Dispatch([this](auto& e) { return m_SceneManager->OnKeyPressed(e); }); - // 3. Layout/System - dispatcher.Dispatch([this](auto& ev) { - ResetLayout(); - return true; - }); - dispatcher.Dispatch([this](auto& ev) { - m_Layout->SaveDefaultLayout(); - return true; - }); - dispatcher.Dispatch([this](auto& ev) { - std::filesystem::path scenePath = ev.GetPath(); - // If the path is relative, resolve it via Project::GetAssetPath - if (scenePath.is_relative() && Project::GetActive()) - { - scenePath = Project::GetAssetPath(ev.GetPath()); - } - - std::string finalPath = scenePath.string(); - - if (EditorContext::GetSceneState() == SceneState::Play) - { - // Handle mid-play scene change directly or via Manager - m_SceneManager->OpenScene(finalPath); - return true; - } - m_SceneManager->OpenScene(finalPath); - return true; - }); - - // 4. Selections/Picking - dispatcher.Dispatch([this](auto& ev) { - EditorContext::SetSelectedEntity(Entity(ev.GetEntity(), &ev.GetScene()->GetRegistry())); - EditorContext::GetState().LastHitMeshIndex = ev.GetMeshIndex(); - return false; - }); - - // 6. Raw Input Overrides - if (EditorContext::GetSceneState() == SceneState::Play) - { - // Script events are already dispatched on line 390 - } - else if (e.GetEventType() == EventType::KeyPressed) - { - auto& ke = (KeyPressedEvent&)e; - if (ke.GetKeyCode() == Key::Escape && EditorContext::GetState().FullscreenGame) - { - EditorContext::GetState().FullscreenGame = false; - e.Handled = true; - } - } -} - -CommandHistory& EditorLayer::GetCommandHistory() -{ - return s_Instance->m_CommandHistory; -} - -// File and project operations are now handled by EditorProjectManager. - -void EditorLayer::LaunchStandalone() -{ - CH_PROFILE_FUNCTION(); - EditorLauncher::LaunchStandalone(Project::GetActive(), GetActiveScene()); -} - -void EditorLayer::ReparentEntity(Entity child, Entity parent) -{ - if (child.HasComponent()) - { - child.GetComponent().Parent = parent; - } -} - -} // namespace CHEngine diff --git a/editor/editor_layer.h b/editor/editor_layer.h deleted file mode 100644 index f71e18dd5..000000000 --- a/editor/editor_layer.h +++ /dev/null @@ -1,148 +0,0 @@ -#ifndef CH_EDITOR_LAYER_H -#define CH_EDITOR_LAYER_H - -#include -#include -#include -#include -#include - - -#include "editor_context.h" -#include "launcher/editor_launcher.h" -#include "editor_project_manager.h" -#include "editor_scene_manager.h" -#include "engine/core/application.h" -#include "engine/core/base.h" -#include "engine/core/layer.h" -#include "editor_layout.h" -#include "editor_panels.h" -#include "engine/graphics/pipeline/renderer.h" -#include "engine/scene/scene.h" -#include "engine/scene/scene_events.h" -#include "imgui.h" -#include "undo/command_history.h" - -namespace CHEngine -{ - -struct EditorLayerConfig -{ - std::string LastProjectPath = ""; - std::string LastScenePath = ""; - bool LoadLastProjectOnStartup = true; - bool AutoSaveEnabled = true; - float AutoSaveInterval = 300.0f; - std::vector RecentProjects; // Ordered list of recently opened project paths -}; - -// Owns the editor scene pair, viewport state, and project/scene transition flow. -class EditorLayer : public Layer -{ -public: - EditorLayer(); - virtual ~EditorLayer(); - - virtual void OnAttach() override; - virtual void OnDetach() override; - virtual void OnUpdate(Timestep ts) override; - virtual void OnRender(Timestep ts) override; - virtual void OnImGuiRender() override; - virtual void OnEvent(Event& e) override; - - // Returns the viewport width currently tracked by the editor. - static float GetViewportWidth() - { - return s_Instance->m_ViewportSize.x; - } - // Returns the viewport height currently tracked by the editor. - static float GetViewportHeight() - { - return s_Instance->m_ViewportSize.y; - } - - // Resets the editor layout to the default dock structure. - void ResetLayout(); - - SceneState GetSceneState() const - { - return EditorContext::GetSceneState(); - } - - static EditorLayer& Get() - { - return *s_Instance; - } - // Draws the main editor docking root. - void DrawDockSpace(); - - // File and project operations (delegated to ProjectManager). - EditorProjectManager& GetProjectManager() { return *m_ProjectManager; } - - // Scene operations (delegated to SceneManager). - EditorSceneManager& GetSceneManager() { return *m_SceneManager; } - - void LaunchStandalone(); - -private: - void LoadEditorFonts(); - void DrawLoadingOverlay(const char* title, const char* status); - -public: - static EditorLayer* s_Instance; -public: - static CommandHistory& GetCommandHistory(); - static CommandHistory& History() - { - return GetCommandHistory(); - } - EditorPanels& GetPanels() - { - return *m_Panels; - } - - Entity GetSelectedEntity() const - { - return EditorContext::GetSelectedEntity(); - } - - static void ReparentEntity(Entity child, Entity parent); - - const ImVec2& GetViewportSize() const { return m_ViewportSize; } - void SetViewportSize(const ImVec2& size) { m_ViewportSize = size; } - void SetLastScenePath(const std::string& path) - { - m_Config.LastScenePath = path; - } - - // Loads the editor config from disk. - void LoadConfig(); - // Saves the editor config to disk. - void SaveConfig(); - const EditorLayerConfig& GetConfig() const - { - return m_Config; - } - EditorLayerConfig& GetConfig() - { - return m_Config; - } - - // Returns the scene currently being edited or played, or null while transitions are in flight. - std::shared_ptr GetActiveScene() const; - -private: - EditorLayerConfig m_Config; - -private: - std::unique_ptr m_Layout; - std::unique_ptr m_Panels; - std::unique_ptr m_ProjectManager; - std::unique_ptr m_SceneManager; - - CommandHistory m_CommandHistory; - ImVec2 m_ViewportSize = {1280, 720}; -}; -} // namespace CHEngine - -#endif // CH_EDITOR_LAYER_H diff --git a/editor/editor_layout.cpp b/editor/editor_layout.cpp deleted file mode 100644 index 8dcb20014..000000000 --- a/editor/editor_layout.cpp +++ /dev/null @@ -1,133 +0,0 @@ -#include "editor_layout.h" -#include "editor/editor_layer.h" -#include "editor/panels/panel.h" -#include "editor_gui.h" -#include "engine/core/application.h" -#include "engine/scene/project.h" -#include "imgui.h" -#include "imgui_internal.h" -#include -#include - -namespace CHEngine -{ - -void EditorLayout::BeginWorkspace() -{ - static bool dockspaceOpen = true; - static ImGuiDockNodeFlags dockspace_flags = ImGuiDockNodeFlags_PassthruCentralNode; - ImGuiWindowFlags window_flags = ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoDocking; - - ImGuiViewport* viewport = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos(viewport->Pos); - ImGui::SetNextWindowSize(viewport->Size); - ImGui::SetNextWindowViewport(viewport->ID); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - window_flags |= - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove; - window_flags |= ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus; - - if (dockspace_flags & ImGuiDockNodeFlags_PassthruCentralNode) - { - window_flags |= ImGuiWindowFlags_NoBackground; - } - - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); - ImGui::Begin("MainDockSpaceWindow", &dockspaceOpen, window_flags); - ImGui::PopStyleVar(); - ImGui::PopStyleVar(2); - - ImGuiIO& io = ImGui::GetIO(); - if (io.ConfigFlags & ImGuiConfigFlags_DockingEnable) - { - ImGuiID dockspace_id = ImGui::GetID("MyDockSpace"); - ImGui::DockSpace(dockspace_id, ImVec2(0.0f, 0.0f), dockspace_flags); - } -} - -void EditorLayout::EndWorkspace() -{ - ImGui::End(); -} - -void EditorLayout::DrawInterface() -{ - auto& layer = EditorLayer::Get(); - EditorGUI::DrawMenuBar(layer.GetPanels()); - - // Render all panels - bool readOnly = EditorContext::GetSceneState() == SceneState::Play; - layer.GetPanels().OnImGuiRender(readOnly); -} - -void EditorLayout::ResetLayout() -{ - // Try to load from default template first - std::string defaultPath = std::string(PROJECT_ROOT_DIR) + "/imgui_default.ini"; - if (std::filesystem::exists(defaultPath)) - { - CH_CORE_INFO("EditorLayout: Resetting from template: {}", defaultPath); - std::ifstream f(defaultPath); - if (f.is_open()) - { - std::string content((std::istreambuf_iterator(f)), std::istreambuf_iterator()); - ImGui::LoadIniSettingsFromMemory(content.c_str(), content.size()); - return; - } - } - - CH_CORE_WARN("EditorLayout: Template not found at {}, using fallback procedural layout", defaultPath); - - // Fallback procedural layout if no template exists - ImGuiID dockspace_id = ImGui::GetID("MyDockSpace"); - ImGui::DockBuilderRemoveNode(dockspace_id); - ImGui::DockBuilderAddNode(dockspace_id, ImGuiDockNodeFlags_DockSpace | ImGuiDockNodeFlags_PassthruCentralNode); - - ImGuiID main = dockspace_id; - ImGuiID right = ImGui::DockBuilderSplitNode(main, ImGuiDir_Right, 0.25f, nullptr, &main); - ImGuiID left = ImGui::DockBuilderSplitNode(main, ImGuiDir_Left, 0.20f, nullptr, &main); - ImGuiID down = ImGui::DockBuilderSplitNode(main, ImGuiDir_Down, 0.30f, nullptr, &main); - - // Ensure Viewport gets the central node - ImGui::DockBuilderDockWindow("Viewport", main); - - // Left side: Hierarchy and Project Browser (tabs) - ImGui::DockBuilderDockWindow("Scene Hierarchy", left); - ImGui::DockBuilderDockWindow("Content Browser", left); - - // Right side: Inspector and Settings - ImGui::DockBuilderDockWindow("Inspector", right); - ImGui::DockBuilderDockWindow("World Settings", right); - ImGui::DockBuilderDockWindow("Material Editor", right); - - // Bottom: Console, Profiler, Effects - ImGui::DockBuilderDockWindow("Console", down); - ImGui::DockBuilderDockWindow("Profiler", down); - ImGui::DockBuilderDockWindow("Effects & Debug", down); - - ImGui::DockBuilderFinish(dockspace_id); - CH_CORE_INFO("EditorLayout: Procedural layout applied."); -} - -void EditorLayout::SaveDefaultLayout() -{ - size_t size = 0; - const char* settings = ImGui::SaveIniSettingsToMemory(&size); - if (settings) - { - std::string defaultPath = std::string(PROJECT_ROOT_DIR) + "/imgui_default.ini"; - std::ofstream file(defaultPath); - if (file.is_open()) - { - file.write(settings, size); - CH_CORE_INFO("EditorLayout: Saved current layout as default: {}", defaultPath); - } - else - { - CH_CORE_ERROR("EditorLayout: Failed to open {} for writing!", defaultPath); - } - } -} - -} // namespace CHEngine diff --git a/editor/editor_layout.h b/editor/editor_layout.h deleted file mode 100644 index 0d0e4df77..000000000 --- a/editor/editor_layout.h +++ /dev/null @@ -1,27 +0,0 @@ -#ifndef CH_EDITOR_LAYOUT_H -#define CH_EDITOR_LAYOUT_H - -#include "editor/editor_panels.h" -#include "engine/core/events.h" -#include - -namespace CHEngine -{ - -class EditorLayout -{ -public: - void BeginWorkspace(); - void EndWorkspace(); - - void DrawInterface(); - void ResetLayout(); - void SaveDefaultLayout(); - -private: - void DrawProjectSelector(); -}; - -} // namespace CHEngine - -#endif // CH_EDITOR_LAYOUT_H diff --git a/editor/editor_main.cpp b/editor/editor_main.cpp deleted file mode 100644 index 54c4dd221..000000000 --- a/editor/editor_main.cpp +++ /dev/null @@ -1,23 +0,0 @@ -#include "editor_layer.h" -#include "engine/core/entry_point.h" -#include "engine/core/project_launcher.h" -#include "panels/console_panel.h" -#include "engine/core/log.h" -#include "scripting/scriptengine.h" - -namespace CHEngine -{ -Application* CreateApplication(ApplicationCommandLineArgs args) -{ - Log::SetLogCallback(ConsolePanel::AddLog); - - auto details = ProjectLauncher::PrepareEditor(args); - - details.Spec.InitScripting = []() { ScriptEngine::Init(); }; - details.Spec.ShutdownScripting = []() { ScriptEngine::Shutdown(); }; - - auto app = new Application(details.Spec); - app->PushLayer(new EditorLayer()); - return app; -} -} // namespace CHEngine diff --git a/editor/editor_menu.cpp b/editor/editor_menu.cpp new file mode 100644 index 000000000..b85b8a120 --- /dev/null +++ b/editor/editor_menu.cpp @@ -0,0 +1,930 @@ +#include "editor_menu.h" +#include "editor/editor_colors.h" +#include "editor/layer.h" +#include "editor/panels.h" +#include "editor/project/project_exporter.h" +#include "engine/app/application.h" +#include "engine/common/thread_pool.h" +#include "engine/core/service_locator.h" +#include "engine/platform/dialogs/dialogs.h" +#include "engine/project/project.h" +#include "events.h" +#include "thirdparty/IconsFontAwesome6.h" +#include "engine/scripting/scriptengine.h" +#include "editor/scene_manager.h" +#include "gui.h" +#include "imgui.h" +#include "imgui_internal.h" +#include "engine/assets/asset_manager.h" +#include "editor/font_choice_gui.h" + +#include + +namespace Chained +{ + constexpr float kPlaybackBarWidth = 330.0f; + + void EditorMenu::DrawMenuBar(EditorPanels& panels) + { + if (!ImGui::BeginMenuBar()) + { + return; + } + + DrawFileMenu(); + DrawViewMenu(panels); + DrawProjectMenu(); + DrawEditorMenu(); + DrawPlaybackControls(); + DrawExportResultPopup(); + DrawUnsavedChangesPopup(); + + ImGui::EndMenuBar(); + } + + void EditorMenu::DrawFileMenu() + { + if (ImGui::BeginMenu("File")) + { + if (ImGui::MenuItem(ICON_FA_FILE " New Project", "Ctrl+Shift+N")) + { + auto newScene = Scene::CreateDefault(); + EditorLayer::Get().GetSceneManager().SetScene(newScene); + } + if (ImGui::MenuItem(ICON_FA_FOLDER_OPEN " Open Project", "Ctrl+O")) + { + std::vector filters = {{"Chained Scene", "chscene"}}; + auto result = Chained::Dialogs::OpenFile(filters); + if (result) + { + EditorLayer::Get().GetSceneManager().OpenScene(*result); + } + } + if (ImGui::MenuItem(ICON_FA_FLOPPY_DISK " Save Project")) + { + EditorLayer::Get().GetSceneManager().SaveScene(); + } + if (ImGui::MenuItem(ICON_FA_XMARK " Close Project")) + { + Project::SetActive(nullptr); + } + ImGui::Separator(); + if (ImGui::MenuItem(ICON_FA_FILE_CODE " New Scene", "Ctrl+N")) + { + EditorLayer::Get().GetSceneManager().NewScene(); + } + if (ImGui::MenuItem(ICON_FA_FLOPPY_DISK " Save Scene", "Ctrl+S")) + { + EditorLayer::Get().GetSceneManager().SaveScene(); + } + if (ImGui::MenuItem(ICON_FA_FILE_EXPORT " Save Scene As...", "Ctrl+Shift+S")) + { + EditorLayer::Get().GetSceneManager().SaveSceneAs(); + } + if (ImGui::MenuItem(ICON_FA_FOLDER_OPEN " Load Scene", "Ctrl+L")) + { + EditorLayer::Get().GetSceneManager().OpenScene(); + } + ImGui::Separator(); + if (ImGui::MenuItem(ICON_FA_POWER_OFF " Exit")) + { + Application::Get().Close(); + } + ImGui::EndMenu(); + } + } + + void EditorMenu::DrawViewMenu(EditorPanels& panels) + { + if (ImGui::BeginMenu("View")) + { + panels.ForEach([](const std::shared_ptr& panel) { + if (panel->GetName() != "Project Browser") + { + ImGui::MenuItem(panel->GetName().c_str(), nullptr, &panel->IsOpen()); + } + }); + ImGui::Separator(); + if (ImGui::MenuItem(ICON_FA_EXPAND " Fullscreen", "F11")) + { + Application::Get().GetWindow().ToggleFullscreen(); + } + ImGui::EndMenu(); + } + } + + void EditorMenu::DrawProjectMenu() + { + if (ImGui::BeginMenu("Project")) + { + if (ImGui::MenuItem(ICON_FA_GEARS " Settings")) + { + if (auto p = EditorLayer::Get().GetPanels().Get("Project Settings")) + { + p->IsOpen() = true; + } + } + bool isExporting = false; + { + std::lock_guard lock(m_ExportState.Mutex); + isExporting = m_ExportState.IsExporting; + } + + if (ImGui::MenuItem(isExporting ? ICON_FA_FILE_EXPORT " Exporting..." + : ICON_FA_FILE_EXPORT " Export Project...")) + { + if (!isExporting) + { + m_ExportState.CancelRequested.store(false, std::memory_order_relaxed); + m_ExportDialog.Open = true; + auto project = Project::GetActive(); + if (project) + { + m_ExportDialog.SelectedMode = project->GetConfig().Export.Mode; + m_ExportDialog.ZipThreshold = project->GetConfig().Export.ZipThreshold; + m_ExportDialog.DataVersion = project->GetConfig().Export.DataVersion; + m_ExportDialog.SplitSizeMB = project->GetConfig().Export.SplitSizeMB; + uint32_t splitSize = m_ExportDialog.SplitSizeMB; + m_ExportDialog.SplitCustom = + (splitSize != 0 && splitSize != 512 && splitSize != 1024 && splitSize != 2048); + m_ExportDialog.PackName = project->GetConfig().Export.PackName; + } + } + } + ImGui::Separator(); + if (ImGui::MenuItem(ICON_FA_ARROWS_ROTATE " Reload Shaders")) + { + if (auto* renderer = ServiceLocator::TryGet()) + { + renderer->GetShaderLibrary().ReloadAll(); + } + } + if (ImGui::MenuItem(ICON_FA_ARROWS_ROTATE " Reload All Stale Assets")) + { + auto* am = ServiceLocator::TryGet(); + if (am) + { + size_t count = am->ReloadAllStale(); + CH_CORE_INFO("EditorMenu: Reloaded {} stale assets", count); + } + } + if (ImGui::MenuItem(ICON_FA_TRASH " Clear .chasset Cache")) + { + auto* am = ServiceLocator::TryGet(); + if (am) + { + size_t count = am->DeleteAllChassets(); + CH_CORE_INFO("EditorMenu: Deleted {} .chasset file(s)", count); + } + } + if (ImGui::MenuItem(ICON_FA_FILE_CODE " Reload Scripts", "Ctrl+R")) + { + auto project = Project::GetActive(); + if (project) + { + auto assemblyPath = ScriptEngine::ResolveAssemblyPath(project->GetConfig().Scripting, + project->GetConfig().ProjectDirectory); + if (auto* scriptEngine = ServiceLocator::TryGet()) + { + scriptEngine->RequestAssemblyReload(assemblyPath.string(), "EditorGUI"); + } + } + } + ImGui::EndMenu(); + } + } + + void EditorMenu::DrawEditorMenu() + { + if (ImGui::BeginMenu("Editor")) + { + if (ImGui::MenuItem(ICON_FA_SLIDERS " Settings")) + { + m_ShowEditorSettings = true; + } + ImGui::EndMenu(); + } + } + + void EditorMenu::DrawPlaybackControls() + { + float barWidth = ImGui::GetWindowWidth(); + float centerPos = (barWidth - kPlaybackBarWidth) * 0.5f; + if (centerPos > ImGui::GetCursorPosX()) + { + ImGui::SameLine(centerPos); + } + + SceneState sceneState = EditorLayer::Get().GetSceneManager().GetSceneState(); + bool isPlaying = (sceneState == SceneState::Play); + bool isSimulating = (sceneState == SceneState::Simulate); + + // Play / Stop Button + if (isPlaying) + { + ImGui::PushStyleColor(ImGuiCol_Text, EditorColors::PlayGreen); + } + if (ImGui::Button(isPlaying ? (ICON_FA_STOP " Stop") : (ICON_FA_PLAY " Play"), ImVec2(65, 20))) + { + EditorLayer::Get().GetSceneManager().SetSceneState(isPlaying ? SceneState::Edit : SceneState::Play); + } + if (isPlaying) + { + ImGui::PopStyleColor(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip(isPlaying ? "Stop Game" : "Play Game (Run Physics & Scripts)"); + } + + ImGui::SameLine(0, 5); + + // Simulate / Stop Button + if (isSimulating) + { + ImGui::PushStyleColor(ImGuiCol_Text, EditorColors::SimulateOrange); + } + if (ImGui::Button(isSimulating ? (ICON_FA_STOP " Stop") : (ICON_FA_GEARS " Simulate"), ImVec2(80, 20))) + { + EditorLayer::Get().GetSceneManager().SetSceneState(isSimulating ? SceneState::Edit : SceneState::Simulate); + } + if (isSimulating) + { + ImGui::PopStyleColor(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip(isSimulating ? "Stop Simulation" : "Simulate (Physics Only)"); + } + } + + void EditorMenu::DrawExportResultPopup() + { + bool shouldOpenExportPopup = false; + { + std::lock_guard lock(m_ExportState.Mutex); + if (m_ExportState.Open) + { + m_ExportResultSuccess = m_ExportState.Success; + m_ExportResultMessage = m_ExportState.Message; + m_ExportResultOutDir = m_ExportState.OutDir; + m_ExportState.Open = false; + shouldOpenExportPopup = true; + } + } + if (shouldOpenExportPopup) + { + ImGui::OpenPopup("Export Result"); + } + if (ImGui::BeginPopupModal("Export Result", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + if (m_ExportResultSuccess) + { + ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), ICON_FA_CIRCLE_INFO " Success"); + } + else + { + ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), ICON_FA_CIRCLE_EXCLAMATION " Failed"); + } + ImGui::Spacing(); + ImGui::TextWrapped("%s", m_ExportResultMessage.c_str()); + if (!m_ExportResultOutDir.empty()) + { + ImGui::Spacing(); + ImGui::Text("Output: "); + ImGui::SameLine(); + ImGui::TextDisabled("%s", m_ExportResultOutDir.c_str()); + } + ImGui::Spacing(); + ImGui::Separator(); + if (ImGui::Button("OK", ImVec2(120.f, 0.f)) || ImGui::IsKeyPressed(ImGuiKey_Escape) || + ImGui::IsKeyPressed(ImGuiKey_Enter)) + { + m_ExportState.CancelRequested.store(false, std::memory_order_relaxed); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + } + + void EditorMenu::DrawUnsavedChangesPopup() + { + auto& sceneMgr = EditorLayer::Get().GetSceneManager(); + if (sceneMgr.IsConfirmPending()) + { + ImGui::OpenPopup("Unsaved Changes"); + } + if (ImGui::BeginPopupModal("Unsaved Changes", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::Text("Scene has unsaved changes."); + ImGui::Spacing(); + ImGui::TextDisabled("Do you want to save before continuing?"); + ImGui::Spacing(); + ImGui::Separator(); + + if (ImGui::Button("Save", ImVec2(120.f, 0.f))) + { + sceneMgr.SaveScene(); + sceneMgr.ConfirmPendingAction(); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Don't Save", ImVec2(120.f, 0.f))) + { + sceneMgr.ConfirmPendingAction(); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120.f, 0.f))) + { + sceneMgr.CancelPendingAction(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + } + + void EditorMenu::DrawExportDialog() + { + if (!m_ExportDialog.Open) + { + return; + } + + ImGui::SetNextWindowSize(ImVec2(480, 0), ImGuiCond_Once); + if (ImGui::Begin("Export Project", &m_ExportDialog.Open)) + { + ImGui::TextUnformatted("Choose export mode:"); + ImGui::Spacing(); + + struct ModeInfo + { + PackMode mode; + const char* label; + const char* desc; + const char* icon; + }; + ModeInfo modes[] = { + {PackMode::Fast, "Fast", "LZ4 HC compression.\nFast export, larger pack.", ICON_FA_BOLT}, + {PackMode::Balanced, "Balanced", "ZSTD compression.\nBalanced speed and size.", ICON_FA_CUBES}, + {PackMode::Max, "Max", "ZSTD ultra compression.\nSmallest pack, slowest export.", ICON_FA_GEARS}, + {PackMode::Raw, "Raw", "No compression.\nStored as-is, fastest.", ICON_FA_FOLDER_OPEN}, + }; + constexpr size_t modeCount = 4; + + const float avail = ImGui::GetContentRegionAvail().x; + const float spacing = ImGui::GetStyle().ItemSpacing.x; + const float modeWidth = (avail - spacing * 3.0f) / 4.0f; + + for (size_t i = 0; i < modeCount; ++i) + { + const auto& m = modes[i]; + const bool selected = (m_ExportDialog.SelectedMode == m.mode); + ImGui::PushID(static_cast(m.mode)); + + ImGui::PushStyleColor(ImGuiCol_Button, + selected ? ImVec4(0.20f, 0.62f, 0.78f, 1.0f) : ImVec4(0.18f, 0.20f, 0.24f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, + selected ? ImVec4(0.28f, 0.72f, 0.88f, 1.0f) : ImVec4(0.22f, 0.24f, 0.29f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, + selected ? ImVec4(0.16f, 0.52f, 0.68f, 1.0f) : ImVec4(0.14f, 0.16f, 0.20f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(8.0f, 12.0f)); + + std::string btnLabel = std::string(m.icon) + " " + m.label; + if (ImGui::Button(btnLabel.c_str(), ImVec2(modeWidth, 0))) + { + m_ExportDialog.SelectedMode = m.mode; + } + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); + ImGui::PopID(); + + if (i + 1 < modeCount) + { + ImGui::SameLine(); + } + } + + // Description for selected mode + ImGui::Spacing(); + for (size_t i = 0; i < modeCount; ++i) + { + if (m_ExportDialog.SelectedMode == modes[i].mode) + { + ImGui::TextColored(ImVec4(0.6f, 0.75f, 0.85f, 1.0f), "%s", modes[i].desc); + break; + } + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(8.0f, 6.0f)); + if (m_ExportDialog.SelectedMode != PackMode::Raw) + { + ImGui::SliderFloat("Compression Threshold", &m_ExportDialog.ZipThreshold, 0.0f, 1.0f, "%.2f"); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Files with compression ratio above this threshold stay uncompressed.\n0.0 = " + "compress everything, 1.0 = compress nothing."); + } + } + + { + int dataVersion = static_cast(m_ExportDialog.DataVersion); + if (ImGui::InputInt("Data Version", &dataVersion)) + { + m_ExportDialog.DataVersion = static_cast(dataVersion); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Increment to invalidate cached packs at runtime."); + } + } + + if (m_ExportDialog.SelectedMode != PackMode::Raw) + { + // Reserve 128 chars for the pack name buffer + static char packNameBuf[128] = {}; + if (ImGui::IsWindowAppearing()) + { + std::strncpy(packNameBuf, m_ExportDialog.PackName.c_str(), sizeof(packNameBuf) - 1); + packNameBuf[sizeof(packNameBuf) - 1] = '\0'; + } + if (ImGui::InputText("Pack Name", packNameBuf, sizeof(packNameBuf))) + { + std::string newName = packNameBuf; + // Strip spaces and extension characters that would break the filename + newName.erase(std::remove_if(newName.begin(), newName.end(), + [](char c) { return c == '.' || c == '/' || c == '\\' || c == ':'; }), + newName.end()); + if (!newName.empty()) + { + m_ExportDialog.PackName = newName; + } + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Base name for the .pack file(s).\nResult: %s.pack, %s_1.pack, ...", + m_ExportDialog.PackName.c_str(), m_ExportDialog.PackName.c_str()); + } + } + + if (m_ExportDialog.SelectedMode != PackMode::Raw) + { + const char* splitLabels[] = {"Single File (No Split)", "512 MB per pack", "1 GB (1024 MB)", + "2 GB (2048 MB)", "Custom Size..."}; + + // Determine which preset slot is active, respecting the explicit custom flag + int currentSplitIdx = 0; + if (m_ExportDialog.SplitCustom) + { + currentSplitIdx = 4; + } + else if (m_ExportDialog.SplitSizeMB == 0) + { + currentSplitIdx = 0; + } + else if (m_ExportDialog.SplitSizeMB == 512) + { + currentSplitIdx = 1; + } + else if (m_ExportDialog.SplitSizeMB == 1024) + { + currentSplitIdx = 2; + } + else if (m_ExportDialog.SplitSizeMB == 2048) + { + currentSplitIdx = 3; + } + else + { + currentSplitIdx = 4; // unknown preset → treat as custom + } + + if (ImGui::BeginCombo("Split Pack Size", splitLabels[currentSplitIdx])) + { + if (ImGui::Selectable(splitLabels[0], currentSplitIdx == 0)) + { + m_ExportDialog.SplitSizeMB = 0; + m_ExportDialog.SplitCustom = false; + } + if (ImGui::Selectable(splitLabels[1], currentSplitIdx == 1)) + { + m_ExportDialog.SplitSizeMB = 512; + m_ExportDialog.SplitCustom = false; + } + if (ImGui::Selectable(splitLabels[2], currentSplitIdx == 2)) + { + m_ExportDialog.SplitSizeMB = 1024; + m_ExportDialog.SplitCustom = false; + } + if (ImGui::Selectable(splitLabels[3], currentSplitIdx == 3)) + { + m_ExportDialog.SplitSizeMB = 2048; + m_ExportDialog.SplitCustom = false; + } + if (ImGui::Selectable(splitLabels[4], currentSplitIdx == 4)) + { + m_ExportDialog.SplitCustom = true; + // Keep current SplitSizeMB value so the user sees a sensible default + if (m_ExportDialog.SplitSizeMB == 0) + { + m_ExportDialog.SplitSizeMB = 1024; + } + } + ImGui::EndCombo(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Split exported assets across multiple .pack files (%s.pack, %s_1.pack, etc.).", + m_ExportDialog.PackName.c_str(), m_ExportDialog.PackName.c_str()); + } + + // Show custom input only when "Custom Size..." is selected + if (m_ExportDialog.SplitCustom) + { + int customMB = static_cast(m_ExportDialog.SplitSizeMB); + if (ImGui::InputInt("Chunk Size (MB)", &customMB)) + { + m_ExportDialog.SplitSizeMB = static_cast(std::max(1, customMB)); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Maximum uncompressed size per chunk in MB.\nActual .pack file will be " + "smaller after compression."); + } + } + } + + ImGui::Spacing(); + ImGui::Checkbox("Force repack", &m_ExportDialog.ForceRepack); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Rebuild resources.pack even when it is already up to date.\nBy default the pack is " + "reused if no source file changed."); + } + ImGui::PopStyleVar(); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::TextDisabled("Output folder"); + if (ImGui::Button("Browse Output Folder...", ImVec2(-1, 0))) + { + auto outDir = Dialogs::PickFolder(); + if (outDir) + { + m_ExportDialog.OutputDir = outDir->string(); + + // Save export settings to project config + auto project = Project::GetActive(); + if (project) + { + project->GetConfig().Export.Mode = m_ExportDialog.SelectedMode; + project->GetConfig().Export.ZipThreshold = m_ExportDialog.ZipThreshold; + project->GetConfig().Export.DataVersion = m_ExportDialog.DataVersion; + project->GetConfig().Export.SplitSizeMB = m_ExportDialog.SplitSizeMB; + project->GetConfig().Export.PackName = m_ExportDialog.PackName; + } + + m_ExportDialog.Open = false; + + // Start the export + { + std::lock_guard lock(m_ExportState.Mutex); + m_ExportState.IsExporting = true; + m_ExportState.PackedFiles = 0; + m_ExportState.TotalFiles = 0; + m_ExportState.CurrentFile.clear(); + } + m_ExportState.CancelRequested.store(false, std::memory_order_relaxed); + + auto* threadPool = ServiceLocator::TryGet(); + if (!threadPool) + { + CH_CORE_ERROR("EditorMenu: ThreadPool not available, cannot export"); + std::lock_guard lock(m_ExportState.Mutex); + m_ExportState.IsExporting = false; + } + else + { + std::string outDirPath = m_ExportDialog.OutputDir; + bool forceRepack = m_ExportDialog.ForceRepack; + threadPool->QueueTask([outDirPath, forceRepack, this]() { + ExportProgressCallback progressCb = [this](uint64_t packed, uint64_t total, + const std::string& file) { + std::lock_guard lock(m_ExportState.Mutex); + m_ExportState.PackedFiles = packed; + m_ExportState.TotalFiles = total; + m_ExportState.CurrentFile = file; + }; + auto result = ProjectExporter::ExportTo(outDirPath, progressCb, + &m_ExportState.CancelRequested, forceRepack); + std::lock_guard lock(m_ExportState.Mutex); + m_ExportState.Success = result.Success; + m_ExportState.Message = result.Cancelled ? "Export cancelled." + : !result.Success ? ("Export failed: " + result.Error) + : result.PackSkipped + ? "Export complete! (pack reused — no asset changes)" + : "Export complete!"; + m_ExportState.OutDir = result.Cancelled ? "" : result.OutDir.string(); + m_ExportState.Open = true; + m_ExportState.IsExporting = false; + }); + } + } + } + + ImGui::End(); + } + } + + void EditorMenu::DrawExportProgressOverlay() + { + bool showProgress = false; + uint64_t packed = 0, total = 0; + std::string currentFile; + { + std::lock_guard lock(m_ExportState.Mutex); + showProgress = m_ExportState.IsExporting; + packed = m_ExportState.PackedFiles; + total = m_ExportState.TotalFiles; + currentFile = m_ExportState.CurrentFile; + } + + if (!showProgress) + { + return; + } + + // Position: bottom-right corner with a small margin. + ImGuiViewport* vp = ImGui::GetMainViewport(); + const float margin = 16.0f; + const float windowW = 400.0f; + ImVec2 winPos = + ImVec2(vp->WorkPos.x + vp->WorkSize.x - windowW - margin, vp->WorkPos.y + vp->WorkSize.y - margin); + ImGui::SetNextWindowPos(winPos, ImGuiCond_Always, ImVec2(0.0f, 1.0f)); + ImGui::SetNextWindowSize(ImVec2(windowW, 0.0f), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.92f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(14.0f, 12.0f)); + + if (ImGui::Begin("##ExportProgress", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize)) + { + // ── Title + ImGui::TextColored(ImVec4(0.55f, 0.85f, 1.0f, 1.0f), ICON_FA_FILE_EXPORT " Exporting Project"); + ImGui::Spacing(); + + // ── File counter + if (total > 0) + { + ImGui::Text("Packed %llu of %llu files", (unsigned long long)packed, (unsigned long long)total); + } + else + { + ImGui::TextDisabled("Preparing..."); + } + + ImGui::Spacing(); + + // ── Progress bar + float fraction = (total > 0) ? static_cast(packed) / static_cast(total) : 0.0f; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.25f, 0.65f, 1.0f, 1.0f)); + ImGui::ProgressBar(fraction, ImVec2(-1.0f, 8.0f), ""); + ImGui::PopStyleColor(); + + // ── Current file hint + if (!currentFile.empty()) + { + ImGui::Spacing(); + ImGui::TextDisabled("%s", currentFile.c_str()); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + // ── Cancel button + bool alreadyCancelling = m_ExportState.CancelRequested.load(std::memory_order_relaxed); + if (alreadyCancelling) + { + ImGui::BeginDisabled(); + } + + if (ImGui::Button(alreadyCancelling ? ICON_FA_BOLT " Cancelling..." : ICON_FA_BOLT " Cancel", + ImVec2(-1.0f, 0.0f))) + { + m_ExportState.CancelRequested.store(true, std::memory_order_relaxed); + } + + if (alreadyCancelling) + { + ImGui::EndDisabled(); + } + } + ImGui::End(); + ImGui::PopStyleVar(2); + } + + void EditorMenu::DrawEditorSettings() + { + + if (!m_ShowEditorSettings) + { + return; + } + + ImGui::SetNextWindowSize(ImVec2(700, 480), ImGuiCond_FirstUseEver); + auto& config = EditorLayer::Get().GetConfig(); + + if (ImGui::Begin(ICON_FA_SLIDERS " Editor Settings", &m_ShowEditorSettings)) + { + static int selectedCategory = 0; + const char* categories[] = {ICON_FA_PALETTE " Appearance", ICON_FA_CAMERA " Camera", + ICON_FA_VIDEO " Viewport", ICON_FA_IMAGE " Content Browser", + ICON_FA_FLOPPY_DISK " Auto-Save", ICON_FA_ROCKET " Startup", + ICON_FA_GEAR " General"}; + + float buttonRowHeight = ImGui::GetFrameHeightWithSpacing(); + + // --- Left sidebar --- + ImGui::BeginChild("EditorSettingsSidebar", ImVec2(180, -buttonRowHeight), ImGuiChildFlags_NavFlattened); + for (int i = 0; i < IM_ARRAYSIZE(categories); i++) + { + if (ImGui::Selectable(categories[i], selectedCategory == i)) + { + selectedCategory = i; + } + } + ImGui::EndChild(); + + ImGui::SameLine(); + + // --- Right content --- + ImGui::BeginChild("EditorSettingsContent", ImVec2(0, -buttonRowHeight), ImGuiChildFlags_NavFlattened); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4, 4)); + + if (selectedCategory == 0) // Appearance + { + ImGui::TextDisabled("Font"); + ImGui::Separator(); + ImGui::Spacing(); + + const auto& fontChoices = GetEditorFontChoices(); + int currentFont = -1; + for (int i = 0; i < (int)fontChoices.size(); i++) + { + if (config.FontPath == fontChoices[i].Path) + { + currentFont = i; + break; + } + } + const char* preview = currentFont >= 0 ? fontChoices[currentFont].Label.c_str() : "Custom"; + if (ImGui::BeginCombo("Editor Font", preview)) + { + for (int i = 0; i < (int)fontChoices.size(); i++) + { + bool sel = (currentFont == i); + if (ImGui::Selectable(fontChoices[i].Label.c_str(), sel)) + { + config.FontPath = fontChoices[i].Path; + } + if (sel) + { + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndCombo(); + } + + ImGui::DragFloat("Font Size", &config.FontSize, 0.25f, 8.0f, 48.0f, "%.0f px"); + + ImGui::Spacing(); + ImGui::Spacing(); + ImGui::TextDisabled("Viewport Icons"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::DragFloat("Icon Scale", &config.IconSizeScale, 0.005f, 0.01f, 1.0f, "%.3f"); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("How fast gizmo icons grow with camera distance."); + } + ImGui::DragFloat("Icon Min Size", &config.IconSizeMin, 0.05f, 0.1f, config.IconSizeMax, "%.2f"); + ImGui::DragFloat("Icon Max Size", &config.IconSizeMax, 0.05f, config.IconSizeMin, 40.0f, "%.2f"); + } + else if (selectedCategory == 1) // Camera + { + ImGui::TextDisabled("Editor Camera (Edit Mode)"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::SliderFloat("Move Speed", &config.CameraMoveSpeed, 0.1f, 100.0f, "%.1f"); + ImGui::SliderFloat("Boost Multiplier", &config.CameraBoostMultiplier, 1.0f, 10.0f, "%.1f"); + ImGui::SliderFloat("Rotation Speed", &config.CameraRotationSpeed, 0.1f, 5.0f, "%.1f"); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("How fast the camera rotates when holding right-click."); + } + ImGui::SliderFloat("Zoom Speed", &config.CameraZoomSpeedMultiplier, 0.1f, 5.0f, "%.1f"); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Multiplier for mouse wheel zoom speed."); + } + ImGui::DragFloat("FOV", &config.CameraFovDegrees, 0.5f, 20.0f, 120.0f, "%.1f deg"); + ImGui::DragFloat("Near Clip", &config.CameraNearClip, 0.01f, 0.001f, 10.0f, "%.3f"); + ImGui::DragFloat("Far Clip", &config.CameraFarClip, 100.0f, 100.0f, 100000.0f, "%.0f"); + ImGui::Checkbox("Disable Camera Zoom", &config.DisableCameraZoom); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Prevent the mouse wheel from zooming the editor camera."); + } + } + else if (selectedCategory == 2) // Viewport + { + ImGui::TextDisabled("Viewport"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::Checkbox("Show Editor Icons", &config.ShowEditorIcons); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Show camera, light, and spawn zone icons in the viewport."); + } + ImGui::DragFloat("Gizmo Scale", &config.GizmoScale, 0.05f, 0.5f, 3.0f, "%.2f"); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Scale of the transform gizmo in the viewport."); + } + } + else if (selectedCategory == 3) // Content Browser + { + ImGui::TextDisabled("Content Browser"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::DragFloat("Thumbnail Size", &config.DefaultThumbnailSize, 4.0f, 32.0f, 256.0f, "%.0f px"); + const char* sortNames[] = {"Name", "Date", "Size"}; + ImGui::Combo("Sort Order", &config.DefaultSortOrder, sortNames, 3); + ImGui::Checkbox("Show File Extensions", &config.ShowFileExtensions); + } + else if (selectedCategory == 4) // Auto-Save + { + ImGui::TextDisabled("Auto-Save"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::Checkbox("Enable Auto-Save", &config.AutoSaveEnabled); + ImGui::DragFloat("Interval (s)", &config.AutoSaveInterval, 1.0f, 10.0f, 3600.0f, "%.0f"); + } + else if (selectedCategory == 5) // Startup + { + ImGui::TextDisabled("Startup"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::Checkbox("Load Last Project on Startup", &config.LoadLastProjectOnStartup); + ImGui::Spacing(); + ImGui::TextDisabled("Last project:"); + ImGui::SameLine(); + ImGui::TextWrapped("%s", config.LastProjectPath.empty() ? "(none)" : config.LastProjectPath.c_str()); + } + else if (selectedCategory == 6) // General + { + ImGui::TextDisabled("General"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::Checkbox("Confirm on Scene Close", &config.ConfirmOnSceneClose); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Show a warning when closing/switching a scene with unsaved changes."); + } + ImGui::DragInt("Max Recent Projects", &config.MaxRecentProjects, 1, 1, 50); + } + + ImGui::PopStyleVar(); + ImGui::EndChild(); + + if (ImGui::Button(ICON_FA_FLOPPY_DISK " Save Settings", ImVec2(-1, 0))) + { + EditorLayer::Get().SaveConfig(); + EditorGUI::ApplyTheme(); + EditorLayer::Get().RequestEditorFontReload(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Save and apply all settings."); + } + } + ImGui::End(); + } + +} // namespace Chained diff --git a/editor/editor_menu.h b/editor/editor_menu.h new file mode 100644 index 000000000..70c959a02 --- /dev/null +++ b/editor/editor_menu.h @@ -0,0 +1,90 @@ +#ifndef CH_EDITOR_MENU_H +#define CH_EDITOR_MENU_H + +#include "engine/project/project.h" +#include +#include +#include +#include + +namespace Chained +{ + + class EditorPanels; + class EditorLayer; + + /// @brief Handles the top-level editor menu bar and associated overlays/settings. + class EditorMenu + { + public: + EditorMenu() = default; + ~EditorMenu() = default; + + /// @brief Draws the main menu bar. + /// @param panels The editor panels to potentially toggle via the menu. + void DrawMenuBar(EditorPanels& panels); + + /// @brief Draws the standalone Editor Settings window (if active). + void DrawEditorSettings(); + + /// @brief Draws the export progress overlay (if an export is running). + void DrawExportProgressOverlay(); + + /// @brief Draws the export settings dialog. + void DrawExportDialog(); + + private: + void DrawFileMenu(); + void DrawViewMenu(EditorPanels& panels); + void DrawProjectMenu(); + void DrawEditorMenu(); + void DrawPlaybackControls(); + void DrawExportResultPopup(); + void DrawUnsavedChangesPopup(); + + // State for the Export Project feature + struct ExportState + { + bool Open = false; + bool Success = false; + std::string Message; + std::string OutDir; + std::mutex Mutex; + bool IsExporting = false; + + // Progress tracking (updated from background thread under Mutex) + uint64_t PackedFiles = 0; + uint64_t TotalFiles = 0; + std::string CurrentFile; + + // Cancel flag (written by GUI, read by worker thread) + std::atomic CancelRequested{false}; + }; + + // Export dialog state + struct ExportDialogState + { + bool Open = false; + PackMode SelectedMode = PackMode::Balanced; + float ZipThreshold = 0.05f; + uint32_t DataVersion = 0; + uint32_t SplitSizeMB = 0; + bool SplitCustom = false; // true when "Custom Size..." is explicitly selected + std::string PackName = "resources"; + bool ForceRepack = false; + std::string OutputDir; + }; + + ExportState m_ExportState; + ExportDialogState m_ExportDialog; + bool m_ShowEditorSettings = false; + + // Export result popup state + bool m_ExportResultSuccess = false; + std::string m_ExportResultMessage; + std::string m_ExportResultOutDir; + }; + +} // namespace Chained + +#endif // CH_EDITOR_MENU_H diff --git a/editor/editor_panels.cpp b/editor/editor_panels.cpp deleted file mode 100644 index 01adf5f26..000000000 --- a/editor/editor_panels.cpp +++ /dev/null @@ -1,70 +0,0 @@ -#include "editor_panels.h" -#include "panels/console_panel.h" -#include "panels/content_browser_panel.h" -#include "panels/world_panel.h" -#include "panels/effects_panel.h" -#include "panels/material_panel.h" -#include "panels/inspector_panel.h" -#include "panels/panel.h" -#include "panels/profiler_panel.h" -#include "panels/project_browser_panel.h" -#include "panels/project_settings_panel.h" -#include "panels/scene_hierarchy_panel.h" -#include "panels/viewport_panel.h" - -namespace CHEngine -{ - -void EditorPanels::Init() -{ - Register(); - Register(); - Register(); - Register(); - Register(); - Register(); - Register(); - Register(); - Register(); - Register(); - Register(); -} - -void EditorPanels::OnUpdate(Timestep ts) -{ - for (auto& panel : m_Panels) - { - panel->OnUpdate(ts); - } -} - -void EditorPanels::OnImGuiRender(bool readOnly) -{ - for (auto& panel : m_Panels) - { - if (panel->GetName() == "Project Browser") - { - continue; - } - - panel->OnImGuiRender(readOnly); - } -} - -void EditorPanels::OnEvent(Event& e) -{ - for (auto& panel : m_Panels) - { - panel->OnEvent(e); - } -} - -void EditorPanels::SetContext(const std::shared_ptr& context) -{ - for (auto& panel : m_Panels) - { - panel->SetContext(context); - } -} - -} // namespace CHEngine diff --git a/editor/editor_panels.h b/editor/editor_panels.h deleted file mode 100644 index 426c48f92..000000000 --- a/editor/editor_panels.h +++ /dev/null @@ -1,79 +0,0 @@ -#ifndef CH_EDITOR_PANELS_H -#define CH_EDITOR_PANELS_H - -#include "engine/core/timestep.h" -#include "panels/panel.h" -#include -#include -#include - -namespace CHEngine -{ - -class EditorPanels -{ -public: - EditorPanels() = default; - ~EditorPanels() = default; - -public: - void Init(); - -public: - template std::shared_ptr Register(Args&&... args) - { - auto panel = std::make_shared(std::forward(args)...); - m_Panels.push_back(panel); - return panel; - } - - template std::shared_ptr Get() - { - for (auto& panel : m_Panels) - { - if (auto p = std::dynamic_pointer_cast(panel)) - { - return p; - } - } - return nullptr; - } - - std::shared_ptr Get(const std::string& name) - { - for (auto& panel : m_Panels) - { - if (panel->GetName() == name) - { - return panel; - } - } - return nullptr; - } - - template void ForEach(F&& func) - { - for (auto& panel : m_Panels) - { - func(panel); - } - } - -public: - void OnUpdate(Timestep ts); - void OnImGuiRender(bool readOnly); - void OnEvent(Event& e); - void SetContext(const std::shared_ptr& context); - - std::vector>& GetPanels() - { - return m_Panels; - } - -private: - std::vector> m_Panels; -}; - -} // namespace CHEngine - -#endif // CH_EDITOR_PANELS_H diff --git a/editor/editor_project_manager.cpp b/editor/editor_project_manager.cpp deleted file mode 100644 index 380a8a247..000000000 --- a/editor/editor_project_manager.cpp +++ /dev/null @@ -1,146 +0,0 @@ -#include "editor_project_manager.h" -#include "editor_layer.h" -#include "engine/scene/project.h" -#include "engine/scene/project_serializer.h" -#include "engine/graphics/pipeline/renderer.h" -#include "engine/graphics/pipeline/ui_renderer.h" -#include "engine/core/application.h" -#include "engine/scene/scene_events.h" -#include "engine/platform/utils/dialogs.h" -#include "scripting/scriptengine.h" -#include - -namespace CHEngine -{ - -EditorProjectManager::EditorProjectManager() -{ -} - -void EditorProjectManager::NewProject() -{ - // Simple default: close active project to show Project Browser - Project::SetActive(nullptr); -} - -void EditorProjectManager::NewProject(const std::string& name, const std::string& path) -{ - Project::New(); - auto project = Project::GetActive(); - project->GetConfig().Name = name; - project->GetConfig().ProjectDirectory = path; - - ProjectSerializer serializer(project); - serializer.Serialize((std::filesystem::path(path) / (name + ".chproject")).string()); - - // Load engine shaders and resources for the dynamic newly created project - Renderer::LoadEngineResources(); - UIRenderer::Get().LoadProjectFonts(); -} - -void EditorProjectManager::OpenProject() -{ - std::vector filters = {{"Chained Project", "chproject"}}; - auto result = Dialogs::OpenFile(filters); - if (result) - { - OpenProject(*result); - } -} - -void EditorProjectManager::OpenProject(const std::filesystem::path& path) -{ - if (Project::Load(path)) - { - m_LastProjectPath = path.string(); - - // Load engine shaders and resources - Renderer::LoadEngineResources(); - UIRenderer::Get().LoadProjectFonts(); - - ProjectOpenedEvent e(path.string()); - Application::Get().OnEvent(e); - } -} - -void EditorProjectManager::SaveProject() -{ - auto project = Project::GetActive(); - if (!project) return; - - ProjectSerializer serializer(project); - serializer.Serialize((project->GetConfig().ProjectDirectory / (project->GetConfig().Name + ".chproject")).string()); -} - -bool EditorProjectManager::OnProjectOpened(ProjectOpenedEvent& e) -{ - auto project = Project::GetActive(); - if (project) - { - m_LastProjectPath = e.GetPath(); - - // Track in recent projects list (move to front, cap at 10) - auto& config = EditorLayer::Get().GetConfig(); - auto& recents = config.RecentProjects; - recents.erase(std::remove(recents.begin(), recents.end(), m_LastProjectPath), recents.end()); - recents.insert(recents.begin(), m_LastProjectPath); - if (recents.size() > 10) - recents.resize(10); - - EditorLayer::Get().SaveConfig(); - - // Auto-load script assembly if configured - auto& scripting = project->GetConfig().Scripting; - if (scripting.AutoLoad && !scripting.ModuleName.empty()) - { - std::string dllName = scripting.ModuleName; - if (dllName.find(".dll") == std::string::npos) - dllName += ".dll"; - - std::filesystem::path dllPath = scripting.ModuleDirectory / dllName; - if (dllPath.is_relative()) - dllPath = project->GetConfig().ProjectDirectory / dllPath; - - if (std::filesystem::exists(dllPath)) - { - ScriptEngine::Get().LoadAppAssembly(dllPath.string()); - CH_CORE_INFO("EditorProjectManager: Auto-loaded script assembly '{}'.", dllPath.string()); - } - else - { - CH_CORE_WARN("EditorProjectManager: Script assembly not found at '{}'. Build the C# project first.", - dllPath.string()); - } - } - - // Auto-load scene if available - std::filesystem::path sceneToLoad; - - // 1. Try loading ActiveScene - if (!project->GetConfig().ActiveScenePath.empty()) - { - sceneToLoad = project->GetConfig().ProjectDirectory / project->GetConfig().ActiveScenePath; - } - - // 2. Fallback to StartScene - if (sceneToLoad.empty() || !std::filesystem::exists(sceneToLoad)) - { - if (!project->GetConfig().StartScene.empty()) - { - sceneToLoad = project->GetConfig().ProjectDirectory / project->GetConfig().AssetDirectory / - project->GetConfig().StartScene; - } - } - - // 3. Load the scene if found - if (!sceneToLoad.empty() && std::filesystem::exists(sceneToLoad)) - { - CH_CORE_INFO("EditorProjectManager: Auto-loading scene: {}", sceneToLoad.string()); - EditorLayer::Get().GetSceneManager().OpenScene(sceneToLoad); - } - return true; - } - return false; -} - -} // namespace CHEngine diff --git a/editor/editor_project_manager.h b/editor/editor_project_manager.h deleted file mode 100644 index d2c45e116..000000000 --- a/editor/editor_project_manager.h +++ /dev/null @@ -1,38 +0,0 @@ -#ifndef CH_EDITOR_PROJECT_MANAGER_H -#define CH_EDITOR_PROJECT_MANAGER_H - -#include "engine/scene/scene.h" -#include "engine/scene/scene_events.h" -#include "editor_context.h" -#include "engine/core/base.h" -#include "editor_events.h" -#include -#include - -namespace CHEngine -{ - -class EditorProjectManager -{ -public: - EditorProjectManager(); - ~EditorProjectManager() = default; - - void NewProject(); - void NewProject(const std::string& name, const std::string& path); - void OpenProject(); - void OpenProject(const std::filesystem::path& path); - void SaveProject(); - - bool OnProjectOpened(ProjectOpenedEvent& e); - - const std::string& GetLastProjectPath() const { return m_LastProjectPath; } - void SetLastProjectPath(const std::string& path) { m_LastProjectPath = path; } - -private: - std::string m_LastProjectPath; -}; - -} // namespace CHEngine - -#endif // CH_EDITOR_PROJECT_MANAGER_H diff --git a/editor/editor_scene_manager.cpp b/editor/editor_scene_manager.cpp deleted file mode 100644 index 0b4a8de04..000000000 --- a/editor/editor_scene_manager.cpp +++ /dev/null @@ -1,471 +0,0 @@ -#include "editor_scene_manager.h" -#include "editor_layer.h" -#include "engine/scene/project.h" -#include "engine/scene/scene_serializer.h" -#include "engine/core/thread_pool.h" -#include "engine/core/application.h" -#include "engine/core/input.h" -#include "engine/core/key_codes.h" -#include "engine/core/assets/asset_manager.h" -#include "engine/platform/utils/dialogs.h" -#include "scripting/scene_scripting.h" -#include "engine/scene/scene_events.h" - -namespace CHEngine -{ - -EditorSceneManager::EditorSceneManager() -{ -} - -void EditorSceneManager::NewScene() -{ - SetScene(Scene::CreateDefault()); -} - -void EditorSceneManager::OpenScene() -{ - std::vector filters = {{"Chained Scene", "chscene"}}; - auto result = Dialogs::OpenFile(filters); - if (result) - { - OpenScene(*result); - } -} - -void EditorSceneManager::OpenScene(const std::filesystem::path& path) -{ - m_IsPlayModeSceneLoad = (EditorContext::GetSceneState() == SceneState::Play); - StartSceneOpenTransition(path); -} - -void EditorSceneManager::SaveScene() -{ - auto scene = GetActiveScene(); - if (!scene) return; - - if (scene->GetSettings().ScenePath.empty()) - { - SaveSceneAs(); - return; - } - - SceneSerializer serializer(scene.get()); - serializer.Serialize(scene->GetSettings().ScenePath); - CH_INFO("Scene saved to {0}", scene->GetSettings().ScenePath); -} - -void EditorSceneManager::SaveSceneAs() -{ - std::vector filters = {{"Chained Scene", "chscene"}}; - auto result = Dialogs::SaveFile(filters); - if (result) - { - auto scene = GetActiveScene(); - if (!scene) return; - - scene->GetSettings().ScenePath = result->string(); - SceneSerializer serializer(scene.get()); - serializer.Serialize(result->string()); - } -} - -void EditorSceneManager::AutoSave(float interval, float ts) -{ - auto scene = GetActiveScene(); - if (!scene || scene->GetSettings().ScenePath.empty()) - { - return; - } - - m_AutoSaveTimer += ts; - if (m_AutoSaveTimer < interval) - return; - - m_AutoSaveTimer = 0.0f; - SceneSerializer serializer(scene.get()); - serializer.Serialize(scene->GetSettings().ScenePath); - CH_TRACE("Scene auto-saved to {0}", scene->GetSettings().ScenePath); -} - -void EditorSceneManager::SetScene(std::shared_ptr scene) -{ - CancelPlayModeTransition(); - CancelSceneOpenTransition(); - m_EditorScene = scene; - EditorContext::SetSelectedEntity({}); -} - -void EditorSceneManager::SetSceneState(SceneState state) -{ - if (state == SceneState::Play) - { - if (m_PlayModeStartRequested || m_IsPlayModeLoading || m_IsSceneOpenLoading || - EditorContext::GetSceneState() == SceneState::Play) - { - return; - } - - if (!m_EditorScene) - { - CH_CORE_WARN("EditorSceneManager::SetSceneState - No editor scene available for play mode."); - return; - } - - m_PlayModeStartRequested = true; - CH_CORE_INFO("Editor: Play mode requested."); - } - else - { - if (m_PlayModeStartRequested || m_IsPlayModeLoading) - { - CancelPlayModeTransition(); - } - - if (m_IsSceneOpenLoading) - { - CancelSceneOpenTransition(); - } - - if (EditorContext::GetSceneState() == SceneState::Edit) - { - return; - } - - CH_CORE_INFO("Editor: Play Mode Stopped"); - if (m_RuntimeScene) - { - CH_CORE_INFO("Editor: Cleaning up runtime scene..."); - SceneScripting::OnRuntimeStop(m_RuntimeScene.get()); - SceneScripting::Stop(m_RuntimeScene.get()); - m_RuntimeScene->OnRuntimeStop(); - m_RuntimeScene.reset(); - } - - EditorContext::SetSceneState(SceneState::Edit); - } -} - -std::shared_ptr EditorSceneManager::GetActiveScene() const -{ - return (EditorContext::GetSceneState() == SceneState::Play) ? m_RuntimeScene : m_EditorScene; -} - -void EditorSceneManager::OnUpdate(Timestep ts) -{ - if (m_PlayModeStartRequested) - { - StartPlayModeTransition(); - } - - if (m_IsPlayModeLoading) - { - UpdatePlayModeTransition(); - } - - if (m_IsSceneOpenLoading) - { - UpdateSceneOpenTransition(); - } - - EditorContext::GetState().IsLoading = IsLoading(); - EditorContext::GetState().LoadingStatus = m_LoadingStatus; -} - -void EditorSceneManager::OnViewportResize(uint32_t width, uint32_t height) -{ - if (m_EditorScene) - { - m_EditorScene->OnViewportResize(width, height); - } - - if (m_RuntimeScene) - { - m_RuntimeScene->OnViewportResize(width, height); - } -} - -void EditorSceneManager::StartSceneOpenTransition(const std::filesystem::path& path) -{ - if (path.empty()) return; - if (m_IsSceneOpenLoading) return; - - CancelPlayModeTransition(); - - std::filesystem::path scenePath = path; - if (scenePath.is_relative() && Project::GetActive()) - { - scenePath = Project::GetAssetPath(scenePath); - } - - m_PendingSceneOpenPath = scenePath; - m_SceneOpenSceneReady = false; - m_LoadingStatus = "Loading scene..."; - - try - { - m_SceneOpenFuture = ThreadPool::Get().Enqueue([scenePath]() { - auto newScene = std::make_shared(); - SceneSerializer serializer(newScene.get()); - if (!serializer.Deserialize(scenePath.string())) - { - return std::shared_ptr{}; - } - return newScene; - }); - - m_IsSceneOpenLoading = true; - CH_CORE_INFO("Editor: Loading scene '{}' on a worker thread.", scenePath.string()); - } catch (const std::exception& e) - { - CH_CORE_ERROR("Editor: Failed to start scene load: {}", e.what()); - CancelSceneOpenTransition(); - } -} - -void EditorSceneManager::UpdateSceneOpenTransition() -{ - if (!m_IsSceneOpenLoading) return; - - if (!m_SceneOpenSceneReady) - { - if (m_SceneOpenFuture.valid() && - m_SceneOpenFuture.wait_for(std::chrono::seconds(0)) == std::future_status::ready) - { - try - { - if (m_IsPlayModeSceneLoad) - { - if (m_RuntimeScene) - { - CH_CORE_INFO("Editor: Stopping current runtime scene to load '{}'.", m_PendingSceneOpenPath.string()); - SceneScripting::OnRuntimeStop(m_RuntimeScene.get()); - SceneScripting::Stop(m_RuntimeScene.get()); - m_RuntimeScene->OnRuntimeStop(); - } - - m_RuntimeScene = m_SceneOpenFuture.get(); - if (!m_RuntimeScene) - { - CH_CORE_ERROR("Editor: Runtime Scene load returned null for '{}'.", m_PendingSceneOpenPath.string()); - CancelSceneOpenTransition(); - return; - } - } - else - { - m_EditorScene = m_SceneOpenFuture.get(); - if (!m_EditorScene) - { - CH_CORE_ERROR("Editor: Scene load returned null for '{}'.", m_PendingSceneOpenPath.string()); - CancelSceneOpenTransition(); - return; - } - } - m_SceneOpenSceneReady = true; - } catch (const std::exception& e) - { - CH_CORE_ERROR("Editor: Scene load failed with exception: {}", e.what()); - CancelSceneOpenTransition(); - return; - } catch (...) - { - CH_CORE_ERROR("Editor: Scene load failed with unknown exception."); - CancelSceneOpenTransition(); - return; - } - } - } - - if (m_SceneOpenSceneReady && (m_EditorScene || m_RuntimeScene) && !AssetManager::Get().HasBackgroundWork()) - { - auto targetScene = m_IsPlayModeSceneLoad ? m_RuntimeScene : m_EditorScene; - - if (Project::GetActive() && Project::GetActive()->GetEnvironment()) - { - // Only override if the loaded scene has no environment defined - bool hasEnvironment = targetScene->GetSettings().Environment && - (!targetScene->GetSettings().Environment->GetPath().empty() || - (!targetScene->GetSettings().Environment->GetSettings().Skybox.TexturePath.empty())); - - if (!hasEnvironment) - { - CH_CORE_INFO("Editor: Applying project environment to scene '{}'.", targetScene->GetSettings().ScenePath); - targetScene->GetSettings().Environment = Project::GetActive()->GetEnvironment(); - } - } - - targetScene->GetSettings().ScenePath = m_PendingSceneOpenPath.string(); - - if (m_IsPlayModeSceneLoad) - { - CH_CORE_INFO("Editor: Activating new runtime scene '{}'.", m_PendingSceneOpenPath.string()); - SceneScripting::OnRuntimeStart(m_RuntimeScene.get()); - m_RuntimeScene->OnRuntimeStart(); - } - else - { - CH_CORE_INFO("Editor: Activating new editor scene '{}'.", m_PendingSceneOpenPath.string()); - SceneOpenedEvent e(m_PendingSceneOpenPath.string()); - EditorLayer::Get().SetLastScenePath(m_PendingSceneOpenPath.string()); - Application::Get().OnEvent(e); - } - - m_IsSceneOpenLoading = false; - m_SceneOpenSceneReady = false; - m_SceneOpenFuture = {}; - - EditorContext::SetSelectedEntity({}); - - m_PendingSceneOpenPath.clear(); - m_LoadingStatus = ""; - m_IsPlayModeSceneLoad = false; - CH_CORE_INFO("Editor: Scene transition complete."); - } -} - -void EditorSceneManager::CancelSceneOpenTransition() -{ - m_IsSceneOpenLoading = false; - m_SceneOpenSceneReady = false; - m_SceneOpenFuture = {}; - m_PendingSceneOpenPath.clear(); - m_LoadingStatus = ""; -} - -void EditorSceneManager::StartPlayModeTransition() -{ - if (!m_PlayModeStartRequested || m_IsPlayModeLoading || !m_EditorScene) - { - m_PlayModeStartRequested = false; - return; - } - - m_PlayModeStartRequested = false; - m_PlayModeSceneReady = false; - m_RuntimeScene.reset(); - m_LoadingStatus = "Preparing Play Mode..."; - - auto editorScene = m_EditorScene; - try { - m_PlayModeCopyFuture = ThreadPool::Get().Enqueue([editorScene]() { return Scene::Copy(editorScene); }); - m_IsPlayModeLoading = true; - CH_CORE_INFO("Editor: Copying scene for play mode on a worker thread."); - } catch (...) { - CancelPlayModeTransition(); - } -} - -void EditorSceneManager::UpdatePlayModeTransition() -{ - if (!m_IsPlayModeLoading) return; - - if (!m_PlayModeSceneReady) - { - if (m_PlayModeCopyFuture.valid() && - m_PlayModeCopyFuture.wait_for(std::chrono::seconds(0)) == std::future_status::ready) - { - try { - m_RuntimeScene = m_PlayModeCopyFuture.get(); - if (!m_RuntimeScene) { - CancelPlayModeTransition(); - return; - } - m_PlayModeSceneReady = true; - } catch (...) { - CancelPlayModeTransition(); - return; - } - } - } - - if (m_PlayModeSceneReady && m_RuntimeScene && !AssetManager::Get().HasBackgroundWork()) - { - EditorContext::SetSceneState(SceneState::Play); - SceneScripting::OnRuntimeStart(m_RuntimeScene.get()); - m_RuntimeScene->OnRuntimeStart(); - - m_IsPlayModeLoading = false; - m_PlayModeSceneReady = false; - m_PlayModeCopyFuture = {}; - m_LoadingStatus = ""; - CH_CORE_INFO("Editor: Play Mode Started"); - } -} - -void EditorSceneManager::CancelPlayModeTransition() -{ - m_PlayModeStartRequested = false; - m_IsPlayModeLoading = false; - m_PlayModeSceneReady = false; - m_PlayModeCopyFuture = {}; - m_RuntimeScene.reset(); - m_LoadingStatus = ""; -} - -bool EditorSceneManager::OnSceneOpened(SceneOpenedEvent& e) -{ - // Sync project path - auto project = Project::GetActive(); - if (project && !e.GetPath().empty()) - { - project->SetActiveScenePath(std::filesystem::relative(e.GetPath(), project->GetProjectDirectory())); - EditorLayer::Get().GetProjectManager().SaveProject(); - - EditorLayer::Get().GetConfig().LastScenePath = e.GetPath(); - EditorLayer::Get().SaveConfig(); - return true; - } - return false; -} - -bool EditorSceneManager::OnKeyPressed(KeyPressedEvent& e) -{ - if (e.IsRepeat()) - { - return false; - } - - bool ctrl = Input::IsKeyDown(Key::LeftControl) || Input::IsKeyDown(Key::RightControl); - bool shift = Input::IsKeyDown(Key::LeftShift) || Input::IsKeyDown(Key::RightShift); - - auto keyCode = e.GetKeyCode(); - - if (ctrl) - { - switch (keyCode) - { - case Key::N: - NewScene(); - return true; - case Key::O: - OpenScene(); - return true; - case Key::S: - if (shift) - { - SaveSceneAs(); - } - else - { - SaveScene(); - } - return true; - case Key::Z: - EditorLayer::Get().GetCommandHistory().Undo(); - return true; - case Key::Y: - EditorLayer::Get().GetCommandHistory().Redo(); - return true; - } - } - - if (keyCode == Key::F5) - { - EditorLayer::Get().LaunchStandalone(); - return true; - } - - return false; -} -} // namespace CHEngine diff --git a/editor/editor_scene_manager.h b/editor/editor_scene_manager.h deleted file mode 100644 index e972bd919..000000000 --- a/editor/editor_scene_manager.h +++ /dev/null @@ -1,78 +0,0 @@ -#ifndef CH_EDITOR_SCENE_MANAGER_H -#define CH_EDITOR_SCENE_MANAGER_H - -#include "editor_context.h" -#include "engine/scene/scene.h" -#include "engine/scene/scene_events.h" -#include -#include -#include - -namespace CHEngine -{ - -class EditorSceneManager -{ -public: - EditorSceneManager(); - ~EditorSceneManager() = default; - - void NewScene(); - void OpenScene(); - void OpenScene(const std::filesystem::path& path); - void SaveScene(); - void SaveSceneAs(); - void AutoSave(float interval, float ts); - - void SetScene(std::shared_ptr scene); - void SetSceneState(SceneState state); - std::shared_ptr GetActiveScene() const; - - void OnUpdate(Timestep ts); - void OnViewportResize(uint32_t width, uint32_t height); - - bool OnSceneOpened(SceneOpenedEvent& e); - bool OnKeyPressed(KeyPressedEvent& e); - - bool IsLoading() const - { - return m_IsPlayModeLoading || m_IsSceneOpenLoading; - } - const std::string& GetLoadingStatus() const - { - return m_LoadingStatus; - } - -private: - void StartSceneOpenTransition(const std::filesystem::path& path); - void UpdateSceneOpenTransition(); - void CancelSceneOpenTransition(); - void StartPlayModeTransition(); - void UpdatePlayModeTransition(); - void CancelPlayModeTransition(); - -private: - std::shared_ptr m_EditorScene; - std::shared_ptr m_RuntimeScene; - - // Async state - std::future> m_PlayModeCopyFuture; - std::future> m_SceneOpenFuture; - std::filesystem::path m_PendingSceneOpenPath; - - bool m_IsPlayModeLoading = false; - bool m_IsSceneOpenLoading = false; - bool m_PlayModeSceneReady = false; - bool m_SceneOpenSceneReady = false; - bool m_PlayModeStartRequested = false; - bool m_IsPlayModeSceneLoad = false; - - std::string m_LoadingStatus = ""; - - float m_AutoSaveTimer = 0.0f; - float m_LastAutoSaveTime = 0.0f; -}; - -} // namespace CHEngine - -#endif // CH_EDITOR_SCENE_MANAGER_H diff --git a/editor/events.cpp b/editor/events.cpp new file mode 100644 index 000000000..dc917f9dc --- /dev/null +++ b/editor/events.cpp @@ -0,0 +1,20 @@ +#include "events.h" +#include "engine/app/application.h" +#include "engine/scene/scene_events.h" + +namespace Chained +{ + + void SelectEntity(Entity entity, Scene* scene) + { + EntitySelectedEvent e((entt::entity)entity, scene); + Application::Get().OnEvent(e); + } + + void DeselectEntity(Scene* scene) + { + EntitySelectedEvent e(entt::null, scene); + Application::Get().OnEvent(e); + } + +} // namespace Chained diff --git a/editor/events.h b/editor/events.h new file mode 100644 index 000000000..8fce080d1 --- /dev/null +++ b/editor/events.h @@ -0,0 +1,55 @@ +#ifndef CH_EDITOR_EVENTS_H +#define CH_EDITOR_EVENTS_H + +#include "engine/core/events/events.h" +#include "engine/scene/entity.h" + +namespace Chained +{ + + // Forward declarations to avoid heavy includes in header. + class Scene; + + void SelectEntity(Entity entity, Scene* scene); + void DeselectEntity(Scene* scene); + + // Event to trigger an layout reset. + class AppResetLayoutEvent : public Event + { + public: + AppResetLayoutEvent() = default; + EVENT_CLASS_TYPE(AppResetLayout) + EVENT_CLASS_CATEGORY(EventCategoryApplication) + }; + + // Event to trigger launching the game in runtime mode. + class AppLaunchRuntimeEvent : public Event + { + public: + AppLaunchRuntimeEvent() = default; + EVENT_CLASS_TYPE(AppLaunchRuntime) + EVENT_CLASS_CATEGORY(EventCategoryApplication) + }; + + // Event to signal focusing on a specific entity in the viewport + class ViewportFocusEntityEvent : public Event + { + public: + ViewportFocusEntityEvent(Entity entity) + : m_Entity(entity) + { + } + Entity GetEntity() const + { + return m_Entity; + } + + EVENT_CLASS_TYPE(ViewportFocusEntity) + EVENT_CLASS_CATEGORY(EventCategoryApplication) + private: + Entity m_Entity; + }; + +} // namespace Chained + +#endif // CH_EDITOR_EVENTS_H diff --git a/editor/font_choice_gui.cpp b/editor/font_choice_gui.cpp new file mode 100644 index 000000000..c3d2e4d42 --- /dev/null +++ b/editor/font_choice_gui.cpp @@ -0,0 +1,97 @@ +#include "font_choice_gui.h" +#include "engine/core/service_locator.h" +#include "engine/assets/asset_manager.h" + +namespace Chained +{ + const std::vector& GetEditorFontChoices() + { + static std::vector s_Choices; + static bool s_Scanned = false; + if (s_Scanned) + { + return s_Choices; + } + s_Scanned = true; + + auto* assetManager = ServiceLocator::TryGet(); + if (!assetManager) + { + CH_CORE_WARN("EditorGUI: AssetManager not available; font picker will be empty."); + return s_Choices; + } + auto engineRoot = assetManager->GetEngineRoot(); + const std::filesystem::path fontDir = engineRoot / "resources" / "font"; + + std::error_code ec; + if (!std::filesystem::exists(fontDir, ec) || ec) + { + CH_CORE_WARN("EditorGUI: Font directory '{}' not found; font picker will be empty.", fontDir.string()); + return s_Choices; + } + + for (std::filesystem::recursive_directory_iterator + it(fontDir, std::filesystem::directory_options::skip_permission_denied, ec), + end; + it != end && !ec; it.increment(ec)) + { + if (!it->is_regular_file(ec)) + { + ec.clear(); + continue; + } + + std::string ext = it->path().extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return (char)std::tolower(c); }); + if (ext != ".ttf" && ext != ".otf") + { + continue; + } + + // Skip icon fonts (merged separately in LoadEditorFonts). + std::string stem = it->path().stem().string(); + if (stem.rfind("fa-", 0) == 0) + { + continue; + } + + auto rel = std::filesystem::relative(it->path(), engineRoot, ec); + if (ec) + { + ec.clear(); + continue; + } + + // Store with the "engine/" prefix so the path resolves against EngineRoot consistently. + s_Choices.push_back({MakeFontLabel(it->path()), "engine/" + rel.generic_string()}); + } + + std::sort(s_Choices.begin(), s_Choices.end(), + [](const FontChoice& a, const FontChoice& b) { return a.Label < b.Label; }); + + CH_CORE_INFO("EditorGUI: Discovered {} editor font(s) in '{}'.", s_Choices.size(), fontDir.string()); + return s_Choices; + } + + std::string MakeFontLabel(const std::filesystem::path& file) + { + std::string stem = file.stem().string(); + std::string label; + label.reserve(stem.size()); + bool upperNext = true; + for (char c : stem) + { + if (c == '-' || c == '_') + { + label += ' '; + upperNext = true; + } + else + { + label += upperNext ? (char)std::toupper((unsigned char)c) : c; + upperNext = false; + } + } + return label; + } +} // namespace Chained diff --git a/editor/font_choice_gui.h b/editor/font_choice_gui.h new file mode 100644 index 000000000..ad429262b --- /dev/null +++ b/editor/font_choice_gui.h @@ -0,0 +1,25 @@ +#ifndef CH_FONT_CHOICE_H +#define CH_FONT_CHOICE_H +#include +#include +#include + +namespace Chained +{ + + struct FontChoice + { + std::string Label; // e.g. "Lato Bold" (derived from filename) + std::string Path; // relative to the engine root, prefixed with "engine/", e.g. + // "engine/resources/font/lato/lato-bold.ttf" + }; + + // Turns "lato-bold" / "AlanSans_Medium" into "Lato Bold" / "AlanSans Medium". + std::string MakeFontLabel(const std::filesystem::path& file); + + // Scans /resources/font recursively; cached after the first call. + // FontAwesome icon fonts are excluded — merging them as the main UI font breaks text. + const std::vector& GetEditorFontChoices(); +} // namespace Chained + +#endif /* CH_FONT_CHOICE_H */ diff --git a/editor/font_manager.cpp b/editor/font_manager.cpp new file mode 100644 index 000000000..ec8cc74d8 --- /dev/null +++ b/editor/font_manager.cpp @@ -0,0 +1,108 @@ +#include "font_manager.h" +#include "gui.h" +#include "engine/app/application.h" +#include "engine/assets/asset_manager.h" +#include "engine/core/service_locator.h" +#include "engine/ui/ui_font_registry.h" +#include "engine/ui/widget_renderer.h" +#include "engine/imgui/imgui_layer.h" +#include "thirdparty/IconsFontAwesome6.h" + +namespace Chained +{ + + FontManager::FontManager(EditorConfig& config) + : m_Config(config) + { + } + + void FontManager::AddFontsToAtlas() + { + auto* imguiLayer = Application::Get().GetImGuiLayer(); + if (!imguiLayer) + { + return; + } + + float fontSize = m_Config.FontSize > 0.0f ? m_Config.FontSize : 16.0f; + auto* assetManager = ServiceLocator::TryGet(); + if (!assetManager) + { + return; + } + std::string relFont = + !m_Config.FontPath.empty() ? m_Config.FontPath : "engine/resources/font/lato/lato-bold.ttf"; + std::string fontPath = assetManager->ResolvePath(relFont); + + bool baseFontLoaded = false; + + if (std::filesystem::exists(fontPath)) + { + imguiLayer->AddFontFromFile(fontPath, fontSize, nullptr, ImGui::GetIO().Fonts->GetGlyphRangesCyrillic()); + CH_CORE_INFO("Loaded editor font: {} @ {}px (with Cyrillic)", fontPath, fontSize); + baseFontLoaded = true; + } + else + { + CH_CORE_WARN("Editor font not found: {}. Using default ImGui font.", fontPath); + ImGui::GetIO().Fonts->AddFontDefault(); + } + + // --- Icon Font (FontAwesome) --- + std::string faPath = assetManager->ResolvePath("engine/resources/font/fa-solid-900.ttf"); + if (baseFontLoaded && std::filesystem::exists(faPath)) + { + ImFontConfig icons_config; + icons_config.MergeMode = true; + icons_config.PixelSnapH = true; + + static const ImWchar* font_awesome_ranges = nullptr; + if (!font_awesome_ranges) + { + static const ImWchar ranges[] = {ICON_MIN_FA, ICON_MAX_16_FA, 0}; + font_awesome_ranges = ranges; + } + + imguiLayer->AddFontFromFile(faPath, fontSize, &icons_config, font_awesome_ranges); + CH_CORE_INFO("Loaded and merged FontAwesome for editor: {}", faPath); + } + } + + void FontManager::LoadFonts() + { + AddFontsToAtlas(); + Application::Get().GetImGuiLayer()->RefreshFontAtlasTexture(); + } + + void FontManager::ReloadFonts() + { + auto* imguiLayer = Application::Get().GetImGuiLayer(); + if (!imguiLayer) + { + return; + } + + imguiLayer->ClearFonts(); + + if (auto* fontRegistry = ServiceLocator::TryGet()) + { + fontRegistry->Clear(); + } + + AddFontsToAtlas(); + EditorGUI::ApplyTheme(); + + if (auto* widgetRenderer = ServiceLocator::TryGet()) + { + widgetRenderer->LoadProjectFonts(); + } + + imguiLayer->RefreshFontAtlasTexture(); + } + + void FontManager::RequestReload() + { + m_PendingReload = true; + } + +} // namespace Chained diff --git a/editor/font_manager.h b/editor/font_manager.h new file mode 100644 index 000000000..3c427e48c --- /dev/null +++ b/editor/font_manager.h @@ -0,0 +1,35 @@ +#ifndef CH_FONT_MANAGER_H +#define CH_FONT_MANAGER_H + +#include "editor/project/editor_settings.h" + +namespace Chained +{ + + class FontManager + { + public: + explicit FontManager(EditorConfig& config); + + void LoadFonts(); + void ReloadFonts(); + void AddFontsToAtlas(); + void RequestReload(); + + bool HasPendingReload() const + { + return m_PendingReload; + } + void ClearPendingReload() + { + m_PendingReload = false; + } + + private: + EditorConfig& m_Config; + bool m_PendingReload = false; + }; + +} // namespace Chained + +#endif // CH_FONT_MANAGER_H diff --git a/editor/gui.cpp b/editor/gui.cpp new file mode 100644 index 000000000..9e6d1c0c4 --- /dev/null +++ b/editor/gui.cpp @@ -0,0 +1,485 @@ +#include "gui.h" +#include "editor/layer.h" +#include "editor/panels/panel.h" +#include "editor/panels/viewport_panel.h" +#include "editor/project/project_exporter.h" +#include "engine/app/application.h" +#include "engine/core/service_locator.h" +#include "engine/platform/dialogs/dialogs.h" +#include "engine/project/project.h" +#include "events.h" +#include "thirdparty/IconsFontAwesome6.h" +#include "misc/cpp/imgui_stdlib.h" + +#define IMGUI_DEFINE_MATH_OPERATORS +#include "engine/common/thread_pool.h" +#include "thirdparty/imgui/imgui_internal.h" +#include "engine/scripting/scriptengine.h" +#include "editor/font_choice_gui.h" +#include +#include +#include +#include +#include + +namespace Chained +{ + + static float GetButtonSize() + { + return ImGui::GetFontSize() + ImGui::GetStyle().FramePadding.y * 2.0f; + } + + static float GetThumbnailSize(float buttonSize) + { + return buttonSize * 1.5f; + } + + void EditorGUI::DrawPropertyLabel(const char* label) + { + const char* displayLabel = label ? label : "Unknown"; + if (ImGui::GetCurrentTable() != nullptr) + { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::AlignTextToFramePadding(); + ImGui::Text("%s", displayLabel); + ImGui::TableSetColumnIndex(1); + } + else + { + ImGui::Text("%s", displayLabel); + ImGui::SameLine(ImGui::GetContentRegionAvail().x * 0.4f); + } + } + + void EditorGUI::BeginPropertyGrid() + { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 6)); + ImGui::BeginTable("PropertyGrid", 2, + ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_RowBg | + ImGuiTableFlags_SizingStretchSame); + + ImGui::TableSetupColumn("Label", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Control", ImGuiTableColumnFlags_WidthStretch); + } + + void EditorGUI::EndPropertyGrid() + { + ImGui::EndTable(); + ImGui::PopStyleVar(); + } + + // --- Property Widgets Implementation --- + + template bool EditorGUI::PropertyWidget(const char* label, F&& widgetFn) + { + if (!label) + { + return false; + } + DrawPropertyLabel(label); + ImGui::PushID(label); + bool changed = widgetFn(); + ImGui::PopID(); + return changed; + } + + bool EditorGUI::Property(const char* label, bool& value) + { + return PropertyWidget(label, [&]() { return ImGui::Checkbox("##prop", &value); }); + } + + bool EditorGUI::Property(const char* label, float& value, float speed, float min, float max) + { + return PropertyWidget(label, [&]() { return ImGui::DragFloat("##prop", &value, speed, min, max); }); + } + + bool EditorGUI::Property(const char* label, int& value, int min, int max) + { + return PropertyWidget(label, [&]() { return ImGui::DragInt("##prop", &value, 1.0f, min, max); }); + } + + bool EditorGUI::Property(const char* label, uint64_t& value) + { + return PropertyWidget(label, [&]() { return ImGui::InputScalar("##prop", ImGuiDataType_U64, &value); }); + } + + bool EditorGUI::Property(const char* label, std::string& value, bool multiline) + { + if (!label) + { + return false; + } + DrawPropertyLabel(label); + ImGui::PushID(label); + bool changed; + if (multiline) + { + changed = ImGui::InputTextMultiline("##prop", &value, ImVec2(0, ImGui::GetTextLineHeightWithSpacing() * 3)); + } + else + { + changed = ImGui::InputText("##prop", &value); + } + ImGui::PopID(); + return changed; + } + + bool EditorGUI::Property(const char* label, Color& value) + { + return PropertyWidget(label, [&]() { + float c[4] = {value.r / 255.0f, value.g / 255.0f, value.b / 255.0f, value.a / 255.0f}; + ImGuiColorEditFlags flags = + ImGuiColorEditFlags_AlphaBar | ImGuiColorEditFlags_Uint8 | ImGuiColorEditFlags_DisplayRGB; + bool changed = ImGui::ColorEdit4("##prop", c, flags); + if (changed) + { + value = {(unsigned char)(c[0] * 255), (unsigned char)(c[1] * 255), (unsigned char)(c[2] * 255), + (unsigned char)(c[3] * 255)}; + } + return changed; + }); + } + + bool EditorGUI::Property(const char* label, glm::vec2& value) + { + return DrawVec2(label, value, 0.0f); + } + + bool EditorGUI::Property(const char* label, glm::vec3& value) + { + return DrawVec3(label, value, 0.0f); + } + + bool EditorGUI::Property(const char* label, glm::vec4& value) + { + return DrawVec4(label, value, 0.0f); + } + + bool EditorGUI::PropertyColor(const char* label, glm::vec4& value, bool hdr) + { + return PropertyWidget(label, [&]() { + ImGuiColorEditFlags flags = ImGuiColorEditFlags_AlphaBar | ImGuiColorEditFlags_DisplayRGB; + if (hdr) + { + flags |= ImGuiColorEditFlags_HDR | ImGuiColorEditFlags_Float; + } + return ImGui::ColorEdit4("##prop", &value.x, flags); + }); + } + + bool EditorGUI::Property(const char* label, int& value, const char** items, int itemCount) + { + if (!label) + { + return false; + } + return PropertyWidget(label, [&]() { + if (!items || itemCount <= 0) + { + return false; + } + return ImGui::Combo("##prop", &value, items, itemCount); + }); + } + + bool EditorGUI::FilePropertyImpl(const char* label, std::string& value, const char* filter, + std::function thumbnailFn, const char* placeholder) + { + if (!label) + { + return false; + } + DrawPropertyLabel(label); + ImGui::PushID(label); + + float width = ImGui::GetContentRegionAvail().x; + float buttonSize = GetButtonSize(); + + float thumbnailSize = 0.0f; + if (thumbnailFn) + { + thumbnailSize = GetThumbnailSize(buttonSize); + thumbnailFn(); + ImGui::SameLine(); + } + + ImGui::PushItemWidth(width - buttonSize - thumbnailSize - (thumbnailFn ? 10.0f : 5.0f)); + + auto project = Project::GetActive(); + std::string displayPath = project ? project->GetRelativePath(value) : value; + char inputTextBuf[256]; + memset(inputTextBuf, 0, sizeof(inputTextBuf)); + strncpy(inputTextBuf, displayPath.c_str(), sizeof(inputTextBuf) - 1); + + bool changed = false; + const char* hint = placeholder ? placeholder : ""; + if (ImGui::InputTextWithHint("##prop", hint, inputTextBuf, sizeof(inputTextBuf))) + { + value = project ? project->GetRelativePath(inputTextBuf) : std::string(inputTextBuf); + changed = true; + } + if (ImGui::BeginDragDropTarget()) + { + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("CONTENT_BROWSER_ITEM")) + { + const char* dropPath = static_cast(payload->Data); + if (dropPath) + { + value = project ? project->GetRelativePath(dropPath) : std::string(dropPath); + changed = true; + } + } + ImGui::EndDragDropTarget(); + } + ImGui::PopItemWidth(); + ImGui::SameLine(); + if (ImGui::Button(ICON_FA_FOLDER_OPEN, {buttonSize, buttonSize})) + { + std::vector filters; + if (filter != nullptr && filter[0] != '\0') + { + filters.push_back({"Files", filter}); + } + auto result = Chained::Dialogs::OpenFile(filters); + if (result) + { + value = project ? project->GetRelativePath(*result) : result->string(); + changed = true; + } + } + + ImGui::PopID(); + return changed; + } + + bool EditorGUI::FileProperty(const char* label, std::string& value, const char* filter) + { + return FilePropertyImpl(label, value, filter, nullptr, nullptr); + } + + bool EditorGUI::FileProperty(const char* label, std::string& path, uint32_t textureId, const char* filter) + { + const char* placeholder = (textureId > 0 && path.empty()) ? "" : nullptr; + return FilePropertyImpl( + label, path, filter, + [textureId]() { + float buttonSize = GetButtonSize(); + float thumbnailSize = GetThumbnailSize(buttonSize); + if (textureId > 0) + { + ImGui::Image((void*)(intptr_t)textureId, {thumbnailSize, thumbnailSize}, {0, 1}, {1, 0}); + } + else + { + ImGui::Button("##empty", {thumbnailSize, thumbnailSize}); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("No texture loaded"); + } + } + }, + placeholder); + } + + bool EditorGUI::ActionButton(const char* icon, const char* label) + { + std::string text; + if (icon && icon[0] != '\0') + { + text = std::string(icon) + " " + (label ? label : ""); + } + else + { + text = (label ? label : ""); + } + return ImGui::Button(text.c_str()); + } + + static void DrawPropertyControl(const char* id, float& val, ImVec4 color, const char* label, float resetValue, + float width, bool& changed) + { + if (!label || !id) + { + return; + } + ImGui::PushID(label); + + float lineHeight = GetButtonSize(); + ImVec2 buttonSize = {lineHeight, lineHeight}; + + ImGui::PushStyleColor(ImGuiCol_Button, color); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, color); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, color); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 3.0f); + + if (ImGui::Button(label, buttonSize)) + { + val = resetValue; + changed = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Click to reset to %.2f", resetValue); + } + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); + + ImGui::SameLine(0, 0); + + ImGui::SetNextItemWidth(width - buttonSize.x); + char buf[32]; + snprintf(buf, sizeof(buf), "##%.8s_%.8s", label, id); + if (ImGui::DragFloat(buf, &val, 0.1f, 0.0f, 0.0f, "%.2f")) + { + changed = true; + } + + ImGui::PopID(); + } + + template + bool EditorGUI::DrawVecImpl(const char* label, float* values, float resetValue, const ImVec4* colors, + const char* componentLabels[N]) + { + if (!label) + { + return false; + } + DrawPropertyLabel(label); + ImGui::PushID(label); + + bool changed = false; + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2{4, 0}); + float width = ImGui::GetContentRegionAvail().x; + float spacing = 4.0f * (N - 1); + float itemWidth = (width - spacing) / N; + + ImGui::BeginGroup(); + + for (int i = 0; i < N; ++i) + { + if (i > 0) + { + ImGui::SameLine(); + } + ImGui::SetNextItemWidth(itemWidth); + DrawPropertyControl(componentLabels[i], values[i], colors[i], componentLabels[i], resetValue, itemWidth, + changed); + } + + ImGui::EndGroup(); + + ImGui::PopStyleVar(); + ImGui::PopID(); + return changed; + } + + bool EditorGUI::DrawVec2(const char* label, glm::vec2& values, float resetValue) + { + float arr[2] = {values.x, values.y}; + ImVec4 colors[2] = {{0.8f, 0.1f, 0.15f, 1.0f}, {0.2f, 0.7f, 0.2f, 1.0f}}; + const char* labels[2] = {"X", "Y"}; + bool changed = DrawVecImpl<2>(label, arr, resetValue, colors, labels); + if (changed) + { + values.x = arr[0]; + values.y = arr[1]; + } + return changed; + } + + bool EditorGUI::DrawVec3(const char* label, glm::vec3& values, float resetValue) + { + float arr[3] = {values.x, values.y, values.z}; + ImVec4 colors[3] = {{0.8f, 0.1f, 0.15f, 1.0f}, {0.2f, 0.7f, 0.2f, 1.0f}, {0.1f, 0.25f, 0.8f, 1.0f}}; + const char* labels[3] = {"X", "Y", "Z"}; + bool changed = DrawVecImpl<3>(label, arr, resetValue, colors, labels); + if (changed) + { + values.x = arr[0]; + values.y = arr[1]; + values.z = arr[2]; + } + return changed; + } + + bool EditorGUI::DrawVec4(const char* label, glm::vec4& values, float resetValue) + { + float arr[4] = {values.x, values.y, values.z, values.w}; + ImVec4 colors[4] = { + {0.8f, 0.1f, 0.15f, 1.0f}, {0.2f, 0.7f, 0.2f, 1.0f}, {0.1f, 0.25f, 0.8f, 1.0f}, {0.5f, 0.5f, 0.5f, 1.0f}}; + const char* labels[4] = {"X", "Y", "Z", "W"}; + bool changed = DrawVecImpl<4>(label, arr, resetValue, colors, labels); + if (changed) + { + values.x = arr[0]; + values.y = arr[1]; + values.z = arr[2]; + values.w = arr[3]; + } + return changed; + } + + void EditorGUI::ApplyTheme() + { + ImGuiStyle& style = ImGui::GetStyle(); + style = ImGuiStyle(); // Reset to clean defaults to prevent ScaleAllSizes from accumulating + + style.WindowRounding = 5.0f; + style.FrameRounding = 4.0f; + style.PopupRounding = 4.0f; + style.ScrollbarRounding = 12.0f; + style.GrabRounding = 4.0f; + style.TabRounding = 4.0f; + + ImVec4* colors = style.Colors; + colors[ImGuiCol_Text] = ImVec4(0.95f, 0.96f, 0.98f, 1.00f); + colors[ImGuiCol_TextDisabled] = ImVec4(0.36f, 0.42f, 0.47f, 1.00f); + colors[ImGuiCol_WindowBg] = ImVec4(0.10f, 0.12f, 0.14f, 1.00f); + colors[ImGuiCol_ChildBg] = ImVec4(0.12f, 0.14f, 0.16f, 1.00f); + colors[ImGuiCol_PopupBg] = ImVec4(0.08f, 0.10f, 0.12f, 0.94f); + colors[ImGuiCol_Border] = ImVec4(0.20f, 0.22f, 0.25f, 0.50f); + colors[ImGuiCol_FrameBg] = ImVec4(0.18f, 0.20f, 0.22f, 1.00f); + colors[ImGuiCol_FrameBgHovered] = ImVec4(0.25f, 0.28f, 0.32f, 1.00f); + colors[ImGuiCol_FrameBgActive] = ImVec4(0.22f, 0.24f, 0.26f, 1.00f); + colors[ImGuiCol_TitleBg] = ImVec4(0.08f, 0.10f, 0.12f, 1.00f); + colors[ImGuiCol_TitleBgActive] = ImVec4(0.06f, 0.08f, 0.10f, 1.00f); + + colors[ImGuiCol_Header] = ImVec4(0.20f, 0.25f, 0.35f, 0.60f); + colors[ImGuiCol_HeaderHovered] = ImVec4(0.25f, 0.35f, 0.50f, 0.80f); + colors[ImGuiCol_HeaderActive] = ImVec4(0.30f, 0.40f, 0.60f, 1.00f); + + colors[ImGuiCol_Separator] = ImVec4(0.20f, 0.22f, 0.25f, 1.00f); + colors[ImGuiCol_CheckMark] = ImVec4(0.40f, 0.60f, 0.90f, 1.00f); + colors[ImGuiCol_SliderGrab] = ImVec4(0.40f, 0.60f, 0.90f, 1.00f); + colors[ImGuiCol_SliderGrabActive] = ImVec4(0.50f, 0.70f, 1.00f, 1.00f); + colors[ImGuiCol_Button] = ImVec4(0.18f, 0.20f, 0.22f, 1.00f); + colors[ImGuiCol_ButtonHovered] = ImVec4(0.25f, 0.35f, 0.50f, 1.00f); + colors[ImGuiCol_ButtonActive] = ImVec4(0.30f, 0.45f, 0.70f, 1.00f); + + colors[ImGuiCol_Tab] = ImVec4(0.08f, 0.10f, 0.12f, 1.00f); + colors[ImGuiCol_TabHovered] = ImVec4(0.25f, 0.35f, 0.50f, 0.80f); + colors[ImGuiCol_TabActive] = ImVec4(0.12f, 0.14f, 0.16f, 1.00f); + colors[ImGuiCol_TabUnfocused] = ImVec4(0.08f, 0.10f, 0.12f, 1.00f); + colors[ImGuiCol_TabUnfocusedActive] = ImVec4(0.10f, 0.12f, 0.14f, 1.00f); + colors[ImGuiCol_PlotLines] = ImVec4(0.61f, 0.61f, 0.61f, 1.00f); + colors[ImGuiCol_PlotLinesHovered] = ImVec4(1.00f, 0.43f, 0.35f, 1.00f); + colors[ImGuiCol_PlotHistogram] = ImVec4(0.90f, 0.70f, 0.00f, 1.00f); + colors[ImGuiCol_PlotHistogramHovered] = ImVec4(1.00f, 0.60f, 0.00f, 1.00f); + colors[ImGuiCol_TextSelectedBg] = ImVec4(0.26f, 0.59f, 0.98f, 0.35f); + colors[ImGuiCol_DragDropTarget] = ImVec4(1.00f, 1.00f, 0.00f, 0.90f); + colors[ImGuiCol_NavHighlight] = ImVec4(0.26f, 0.59f, 0.98f, 1.00f); + colors[ImGuiCol_NavWindowingHighlight] = ImVec4(1.00f, 1.00f, 1.00f, 0.70f); + colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.80f, 0.80f, 0.80f, 0.20f); + colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.10f, 0.12f, 0.14f, 0.73f); + + float fontSize = EditorLayer::Get().GetConfig().FontSize; + float scale = fontSize > 0.0f ? (fontSize / 13.0f) : 1.0f; + style.ScaleAllSizes(scale); + } + +} // namespace Chained diff --git a/editor/gui.h b/editor/gui.h new file mode 100644 index 000000000..d347f732c --- /dev/null +++ b/editor/gui.h @@ -0,0 +1,65 @@ +#ifndef CH_EDITOR_GUI_H +#define CH_EDITOR_GUI_H + +#include +#include +#include "editor/layer.h" +#include "editor/panels.h" +#include "engine/common/color.h" + +namespace Chained +{ + + // Immediate-mode GUI helpers shared by editor panels and property inspectors. + class EditorGUI + { + public: + /// @brief Begins a 2-column property grid. + static void BeginPropertyGrid(); + static void EndPropertyGrid(); + static void DrawPropertyLabel(const char* label); + + // Simple property widgets that do not use columns. + static bool Property(const char* label, bool& value); + static bool Property(const char* label, int& value, int min = 0, int max = 0); + static bool Property(const char* label, float& value, float speed = 0.1f, float min = 0.0f, float max = 0.0f); + static bool Property(const char* label, std::string& value, bool multiline = false); + static bool Property(const char* label, Color& value); + static bool Property(const char* label, glm::vec2& value); + static bool Property(const char* label, glm::vec3& value); + static bool Property(const char* label, glm::vec4& value); + static bool Property(const char* label, uint64_t& value); + + // Renders a glm::vec4 as an RGBA color swatch/picker instead of raw X/Y/Z/W drag fields. + static bool PropertyColor(const char* label, glm::vec4& value, bool hdr = false); + + static bool Property(const char* label, int& value, const char** items, int itemCount); + + // Action widgets. + static bool ActionButton(const char* icon, const char* label); + + // File property widgets. + static bool FileProperty(const char* label, std::string& value, const char* filter = nullptr); + static bool FileProperty(const char* label, std::string& path, uint32_t textureId, + const char* filter = nullptr); + + static bool DrawVec2(const char* label, glm::vec2& values, float resetValue = 0.0f); + static bool DrawVec3(const char* label, glm::vec3& values, float resetValue = 0.0f); + static bool DrawVec4(const char* label, glm::vec4& values, float resetValue = 0.0f); + // Applies the editor-wide ImGui style. + static void ApplyTheme(); + + private: + template static bool PropertyWidget(const char* label, F&& widgetFn); + + static bool FilePropertyImpl(const char* label, std::string& value, const char* filter, + std::function thumbnailFn, const char* placeholder = nullptr); + + template + static bool DrawVecImpl(const char* label, float* values, float resetValue, const ImVec4* colors, + const char* componentLabels[N]); + }; + +} // namespace Chained + +#endif // CH_EDITOR_GUI_H diff --git a/editor/icons.h b/editor/icons.h new file mode 100644 index 000000000..23e58c2d1 --- /dev/null +++ b/editor/icons.h @@ -0,0 +1,21 @@ + +#ifndef CH_EDITOR_ICONS_H +#define CH_EDITOR_ICONS_H + +#include + +namespace Chained +{ + class TextureAsset; + + // Cached editor icon textures used during scene rendering. + struct EditorIcons + { + std::shared_ptr LightIcon; + std::shared_ptr SpawnIcon; + std::shared_ptr CameraIcon; + std::shared_ptr AudioIcon; + }; +} // namespace Chained + +#endif // CH_EDITOR_ICONS_H diff --git a/editor/launcher/editor_launcher.cpp b/editor/launcher/editor_launcher.cpp deleted file mode 100644 index 3adf23419..000000000 --- a/editor/launcher/editor_launcher.cpp +++ /dev/null @@ -1,298 +0,0 @@ -#include "editor_launcher.h" -#include "engine/core/base.h" -#include "engine/core/profiler.h" -#include "engine/scene/project.h" -#include "engine/scene/project_serializer.h" -#include "engine/scene/scene_serializer.h" -#if CH_PLATFORM_WINDOWS -#include -#include -#endif -#include -#include - -namespace CHEngine -{ - -void EditorLauncher::LaunchStandalone(std::shared_ptr project, std::shared_ptr editorScene) -{ - CH_PROFILE_FUNCTION(); - - if (!project) - { - CH_CORE_ERROR("EditorLauncher: No active project to launch!"); - return; - } - - auto& config = project->GetConfig(); - std::string sceneArgument; - - if (editorScene) - { - std::filesystem::path scenePath = editorScene->GetSettings().ScenePath; - if (scenePath.empty()) - { - scenePath = config.ActiveScenePath; - } - - if (!scenePath.empty()) - { - // Sync scene to disk before launching standalone - if (!editorScene->GetSettings().ScenePath.empty()) - { - SceneSerializer serializer(editorScene.get()); - if (!serializer.Serialize(editorScene->GetSettings().ScenePath)) - { - CH_CORE_ERROR("EditorLauncher: Failed to save current editor scene before launching."); - return; - } - } - - if (scenePath.is_relative()) - { - scenePath = Project::GetAssetPath(scenePath); - } - - scenePath = std::filesystem::absolute(scenePath); - project->SetActiveScenePath(Project::GetRelativePath(scenePath)); - sceneArgument = std::format(" --scene \"{}\"", scenePath.string()); - } - } - - // Save project specifically to persist active scene path - ProjectSerializer pSerializer(project); - pSerializer.Serialize((project->GetProjectDirectory() / (project->GetConfig().Name + ".chproject")).string()); - - std::string runtimePath; - std::string arguments; - - if (!config.LaunchProfiles.empty() && config.ActiveLaunchProfileIndex >= 0 && - config.ActiveLaunchProfileIndex < (int)config.LaunchProfiles.size()) - { - const auto& profile = config.LaunchProfiles[config.ActiveLaunchProfileIndex]; - runtimePath = ResolveLaunchVariables(profile.BinaryPath, project); - arguments = ResolveLaunchVariables(profile.Arguments, project); - - if (profile.UseDefaultArgs) - { - std::filesystem::path projectFile = - project->GetProjectDirectory() / (project->GetConfig().Name + ".chproject"); - arguments += std::format(" \"{}\"", std::filesystem::absolute(projectFile).string()); - } - - if (!sceneArgument.empty()) - { - arguments += sceneArgument; - } - } - else - { - // Fallback to old heuristic if no profiles - CH_CORE_WARN("EditorLauncher: No active launch profile. Falling back to heuristic search."); - std::string configStr = (config.BuildConfig == Configuration::Release) ? "Release" : "Debug"; - runtimePath = FindRuntimeExecutable(config.Name, configStr).string(); - - std::filesystem::path projectFile = project->GetProjectDirectory() / (project->GetConfig().Name + ".chproject"); - arguments = std::format("\"{}\"", std::filesystem::absolute(projectFile).string()); - - if (!sceneArgument.empty()) - { - arguments += sceneArgument; - } - } - - if (runtimePath.empty() || !std::filesystem::exists(runtimePath)) - { - CH_CORE_WARN("EditorLauncher: Profile binary not found at '{}'. Searching heuristic...", runtimePath); - std::string configStr = (config.BuildConfig == Configuration::Release) ? "Release" : "Debug"; - runtimePath = FindRuntimeExecutable(config.Name, configStr).string(); - - if (runtimePath.empty()) - { - CH_CORE_ERROR("EditorLauncher: Runtime executable not found!"); - return; - } - } - -#if CH_PLATFORM_WINDOWS - // Normalize slashes for Windows (start command and ShellExecute prefer \) - std::string normalizedRuntime = runtimePath; - std::replace(normalizedRuntime.begin(), normalizedRuntime.end(), '/', '\\'); - - std::string normalizedArgs = arguments; - std::replace(normalizedArgs.begin(), normalizedArgs.end(), '/', '\\'); - - CH_CORE_INFO("EditorLauncher: Executing via ShellExecute: {} {}", normalizedRuntime, normalizedArgs); - - // Use ShellExecute instead of system to be truly non-blocking and avoid cmd window issues - HINSTANCE result = ShellExecuteA(NULL, "open", normalizedRuntime.c_str(), normalizedArgs.c_str(), NULL, SW_SHOW); - if ((uintptr_t)result <= 32) - { - CH_CORE_ERROR("EditorLauncher: ShellExecute failed with error code: {}", (uintptr_t)result); - } -#else - std::string command = std::format("\"{}\" {} &", runtimePath, arguments); - CH_CORE_INFO("EditorLauncher: Executing: {}", command); - system(command.c_str()); -#endif -} - -std::filesystem::path EditorLauncher::FindRuntimeExecutable(const std::string& projectName, - const std::string& configStr) -{ - CH_PROFILE_FUNCTION(); - - std::filesystem::path root; -#ifdef PROJECT_ROOT_DIR - root = PROJECT_ROOT_DIR; -#else - root = std::filesystem::current_path(); - while (root.has_parent_path() && !std::filesystem::exists(root / "CMakeLists.txt")) - { - root = root.parent_path(); - } -#endif - - if (!std::filesystem::exists(root)) - { - CH_CORE_ERROR("EditorLauncher: Root path not found: {}", root.string()); - return {}; - } - -#if CH_PLATFORM_WINDOWS - const std::string targetName = "ChainedRuntime.exe"; -#else - const std::string targetName = "ChainedRuntime"; -#endif - - // 1. Check current working directory - std::filesystem::path currentBin = std::filesystem::current_path() / targetName; - if (std::filesystem::exists(currentBin)) - { - return currentBin; - } - - // 2. Fast common output locations - std::vector searchSubdirs = {"build/bin", "bin", "out/bin", "cmake-build-debug/bin", - "cmake-build-release/bin"}; - - // Auto-discover build folders in project root - if (std::filesystem::exists(root / "build")) - { - for (const auto& entry : std::filesystem::directory_iterator(root / "build")) - { - if (entry.is_directory()) - { - if (std::filesystem::exists(entry.path() / "bin" / targetName)) - { - searchSubdirs.push_back("build/" + entry.path().filename().string() + "/bin"); - } - } - } - } - - for (const auto& sub : searchSubdirs) - { - std::filesystem::path p = root / sub / targetName; - if (std::filesystem::exists(p)) - { - CH_CORE_INFO("EditorLauncher: Path found at: {}", p.string()); - return p; - } - } - - // 3. Fallback: careful recursive search excluding noisy folders - CH_CORE_INFO("EditorLauncher: Fast path failed, starting scoped recursive search..."); - try - { - for (auto it = std::filesystem::recursive_directory_iterator(root); - it != std::filesystem::recursive_directory_iterator(); ++it) - { - const auto& entry = *it; - auto filename = entry.path().filename().string(); - - if (entry.is_directory()) - { - if (filename == ".git" || filename == ".cache" || filename == ".idea" || filename == "include" || - filename == "engine") - { - it.disable_recursion_pending(); - continue; - } - } - - if (entry.is_regular_file() && filename == targetName) - { - CH_CORE_INFO("EditorLauncher: Deep search found at: {}", entry.path().string()); - return entry.path(); - } - } - } catch (const std::exception& e) - { - CH_CORE_WARN("EditorLauncher: Deep search error: {}", e.what()); - } - - return {}; -} - -std::string EditorLauncher::ResolveLaunchVariables(std::string str, std::shared_ptr project) -{ - CH_PROFILE_FUNCTION(); - - if (!project) - { - return str; - } - - std::filesystem::path root; -#ifdef PROJECT_ROOT_DIR - root = PROJECT_ROOT_DIR; -#else - root = std::filesystem::current_path(); - while (root.has_parent_path() && !std::filesystem::exists(root / "CMakeLists.txt")) - { - root = root.parent_path(); - } -#endif - - std::filesystem::path projectFile = project->GetProjectDirectory() / (project->GetConfig().Name + ".chproject"); - std::string projectPathStr = std::filesystem::absolute(projectFile).string(); - - auto replaceAll = [&](const std::string& from, const std::string& to) { - size_t pos = 0; - while ((pos = str.find(from)) != std::string::npos) - { - str.replace(pos, from.length(), to); - } - }; - - replaceAll("${ROOT}", std::filesystem::absolute(root).string()); - replaceAll("${PROJECT_FILE}", projectPathStr); - - if (str.find("${BUILD}") != std::string::npos) - { - std::string configStr = (project->GetConfig().BuildConfig == Configuration::Release) ? "Release" : "Debug"; - std::filesystem::path exePath = FindRuntimeExecutable(project->GetConfig().Name, configStr); - std::filesystem::path buildPath = exePath.parent_path(); - - if (buildPath.empty()) - { - std::vector searchSubdirs = {"build/bin", "bin", "out/bin", "cmake-build-debug/bin", - "cmake-build-release/bin"}; - for (const auto& sub : searchSubdirs) - { - if (std::filesystem::exists(root / sub)) - { - buildPath = root / sub; - break; - } - } - } - - replaceAll("${BUILD}", std::filesystem::absolute(buildPath).string()); - } - - return str; -} - -} // namespace CHEngine diff --git a/editor/launcher/editor_launcher.h b/editor/launcher/editor_launcher.h deleted file mode 100644 index eceec36ea..000000000 --- a/editor/launcher/editor_launcher.h +++ /dev/null @@ -1,28 +0,0 @@ -#ifndef CH_EDITOR_LAUNCHER_H -#define CH_EDITOR_LAUNCHER_H - -#include "engine/scene/project.h" -#include "engine/scene/scene.h" -#include -#include -#include - -namespace CHEngine { - - class EditorLauncher { - public: - /** - * @brief Launches the standalone runtime for the given project and scene. - * Resolves project-defined launch profiles or uses default heuristics. - * @param project The active project. - * @param editorScene The current editor scene (to sync before launch). - */ - static void LaunchStandalone(std::shared_ptr project, std::shared_ptr editorScene); - - private: - static std::filesystem::path FindRuntimeExecutable(const std::string& projectName, const std::string& configStr); - static std::string ResolveLaunchVariables(std::string str, std::shared_ptr project); - }; - -} // namespace CHEngine -#endif diff --git a/editor/layer.cpp b/editor/layer.cpp new file mode 100644 index 000000000..cd40aede7 --- /dev/null +++ b/editor/layer.cpp @@ -0,0 +1,662 @@ +#include "layer.h" +#include "editor_colors.h" +#include "editor/font_manager.h" +#include "engine/core/input.h" +#include "engine/core/events/input_events.h" +#include "engine/core/key_codes.h" +#include "engine/core/service_locator.h" +#include "engine/imgui/imgui_layer.h" +#include "events.h" +#include "gui.h" +#include "editor_menu.h" +#include "layout.h" +#include "panels.h" + +#include "engine/app/application.h" +#include "engine/assets/asset_manager.h" +#include "engine/core/profiler.h" +#include "engine/graphics/api/graphics_device.h" +#include "engine/ui/ui_font_registry.h" +#include "engine/ui/widget_renderer.h" +#include "engine/project/project.h" +#include "panels/property_editor.h" +#include "panels/viewport_panel.h" +#include "engine/scripting/scriptengine.h" +#include "engine/graphics/pipeline/renderer.h" +#include "engine/graphics/pipeline/scene_renderer.h" +#include "engine/graphics/api/framebuffer.h" +#include "thirdparty/IconsFontAwesome6.h" +#include "ui/project_selector_ui.h" +#include +#include +#include +#include +#include +#include + +namespace Chained +{ + void EditorLayer::DrawLoadingOverlay(const char* title, const char* status) + { + ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(viewport->WorkPos); + ImGui::SetNextWindowSize(viewport->WorkSize); + ImGui::SetNextWindowViewport(viewport->ID); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoDocking | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | + ImGuiWindowFlags_NoInputs; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, EditorColors::LoadingOverlayBg); + + if (ImGui::Begin("##EditorLoadingOverlay", nullptr, flags)) + { + float windowWidth = ImGui::GetWindowWidth(); + float windowHeight = ImGui::GetWindowHeight(); + + ImGui::SetCursorPosY(windowHeight * 0.42f); + + if (title && title[0] != '\0') + { + ImVec2 titleSize = ImGui::CalcTextSize(title); + ImGui::SetCursorPosX((windowWidth - titleSize.x) * 0.5f); + ImGui::TextUnformatted(title); + ImGui::Spacing(); + } + + if (status && status[0] != '\0') + { + ImVec2 statusSize = ImGui::CalcTextSize(status); + ImGui::SetCursorPosX((windowWidth - statusSize.x) * 0.5f); + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), "%s", status); + ImGui::Spacing(); + } + + // Animated progress bar + float barWidth = std::min(320.0f, windowWidth * 0.4f); + float barHeight = 4.0f; + ImGui::SetCursorPosX((windowWidth - barWidth) * 0.5f); + + float time = (float)ImGui::GetTime(); + float animFraction = fmodf(time * 0.8f, 1.0f); + ImGui::ProgressBar(animFraction, ImVec2(barWidth, barHeight), ""); + + auto* assetManager = ServiceLocator::TryGet(); + uint32_t totalPending = assetManager ? (uint32_t)assetManager->GetPendingFinalizeCount() : 0; + if (totalPending > 0) + { + ImGui::Spacing(); + char pendingBuffer[64]; + snprintf(pendingBuffer, sizeof(pendingBuffer), "Finalizing assets: %u", totalPending); + + ImVec2 pendingSize = ImGui::CalcTextSize(pendingBuffer); + ImGui::SetCursorPosX((windowWidth - pendingSize.x) * 0.5f); + ImGui::TextColored(ImVec4(0.55f, 0.55f, 0.55f, 1.0f), "%s", pendingBuffer); + } + } + + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); + } + + EditorLayer::EditorLayer() + : Layer("EditorLayer") + { + s_Instance = this; + + m_ProjectManager = std::make_unique(); + m_SceneManager = std::make_unique(); + + m_Menu = std::make_unique(); + m_Panels = std::make_unique(); + + m_Layout = std::make_unique(*m_Panels); + m_ProjectSelectorUI = std::make_unique(*m_ProjectManager); + m_FontManager = std::make_unique(m_Config); + + LoadConfig(); + } + + EditorLayer::~EditorLayer() + { + SetSelectedEntity({}); + s_Instance = nullptr; + } + + template static void LoadYAMLField(const YAML::Node& node, const char* key, T& target) + { + if (node[key]) + { + target = node[key].as(target); + } + } + + // Single source of truth for all YAML fields — used by both LoadConfig and SaveConfig. + // Each macro expansion: (YAML_KEY, STRUCT_FIELD) +#define EDITOR_CONFIG_FIELDS(X) \ + X("LastScenePath", LastScenePath) \ + X("LoadLastProjectOnStartup", LoadLastProjectOnStartup) \ + X("AutoSaveEnabled", AutoSaveEnabled) \ + X("AutoSaveInterval", AutoSaveInterval) \ + X("FontPath", FontPath) \ + X("FontSize", FontSize) \ + X("IconSizeScale", IconSizeScale) \ + X("IconSizeMin", IconSizeMin) \ + X("IconSizeMax", IconSizeMax) \ + X("CameraMoveSpeed", CameraMoveSpeed) \ + X("CameraBoostMultiplier", CameraBoostMultiplier) \ + X("DisableCameraZoom", DisableCameraZoom) \ + X("CameraRotationSpeed", CameraRotationSpeed) \ + X("CameraZoomSpeedMultiplier", CameraZoomSpeedMultiplier) \ + X("CameraFovDegrees", CameraFovDegrees) \ + X("CameraNearClip", CameraNearClip) \ + X("CameraFarClip", CameraFarClip) \ + X("ShowEditorIcons", ShowEditorIcons) \ + X("GizmoScale", GizmoScale) \ + X("DefaultThumbnailSize", DefaultThumbnailSize) \ + X("DefaultSortOrder", DefaultSortOrder) \ + X("ShowFileExtensions", ShowFileExtensions) \ + X("ConfirmOnSceneClose", ConfirmOnSceneClose) \ + X("MaxRecentProjects", MaxRecentProjects) + + void EditorLayer::LoadConfig() + { + std::filesystem::path configPath = std::filesystem::current_path() / "editor_settings.yaml"; + if (!std::filesystem::exists(configPath)) + { + return; + } + + try + { + YAML::Node data = YAML::LoadFile(configPath.string()); + if (data["Editor"]) + { + auto node = data["Editor"]; + if (node["LastProjectPath"]) + { + std::string lastProj = node["LastProjectPath"].as(""); + m_ProjectManager->RestoreLastProjectPath(lastProj); + m_Config.LastProjectPath = lastProj; + } +#define LOAD_FIELD(yamlKey, field) LoadYAMLField(node, yamlKey, m_Config.field); + EDITOR_CONFIG_FIELDS(LOAD_FIELD) +#undef LOAD_FIELD + + if (node["RecentProjects"]) + { + m_Config.RecentProjects.clear(); + for (const auto& entry : node["RecentProjects"]) + { + m_Config.RecentProjects.push_back(entry.as()); + } + } + } + } catch (const std::exception& e) + { + CH_CORE_ERROR("EditorLayer: Failed to load editor settings: {}", e.what()); + } + } + + void EditorLayer::SaveConfig() + { + YAML::Emitter out; + out << YAML::BeginMap; + out << YAML::Key << "Editor" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "LastProjectPath" << YAML::Value << m_ProjectManager->GetLastProjectPath(); + m_Config.LastProjectPath = m_ProjectManager->GetLastProjectPath(); + +#define SAVE_FIELD(yamlKey, field) out << YAML::Key << yamlKey << YAML::Value << m_Config.field; + EDITOR_CONFIG_FIELDS(SAVE_FIELD) +#undef SAVE_FIELD + + out << YAML::Key << "RecentProjects" << YAML::Value << YAML::BeginSeq; + for (const auto& path : m_Config.RecentProjects) + { + out << path; + } + out << YAML::EndSeq; + + out << YAML::EndMap; + out << YAML::EndMap; + + std::filesystem::path configPath = std::filesystem::current_path() / "editor_settings.yaml"; + std::ofstream fout(configPath); + if (!fout.is_open()) + { + CH_CORE_ERROR("EditorLayer: Failed to open editor settings for writing: {}", configPath.string()); + return; + } + fout << out.c_str(); + } + + void EditorLayer::OnAttach() + { + // Ensure this module (EXE) uses the same ImGui context as the engine DLL + auto& app = Application::Get(); + ImGui::SetCurrentContext(static_cast(app.GetImGuiLayer()->GetContext())); + + // SetTraceLogCallback removed - now using engine logging + + EditorGUI::ApplyTheme(); + PropertyEditor::Init(); + m_Panels->Init(); + + // Load editor fonts BEFORE project auto-load. + // OnProjectOpened will clear + rebuild the atlas (editor + project fonts together). + // LoadEditorFonts must run first so there is a valid atlas for the initial UI frame. + LoadEditorFonts(); + + // Auto-load last project/scene + const auto& config = GetConfig(); + + if (config.LoadLastProjectOnStartup && !m_ProjectManager->GetLastProjectPath().empty() && + std::filesystem::exists(m_ProjectManager->GetLastProjectPath())) + { + CH_CORE_INFO("Auto-loading last project: {}", m_ProjectManager->GetLastProjectPath()); + m_ProjectManager->OpenProject(m_ProjectManager->GetLastProjectPath()); + // No ImGui frame is in flight during OnAttach, so it is safe (and + // required — the scene below needs asset dirs set) to process now. + m_ProjectManager->ProcessPendingProjectOpen(); + + if (!config.LastScenePath.empty() && std::filesystem::exists(config.LastScenePath)) + { + CH_CORE_INFO("Auto-loading last scene: {}", config.LastScenePath); + m_SceneManager->OpenScene(config.LastScenePath); + } + } + else + { + Project::SetActive(nullptr); + } + + // Ensure layout is initialized + const char* iniPath = ImGui::GetIO().IniFilename; + if (iniPath && !std::filesystem::exists(iniPath)) + { + CH_CORE_INFO("OnAttach: Layout file '{}' not found, will be reset on first frame", iniPath); + GetEditorState().NeedsLayoutReset = true; + } + + auto* assetManager = ServiceLocator::TryGet(); + if (assetManager) + { + std::string iconPath = assetManager->ResolvePath("engine/resources/icons/chaineddecosmapeditor.jpg"); + if (std::filesystem::exists(iconPath)) + { + app.GetWindow().SetWindowIcon(iconPath); + } + else + { + CH_CORE_WARN("Editor icon not found at: {}", iconPath); + } + } + CH_CORE_INFO("EditorLayer Attached with modular panels."); + } + + void EditorLayer::LoadEditorFonts() + { + m_FontManager->LoadFonts(); + } + + void EditorLayer::ReloadEditorFonts() + { + m_FontManager->ReloadFonts(); + } + + void EditorLayer::RequestEditorFontReload() + { + m_FontManager->RequestReload(); + } + + void EditorLayer::OnDetach() + { + if (auto scene = GetActiveScene()) + { + if (scene->GetSceneState() != SceneState::Edit) + { + scene->OnRuntimeStop(); + } + } + // Explicitly save the ImGui panel layout before shutdown. + // ImGui's built-in autosave runs on a timer (io.IniSavingRate, default 5s) and + // may not fire before the process exits — especially if OnDetach is called after + // the GLFW window is already destroyed. Saving here guarantees the layout is + // always written regardless of shutdown timing. + if (m_Layout) + { + ImGui::SaveIniSettingsToDisk(ImGui::GetIO().IniFilename); + } + SaveConfig(); + } + + void EditorLayer::OnUpdate(Timestep ts) + { + CH_PROFILE_FUNCTION(); + + m_ProjectManager->ProcessPendingProjectOpen(); + + if (m_FontManager->HasPendingReload()) + { + auto* imguiLayer = Application::Get().GetImGuiLayer(); + if (imguiLayer) + { + imguiLayer->ExecuteNextFrame([this]() { + ReloadEditorFonts(); + ImGuiIO& io = ImGui::GetIO(); + if (!io.Fonts->Fonts.empty()) + { + io.FontDefault = io.Fonts->Fonts[0]; + } + }); + } + m_FontManager->ClearPendingReload(); + } + + if (auto* fontRegistry = ServiceLocator::TryGet()) + { + if (fontRegistry->NeedsAtlasRebuild()) + { + auto* imguiLayer = Application::Get().GetImGuiLayer(); + if (imguiLayer) + { + imguiLayer->ExecuteNextFrame([imguiLayer]() { imguiLayer->RefreshFontAtlasTexture(); }); + } + fontRegistry->ClearRebuildFlag(); + } + } + + if (!m_PendingSceneTransitionPath.empty()) + { + m_SceneManager->OpenScene(m_PendingSceneTransitionPath); + + // Ensure play mode continues after scene load + m_SceneManager->SetSceneState(SceneState::Play); + + m_PendingSceneTransitionPath.clear(); + } + + // 1. Scene manager updates internal transitions and the current active scene + m_SceneManager->OnUpdate(ts); + + // 2. Update panels + m_Panels->SetContext(GetActiveScene()); + m_Panels->OnUpdate(ts); + + // 3. If loading — skip everything else + if (m_SceneManager->IsLoading()) + { + return; + } + + // 4. Logic update (Play/Simulate/Edit) is now handled by the scene + // We just call OnUpdate on the active scene + if (auto scene = GetActiveScene()) + { + // Detect Edit->Play transition. The same physical click that pressed the + // Play toolbar button is still reported by ImGui::IsMouseClicked this + // frame, so suppress UI input once to stop it leaking into game widgets. + SceneState state = scene->GetSceneState(); + if (state == SceneState::Play && m_PrevSceneState != SceneState::Play) + { + m_SuppressNextUIInput = true; + } + m_PrevSceneState = state; + + // If scene is in Play mode, ask ScriptEngine to execute scripts + if (state == SceneState::Play) + { + // Process UI input before scripts read widget state, unconditionally + // each frame (see WidgetRenderer::ProcessInput). Keeps a one-frame + // click edge from sticking when the viewport canvas isn't drawn. + if (auto* uiRenderer = ServiceLocator::TryGet()) + { + bool suppress = m_SuppressNextUIInput; + m_SuppressNextUIInput = false; + uiRenderer->ProcessInput(scene.get(), suppress); + } + + auto* scriptEngine = ServiceLocator::TryGet(); + if (scriptEngine && scriptEngine->GetHost().IsInitialized() && scriptEngine->CanExecuteFrameScripts()) + { + scene->OnUpdateRuntime(ts); + } + + if (!scene->GetPendingScenePath().empty()) + { + std::string path = scene->GetPendingScenePath(); + scene->ClearPendingScenePath(); + m_SceneManager->OpenScene(path); + } + } + else if (scene->GetSceneState() == SceneState::Simulate) + { + scene->OnUpdateSimulation(ts); + + if (!scene->GetPendingScenePath().empty()) + { + std::string path = scene->GetPendingScenePath(); + scene->ClearPendingScenePath(); + m_SceneManager->OpenScene(path); + } + } + else + { + scene->OnUpdateEditor(ts); + + if (m_Config.AutoSaveEnabled) + { + m_SceneManager->AutoSave(m_Config.AutoSaveInterval, ts); + } + } + } + } + + void EditorLayer::OnRender(Timestep ts) + { + GraphicsDevice::Get().Clear({25, 25, 25, 255}); + } + + void EditorLayer::OnImGuiRender() + { + // ImGuizmo context sync only — BeginFrame() is already called in ImGuiLayer::Begin() + ImGuizmo::SetImGuiContext(ImGui::GetCurrentContext()); + + if (GetEditorState().NeedsLayoutReset) + { + ResetLayout(); + GetEditorState().NeedsLayoutReset = false; + } + + bool hasProject = Project::GetActive() != nullptr; + + if (hasProject && GetEditorState().FullscreenGame) + { + if (auto viewportPanel = m_Panels->Get()) + { + viewportPanel->OnImGuiRender(true); + } + } + else if (hasProject) + { + m_Layout->OnImGuiRender(); + } + else + { + m_ProjectSelectorUI->OnImGuiRender(); + } + + if (m_SceneManager->IsLoading()) + { + DrawLoadingOverlay("Editor Busy", m_SceneManager->GetLoadingStatus().c_str()); + } + } + + void EditorLayer::ResetLayout() + { + m_Layout->ResetLayout(); + } + + // Project and Scene event handlers are now managed by EditorProjectManager and EditorSceneManager. + + std::shared_ptr EditorLayer::GetActiveScene() const + { + return m_SceneManager->GetActiveScene(); + } + + void EditorLayer::OnEvent(Event& e) + { + if (auto scene = GetActiveScene()) + { + scene->OnEvent(e); + } + + // Dispatch events to all editor panels + m_Panels->OnEvent(e); + + EventDispatcher dispatcher(e); + + // 1. Scene Management + dispatcher.Dispatch([this](auto& e) { return m_SceneManager->OnSceneOpened(e); }); + dispatcher.Dispatch([this](auto& e) { + m_SceneManager->SetSceneState(SceneState::Play); + return true; + }); + dispatcher.Dispatch([this](auto& e) { + m_SceneManager->SetSceneState(SceneState::Simulate); + return true; + }); + dispatcher.Dispatch([this](auto& e) { + m_SceneManager->SetSceneState(SceneState::Edit); + return true; + }); + dispatcher.Dispatch([this](auto& e) { + m_SceneManager->MarkSceneDirty(); + return true; + }); + + // 2. Project Management + dispatcher.Dispatch([this](auto& e) { return m_ProjectManager->OnProjectOpened(e); }); + dispatcher.Dispatch([this](auto& e) { + if (GetSceneState() != SceneState::Play) + { + m_SceneManager->SetSceneState(SceneState::Play); + } + else + { + m_SceneManager->SetSceneState(SceneState::Edit); + } + return true; + }); + + // 3. Keyboard shortcuts + dispatcher.Dispatch([this](KeyPressedEvent& e) { return HandleKeyboardShortcut(e); }); + + // 4. Layout/System + dispatcher.Dispatch([this](auto& ev) { + ResetLayout(); + return true; + }); + dispatcher.Dispatch([this](auto& ev) { + std::filesystem::path scenePath = ev.GetPath(); + if (scenePath.is_relative() && Project::GetActive()) + { + scenePath = Project::GetActive()->GetAssetPath(ev.GetPath()); + } + m_PendingSceneTransitionPath = scenePath.string(); + return true; + }); + + // 5. Selections/Picking + dispatcher.Dispatch([this](auto& ev) { + if (Scene* scene = ev.GetScene()) + { + SetSelectedEntity(Entity(ev.GetEntity(), &scene->GetRegistry())); + } + GetEditorState().LastHitMeshIndex = ev.GetMeshIndex(); + return false; + }); + + // 6. Raw Input Overrides + if (e.GetEventType() == EventType::KeyPressed) + { + auto& ke = (KeyPressedEvent&)e; + if (ke.GetKeyCode() == KeyCode::Escape && GetEditorState().FullscreenGame) + { + GetEditorState().FullscreenGame = false; + e.Handled = true; + } + else if (ke.GetKeyCode() == KeyCode::F11) + { + Application::Get().GetWindow().ToggleFullscreen(); + e.Handled = true; + } + } + } + + bool EditorLayer::HandleKeyboardShortcut(KeyPressedEvent& e) + { + if (e.IsRepeat()) + { + return false; + } + + bool ctrl = Core::Input::IsKeyDown(KeyCode::LeftControl) || Core::Input::IsKeyDown(KeyCode::RightControl); + bool shift = Core::Input::IsKeyDown(KeyCode::LeftShift) || Core::Input::IsKeyDown(KeyCode::RightShift); + auto keyCode = e.GetKeyCode(); + + if (ctrl) + { + switch (keyCode) + { + case KeyCode::N: + if (GetSceneState() != SceneState::Play) + { + m_SceneManager->NewScene(); + } + return true; + case KeyCode::O: + if (GetSceneState() != SceneState::Play) + { + m_SceneManager->OpenScene(); + } + return true; + case KeyCode::S: + if (GetSceneState() != SceneState::Play) + { + shift ? m_SceneManager->SaveSceneAs() : m_SceneManager->SaveScene(); + } + return true; + case KeyCode::Z: + if (GetSceneState() != SceneState::Play) + { + m_CommandHistory.Undo(); + } + return true; + case KeyCode::Y: + if (GetSceneState() != SceneState::Play) + { + m_CommandHistory.Redo(); + } + return true; + } + } + + if (keyCode == KeyCode::F5) + { + m_ProjectManager->LaunchStandalone(m_SceneManager->GetActiveScene()); + return true; + } + + return false; + } + + CommandHistory& EditorLayer::GetCommandHistory() + { + return m_CommandHistory; + } + +} // namespace Chained diff --git a/editor/layer.h b/editor/layer.h new file mode 100644 index 000000000..dd548b097 --- /dev/null +++ b/editor/layer.h @@ -0,0 +1,168 @@ +#ifndef CH_EDITOR_LAYER_H +#define CH_EDITOR_LAYER_H + +#include +#include +#include "imgui.h" + +#include "engine/scene/scene.h" +#include "editor/project_manager.h" +#include "editor/types.h" +#include "editor/scene_manager.h" +#include "engine/app/application.h" +#include "engine/core/layer.h" +#include "editor/layout.h" +#include "editor/panels.h" +#include "editor/undo/command_history.h" + +namespace Chained +{ + + class ProjectSelectorUI; + class EditorMenu; + class KeyPressedEvent; + class FontManager; + class Framebuffer; + class SceneRenderer; + + class EditorLayer : public Layer + { + public: + static EditorLayer& Get() + { + return *s_Instance; + } + + EditorLayer(); + virtual ~EditorLayer(); + + virtual void OnAttach() override; + virtual void OnDetach() override; + virtual void OnUpdate(Timestep ts) override; + virtual void OnRender(Timestep ts) override; + virtual void OnImGuiRender() override; + virtual void OnEvent(Event& e) override; + + void ResetLayout(); + + EditorSceneManager& GetSceneManager() + { + return *m_SceneManager; + } + EditorProjectManager& GetProjectManager() + { + return *m_ProjectManager; + } + + Entity GetSelectedEntity() const + { + return m_EditorState.SelectedEntity; + } + void SetSelectedEntity(Entity entity) + { + m_EditorState.SelectedEntity = entity; + } + + DebugRenderFlags& GetDebugRenderFlags() + { + return m_EditorState.DebugRenderFlags; + } + EditorState& GetEditorState() + { + return m_EditorState; + } + + SceneState GetSceneState() const + { + return m_SceneManager->GetSceneState(); + } + void SetSceneState(SceneState state) + { + m_SceneManager->SetSceneState(state); + } + + private: + void LoadEditorFonts(); + void DrawLoadingOverlay(const char* title, const char* status); + bool HandleKeyboardShortcut(KeyPressedEvent& e); + + public: + CommandHistory& GetCommandHistory(); + EditorPanels& GetPanels() + { + return *m_Panels; + } + EditorMenu& GetMenu() + { + return *m_Menu; + } + + ImVec2 GetViewportSize() const + { + return m_ViewportSize; + } + ImVec2& GetViewportSizeRef() + { + return m_ViewportSize; + } + void OnViewportResized(const ImVec2& size) + { + m_ViewportSize = size; + } + void SetLastScenePath(const std::string& path) + { + m_Config.LastScenePath = path; + } + + void LoadConfig(); + void SaveConfig(); + const EditorConfig& GetConfig() const + { + return m_Config; + } + EditorConfig& GetConfig() + { + return m_Config; + } + + void ReloadEditorFonts(); + void RequestEditorFontReload(); + FontManager& GetFontManager() + { + return *m_FontManager; + } + + std::shared_ptr GetActiveScene() const; + EditorLayout* GetLayout() const + { + return m_Layout.get(); + } + + private: + EditorConfig m_Config; + EditorState m_EditorState; + + std::unique_ptr m_Layout; + std::unique_ptr m_Panels; + std::unique_ptr m_ProjectManager; + std::unique_ptr m_SceneManager; + std::unique_ptr m_ProjectSelectorUI; + std::unique_ptr m_Menu; + std::unique_ptr m_FontManager; + CommandHistory m_CommandHistory; + + std::string m_PendingSceneTransitionPath; + ImVec2 m_ViewportSize = {1280, 720}; + + // Tracks the scene state seen on the previous frame so we can detect the + // Edit->Play transition. On the first Play frame we suppress UI input so the + // physical mouse click that pressed the Play toolbar button (still reported + // as IsMouseClicked this frame) does not leak through to game widgets. + SceneState m_PrevSceneState = SceneState::Edit; + bool m_SuppressNextUIInput = false; + + static inline EditorLayer* s_Instance = nullptr; + }; +} // namespace Chained + +#endif // CH_EDITOR_LAYER_H \ No newline at end of file diff --git a/editor/layout.cpp b/editor/layout.cpp new file mode 100644 index 000000000..8db104a51 --- /dev/null +++ b/editor/layout.cpp @@ -0,0 +1,130 @@ +#include "layer.h" +#include "editor_menu.h" +#include "layout.h" + +#include "gui.h" +#include "imgui.h" +#include "imgui_internal.h" +#include "engine/core/log.h" +#include + +namespace Chained +{ + + constexpr float kLeftDockRatio = 0.20f; + + EditorLayout::EditorLayout(EditorPanels& panels) + : m_Panels(panels) + { + // Rebuild the default DockBuilder arrangement only when no saved layout exists. + // If imgui.ini is present, honor it so the user's arrangement persists across launches. + const char* iniPath = ImGui::GetIO().IniFilename; + m_NeedsRebuild = !(iniPath != nullptr && std::filesystem::exists(iniPath)); + } + + void EditorLayout::ResetLayout() + { + // Delete saved layout and rebuild the default DockBuilder arrangement + const char* iniPath = ImGui::GetIO().IniFilename; + if (iniPath != nullptr && std::filesystem::exists(iniPath)) + { + std::filesystem::remove(iniPath); + } + m_NeedsRebuild = true; + } + + void EditorLayout::OnImGuiRender() + { + static bool dockspaceOpen = true; + static ImGuiDockNodeFlags dockspace_flags = ImGuiDockNodeFlags_PassthruCentralNode; + ImGuiWindowFlags window_flags = ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoDocking; + + ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(viewport->WorkPos); + ImGui::SetNextWindowSize(viewport->WorkSize); + ImGui::SetNextWindowViewport(viewport->ID); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + window_flags |= ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove; + window_flags |= ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus; + + if (dockspace_flags & ImGuiDockNodeFlags_PassthruCentralNode) + { + window_flags |= ImGuiWindowFlags_NoBackground; + } + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::Begin("MainDockSpaceWindow", &dockspaceOpen, window_flags); + ImGui::PopStyleVar(); + ImGui::PopStyleVar(2); + + ImGuiIO& io = ImGui::GetIO(); + if (io.ConfigFlags & ImGuiConfigFlags_DockingEnable) + { + m_DockSpaceID = ImGui::GetID("MyDockSpace"); + + if (m_NeedsRebuild) + { + m_NeedsRebuild = false; + + ImGui::DockBuilderRemoveNode(m_DockSpaceID); + ImGui::DockBuilderAddNode(m_DockSpaceID, dockspace_flags | ImGuiDockNodeFlags_DockSpace); + ImGui::DockBuilderSetNodeSize(m_DockSpaceID, viewport->WorkSize); + + ImGuiID dock_main_id = m_DockSpaceID; + + // Layout: + // dock_left: Scene Hierarchy, World Settings below it + // dock_right: Inspector, Material Editor + // dock_bottom: Content Browser, Console + // Center: Viewport + + // Build the layout splits + ImGuiID dock_left = + ImGui::DockBuilderSplitNode(dock_main_id, ImGuiDir_Left, kLeftDockRatio, nullptr, &dock_main_id); + ImGuiID dock_right = + ImGui::DockBuilderSplitNode(dock_main_id, ImGuiDir_Right, 0.25f, nullptr, &dock_main_id); + ImGuiID dock_bottom = + ImGui::DockBuilderSplitNode(dock_main_id, ImGuiDir_Down, 0.25f, nullptr, &dock_main_id); + ImGuiID dock_left_bottom = + ImGui::DockBuilderSplitNode(dock_left, ImGuiDir_Down, 0.5f, nullptr, &dock_left); + + // Assign windows to locations + ImGui::DockBuilderDockWindow("Scene Hierarchy", dock_left); + ImGui::DockBuilderDockWindow("World Settings", dock_left_bottom); + + ImGui::DockBuilderDockWindow("Inspector", dock_right); + ImGui::DockBuilderDockWindow("Material Editor", dock_right); // grouped with Inspector + ImGui::DockBuilderDockWindow("Network", dock_right); // grouped with Inspector + + ImGui::DockBuilderDockWindow("Content Browser", dock_bottom); + ImGui::DockBuilderDockWindow("Console", dock_bottom); // grouped with Content Browser + ImGui::DockBuilderDockWindow("Animation Graph", dock_bottom); // grouped with Content Browser + ImGui::DockBuilderDockWindow("Effects & Debug", dock_bottom); // grouped with Content Browser + ImGui::DockBuilderDockWindow("Profiler", dock_bottom); // grouped with Content Browser + + ImGui::DockBuilderDockWindow("Project Settings", dock_left_bottom); // grouped with World Settings + + ImGui::DockBuilderDockWindow("Viewport", dock_main_id); // remainder + + ImGui::DockBuilderFinish(m_DockSpaceID); + } + + ImGui::DockSpace(m_DockSpaceID, ImVec2(0.0f, 0.0f), dockspace_flags); + } + + auto& menu = EditorLayer::Get().GetMenu(); + menu.DrawMenuBar(m_Panels); + + bool readOnly = EditorLayer::Get().GetSceneState() == SceneState::Play; + m_Panels.OnImGuiRender(readOnly); + + menu.DrawEditorSettings(); + menu.DrawExportDialog(); + menu.DrawExportProgressOverlay(); + + ImGui::End(); + } + +} // namespace Chained diff --git a/editor/layout.h b/editor/layout.h new file mode 100644 index 000000000..3c2d7cb7b --- /dev/null +++ b/editor/layout.h @@ -0,0 +1,28 @@ +#ifndef CH_EDITOR_LAYOUT_H +#define CH_EDITOR_LAYOUT_H + +#include "editor/panels.h" +#include +#include + +namespace Chained +{ + + class EditorLayout + { + public: + EditorLayout(EditorPanels& panels); + + void ResetLayout(); + + void OnImGuiRender(); + + private: + EditorPanels& m_Panels; + uint32_t m_DockSpaceID = 0; + bool m_NeedsRebuild = true; + }; + +} // namespace Chained + +#endif // CH_EDITOR_LAYOUT_H diff --git a/editor/main.cpp b/editor/main.cpp new file mode 100644 index 000000000..31d53bd97 --- /dev/null +++ b/editor/main.cpp @@ -0,0 +1,31 @@ +#include "engine/app/entry_point.h" +#include "engine/core/platform.h" +#include "layer.h" + +namespace Chained +{ + Application* CreateApplication(ApplicationCommandLineArgs args) + { + ApplicationSpecification spec; + spec.Name = "ChainedEditor"; + spec.CommandLineArgs = args; + spec.Headless = false; + + // Default editor window settings + spec.Window.Width = 0; + spec.Window.Height = 0; + spec.Window.Fullscreen = false; + spec.EnableScripting = true; + + // Set engine root to the executable directory so AssetManager can find + // resources/shaders, resources/icons, resources/font etc. + spec.EngineRoot = Platform::GetExecutableDirectory(); + spec.WorkingDirectory = Platform::GetExecutableDirectory().string(); + + auto* app = new Application(spec); + + app->PushLayer(std::make_unique()); + + return app; + } +} // namespace Chained diff --git a/editor/panels.cpp b/editor/panels.cpp new file mode 100644 index 000000000..5a538578e --- /dev/null +++ b/editor/panels.cpp @@ -0,0 +1,85 @@ +#include "panels.h" +#include "layer.h" +#include "panels/console_panel.h" +#include "panels/content_browser_panel.h" +#include "panels/world_panel.h" +#include "panels/effects_panel.h" +#include "panels/material_panel.h" +#include "panels/inspector_panel.h" +#include "panels/panel.h" +#include "panels/profiler_panel.h" +#include "panels/project_settings_panel.h" +#include "panels/scene_hierarchy_panel.h" +#include "panels/viewport_panel.h" +#include "panels/anim_graph_panel.h" +#include "panels/network_panel.h" + +namespace Chained +{ + + void EditorPanels::Init() + { + Register(EditorLayer::Get().GetViewportSizeRef()); + Register(); + Register(); + Register(); + Register(); + Register(); + Register(); + Register(); + Register(); + Register(); + Register(); + Register(); + } + + void EditorPanels::OnUpdate(Timestep ts) + { + for (auto& panel : m_Panels) + { + if (!panel->IsPendingKill()) + { + panel->OnUpdate(ts); + } + } + + m_Panels.erase(std::remove_if(m_Panels.begin(), m_Panels.end(), + [](const std::shared_ptr& panel) { return panel->IsPendingKill(); }), + m_Panels.end()); + } + + void EditorPanels::OnImGuiRender(bool readOnly) + { + for (auto& panel : m_Panels) + { + if (!panel->IsPendingKill()) + { + panel->OnImGuiRender(readOnly); + } + } + } + + void EditorPanels::OnEvent(Event& e) + { + for (auto& panel : m_Panels) + { + if (e.Handled) + { + break; + } + if (!panel->IsPendingKill()) + { + panel->OnEvent(e); + } + } + } + + void EditorPanels::SetContext(const std::shared_ptr& context) + { + for (auto& panel : m_Panels) + { + panel->SetContext(context); + } + } + +} // namespace Chained diff --git a/editor/panels.h b/editor/panels.h new file mode 100644 index 000000000..17f9804ff --- /dev/null +++ b/editor/panels.h @@ -0,0 +1,76 @@ +#ifndef CH_EDITOR_PANELS_H +#define CH_EDITOR_PANELS_H + +#include "engine/common/timestep.h" +#include "panels/panel.h" +#include +#include +#include + +namespace Chained +{ + + class EditorPanels + { + public: + EditorPanels() = default; + ~EditorPanels() = default; + + void Init(); + + template std::shared_ptr Register(Args&&... args) + { + auto panel = std::make_shared(std::forward(args)...); + m_Panels.push_back(panel); + return panel; + } + + template std::shared_ptr Get() + { + for (auto& panel : m_Panels) + { + if (auto cast = std::dynamic_pointer_cast(panel)) + { + return cast; + } + } + return nullptr; + } + + std::shared_ptr Get(const std::string& name) + { + for (auto& panel : m_Panels) + { + if (panel->GetName() == name) + { + return panel; + } + } + return nullptr; + } + + template void ForEach(F&& func) + { + for (auto& panel : m_Panels) + { + func(panel); + } + } + + void OnUpdate(Timestep ts); + void OnImGuiRender(bool readOnly); + void OnEvent(Event& e); + void SetContext(const std::shared_ptr& context); + + std::vector>& GetPanels() + { + return m_Panels; + } + + private: + std::vector> m_Panels; + }; + +} // namespace Chained + +#endif // CH_EDITOR_PANELS_H diff --git a/editor/panels/anim_graph_panel.cpp b/editor/panels/anim_graph_panel.cpp new file mode 100644 index 000000000..1b859383b --- /dev/null +++ b/editor/panels/anim_graph_panel.cpp @@ -0,0 +1,1079 @@ +#include +#include +#include "engine/assets/types/animation_graph_asset.h" +#include "engine/scene/components/animation/animation_component.h" +#include "engine/scene/components/render/model_component.h" +#include "engine/assets/asset_manager.h" +#include "engine/assets/types/model_asset.h" +#include "engine/assets/loaders/anim_graph_loader.h" +#include "engine/core/service_locator.h" +#include "thirdparty/IconsFontAwesome6.h" +#include "editor/panels/anim_graph_panel.h" +#include "editor/layer.h" + +namespace Chained +{ + + static const char* s_InputNames[] = {"In"}; + static const char* s_OutputNames[] = {"Out"}; + + static const GraphEditor::Template s_Templates[] = { + // 0: Entry node — gold header, no inputs, 1 output + {IM_COL32(255, 200, 0, 255), IM_COL32(60, 60, 40, 255), IM_COL32(80, 80, 50, 255), 0, nullptr, nullptr, 1, + s_OutputNames, nullptr}, + // 1: State node — blue header, 1 input, 1 output + {IM_COL32(100, 150, 200, 255), IM_COL32(60, 80, 100, 255), IM_COL32(70, 90, 110, 255), 1, s_InputNames, nullptr, + 1, s_OutputNames, nullptr}}; + + // ── Delegate ────────────────────────────────────────────────────── + + void AnimGraphPanel::Delegate::SyncSelection() + { + if (!graph) + { + nodeSelected.clear(); + return; + } + nodeSelected.resize(graph->Nodes.size(), false); + } + + bool AnimGraphPanel::Delegate::AllowedLink(GraphEditor::NodeIndex from, GraphEditor::NodeIndex to) + { + return from != to; + } + + void AnimGraphPanel::Delegate::SelectNode(GraphEditor::NodeIndex nodeIndex, bool selected) + { + if (nodeIndex < nodeSelected.size()) + { + nodeSelected[nodeIndex] = selected; + } + + // Override/restore animation preview in main viewport + if (panel) + { + Entity entity = EditorLayer::Get().GetSelectedEntity(); + if (selected) + { + panel->ApplyPreview(graph, (int)nodeIndex, entity); + } + else + { + panel->RestorePreview(); + } + } + } + + void AnimGraphPanel::Delegate::MoveSelectedNodes(const ImVec2 delta) + { + if (!graph) + { + return; + } + for (size_t i = 0; i < graph->Nodes.size(); i++) + { + if (i < nodeSelected.size() && nodeSelected[i]) + { + graph->Nodes[i].EditorX += delta.x; + graph->Nodes[i].EditorY += delta.y; + } + } + } + + void AnimGraphPanel::Delegate::AddLink(GraphEditor::NodeIndex inputNodeIndex, GraphEditor::SlotIndex, + GraphEditor::NodeIndex outputNodeIndex, GraphEditor::SlotIndex) + { + if (!graph) + { + return; + } + if (inputNodeIndex >= graph->Nodes.size() || outputNodeIndex >= graph->Nodes.size()) + { + return; + } + + AnimTransition tr; + tr.ID = graph->NextLinkID++; + tr.SourceNodeID = graph->Nodes[outputNodeIndex].ID; + tr.TargetNodeID = graph->Nodes[inputNodeIndex].ID; + tr.BlendDuration = 0.2f; + graph->Transitions.push_back(tr); + if (changedFlag) + { + *changedFlag = true; + } + } + + void AnimGraphPanel::Delegate::DelLink(GraphEditor::LinkIndex linkIndex) + { + if (!graph || linkIndex >= graph->Transitions.size()) + { + return; + } + graph->Transitions.erase(graph->Transitions.begin() + linkIndex); + if (changedFlag) + { + *changedFlag = true; + } + } + + void AnimGraphPanel::Delegate::RightClick(GraphEditor::NodeIndex, GraphEditor::SlotIndex, GraphEditor::SlotIndex) + { + // Because Delegate is abstract class , but don`t implement any right now. + } + + void AnimGraphPanel::Delegate::CustomDraw(ImDrawList* drawList, ImRect rectangle, GraphEditor::NodeIndex nodeIndex) + { + if (!graph || nodeIndex >= graph->Nodes.size()) + { + return; + } + + auto& node = graph->Nodes[nodeIndex]; + ImVec2 textPos = rectangle.Min + ImVec2(8, 28); + + if (node.ID == graph->EntryNodeID) + { + drawList->AddText(textPos, IM_COL32(255, 220, 80, 255), "[Entry]"); + textPos.y += 16; + } + else + { + char buf[64]; + snprintf(buf, sizeof(buf), "Anim: %d", node.AnimationIndex); + drawList->AddText(textPos, IM_COL32(200, 200, 200, 200), buf); + textPos.y += 16; + } + + // Frame range + char rangeBuf[64]; + if (node.EndFrame < 0) + { + snprintf(rangeBuf, sizeof(rangeBuf), "Frames: %d-end", node.StartFrame); + } + else + { + snprintf(rangeBuf, sizeof(rangeBuf), "Frames: %d-%d", node.StartFrame, node.EndFrame); + } + drawList->AddText(textPos, IM_COL32(160, 160, 160, 200), rangeBuf); + textPos.y += 14; + + // Speed + if (node.Speed != 1.0f) + { + char spdBuf[32]; + snprintf(spdBuf, sizeof(spdBuf), "Speed: %.1fx", node.Speed); + drawList->AddText(textPos, IM_COL32(180, 220, 180, 200), spdBuf); + } + } + + const size_t AnimGraphPanel::Delegate::GetTemplateCount() + { + return 2; + } + + const GraphEditor::Template AnimGraphPanel::Delegate::GetTemplate(GraphEditor::TemplateIndex index) + { + if (index < 2) + { + return s_Templates[index]; + } + return s_Templates[1]; + } + + const size_t AnimGraphPanel::Delegate::GetNodeCount() + { + return graph ? graph->Nodes.size() : 0; + } + + const GraphEditor::Node AnimGraphPanel::Delegate::GetNode(GraphEditor::NodeIndex index) + { + if (!graph || index >= graph->Nodes.size()) + { + return {}; + } + + auto& node = graph->Nodes[index]; + GraphEditor::TemplateIndex tpl = (node.ID == graph->EntryNodeID) ? 0 : 1; + bool sel = (index < nodeSelected.size()) ? nodeSelected[index] : false; + + return {node.Name.c_str(), tpl, + ImRect(ImVec2(node.EditorX, node.EditorY), ImVec2(node.EditorX + 180, node.EditorY + 80)), sel}; + } + + const size_t AnimGraphPanel::Delegate::GetLinkCount() + { + return graph ? graph->Transitions.size() : 0; + } + + const GraphEditor::Link AnimGraphPanel::Delegate::GetLink(GraphEditor::LinkIndex index) + { + if (!graph || index >= graph->Transitions.size()) + { + return {}; + } + + auto& tr = graph->Transitions[index]; + + auto findIndex = [&](int nodeID) -> GraphEditor::NodeIndex { + for (size_t i = 0; i < graph->Nodes.size(); i++) + { + if (graph->Nodes[i].ID == nodeID) + { + return i; + } + } + return 0; + }; + + return {findIndex(tr.TargetNodeID), 0, findIndex(tr.SourceNodeID), 0}; + } + + // ── Panel ───────────────────────────────────────────────────────── + + AnimGraphPanel::AnimGraphPanel() + { + m_Name = "Animation Graph"; + m_Delegate.panel = this; + } + + AnimGraphPanel::~AnimGraphPanel() + { + RestorePreview(); + } + + void AnimGraphPanel::OnImGuiRender(bool readOnly) + { + if (!m_IsOpen) + { + return; + } + + ImGui::Begin("Animation Graph", &m_IsOpen); + + Entity selectedEntity = EditorLayer::Get().GetSelectedEntity(); + + // Restore preview if entity changed + if (m_PreviewNodeIdx >= 0 && m_PreviewEntity != selectedEntity) + { + RestorePreview(); + } + + if (!selectedEntity || !selectedEntity.HasComponent()) + { + ImGui::Text("Select an entity with an AnimationComponent."); + ImGui::End(); + return; + } + + if (!selectedEntity.HasComponent()) + { + ImGui::Text("Entity must have a ModelComponent."); + ImGui::End(); + return; + } + + auto& animComp = selectedEntity.GetComponent(); + + AnimationGraphAsset* graph = nullptr; + if (!animComp.GraphPath.empty()) + { + auto* assets = ServiceLocator::TryGet(); + if (assets) + { + auto asset = assets->Get(animComp.GraphPath); + if (asset) + { + graph = asset.get(); + } + } + } + + if (!graph) + { + RestorePreview(); + ImGui::TextDisabled("No animation graph loaded."); + + // Generate unique name for entity + std::string baseName = "anim_graph"; + if (selectedEntity.HasComponent()) + { + std::string tag = selectedEntity.GetComponent().Tag; + if (!tag.empty()) + { + std::string cleanTag = tag; + std::replace(cleanTag.begin(), cleanTag.end(), ' ', '_'); + std::replace(cleanTag.begin(), cleanTag.end(), '/', '_'); + std::replace(cleanTag.begin(), cleanTag.end(), '\\', '_'); + std::replace(cleanTag.begin(), cleanTag.end(), '#', '_'); + std::transform(cleanTag.begin(), cleanTag.end(), cleanTag.begin(), ::tolower); + baseName = cleanTag + "_graph"; + } + } + else if (selectedEntity.HasComponent()) + { + std::string mPath = selectedEntity.GetComponent().ModelPath; + if (!mPath.empty()) + { + baseName = std::filesystem::path(mPath).stem().string() + "_graph"; + } + } + + auto* assets = ServiceLocator::TryGet(); + std::string uniqueGraphPath = "animations/" + baseName + ".chag"; + if (assets) + { + int counter = 1; + while (std::filesystem::exists(assets->ResolvePath(uniqueGraphPath))) + { + uniqueGraphPath = "animations/" + baseName + "_" + std::to_string(counter++) + ".chag"; + } + } + + if (ImGui::Button("Create New Graph")) + { + animComp.GraphPath = uniqueGraphPath; + animComp.GraphAssetHandle = 0; + m_ChangedGraph = true; + + AnimationGraphAsset newGraph; + SaveGraph(&newGraph, animComp.GraphPath); + if (assets) + { + assets->Invalidate(animComp.GraphPath); + } + } + ImGui::End(); + return; + } + + // Toolbar + { + ImVec4 btnColor = m_ChangedGraph ? ImVec4(0.8f, 0.4f, 0.1f, 1.0f) : ImVec4(0.3f, 0.3f, 0.3f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_Button, btnColor); + if (ImGui::Button(ICON_FA_FLOPPY_DISK " Save")) + { + SaveGraph(graph, animComp.GraphPath); + m_ChangedGraph = false; + } + ImGui::PopStyleColor(); + + ImGui::SameLine(); + if (ImGui::Button(ICON_FA_COPY " Clone Graph")) + { + auto* assets = ServiceLocator::TryGet(); + if (assets && !animComp.GraphPath.empty()) + { + std::filesystem::path p(animComp.GraphPath); + std::string newPath = (p.parent_path() / (p.stem().string() + "_copy.chag")).generic_string(); + int counter = 1; + while (std::filesystem::exists(assets->ResolvePath(newPath))) + { + newPath = + (p.parent_path() / (p.stem().string() + "_copy" + std::to_string(counter++) + ".chag")) + .generic_string(); + } + SaveGraph(graph, newPath); + animComp.GraphPath = newPath; + animComp.GraphAssetHandle = 0; + assets->Invalidate(newPath); + m_ChangedGraph = false; + } + } + + ImGui::SameLine(); + if (ImGui::Button("Add State")) + { + AnimNode newNode; + newNode.ID = graph->NextNodeID++; + newNode.Name = "State " + std::to_string(newNode.ID); + newNode.EditorX = 100.0f + (graph->Nodes.size() % 3) * 220.0f; + newNode.EditorY = 100.0f + (graph->Nodes.size() / 3) * 120.0f; + graph->Nodes.push_back(newNode); + m_Delegate.nodeSelected.push_back(false); + m_ChangedGraph = true; + } + + ImGui::SameLine(); + if (ImGui::Button("Fit All")) + { + m_Fit = GraphEditor::Fit_AllNodes; + } + + ImGui::SameLine(); + if (ImGui::Button("Fit Selected")) + { + m_Fit = GraphEditor::Fit_SelectedNodes; + } + + ImGui::SameLine(); + ImGui::TextDisabled("File: %s", animComp.GraphPath.c_str()); + } + + // Graph editor | Properties side-by-side + m_Delegate.graph = graph; + m_Delegate.changedFlag = &m_ChangedGraph; + m_Delegate.SyncSelection(); + + float avail = ImGui::GetContentRegionAvail().x; + float graphWidth = avail * 0.72f; + + ImGui::BeginChild("GraphEditor", ImVec2(graphWidth, 0)); + GraphEditor::Show(m_Delegate, m_Options, m_ViewState, true, &m_Fit); + ImGui::EndChild(); + + ImGui::SameLine(); + + ImGui::BeginChild("Properties", ImVec2(0, 0)); + DrawProperties(graph, selectedEntity); + ImGui::EndChild(); + + ImGui::End(); + } + + void AnimGraphPanel::RestorePreview() + { + if (!m_PreviewEntity || !m_PreviewEntity.HasComponent()) + { + return; + } + auto& anim = m_PreviewEntity.GetComponent(); + anim.CurrentAnimationIndex = m_OrigAnimIndex; + anim.CurrentFrame = m_OrigFrame; + anim.StartFrame = m_OrigStartFrame; + anim.EndFrame = m_OrigEndFrame; + anim.Speed = m_OrigSpeed; + anim.IsLooping = m_OrigIsLooping; + anim.IsPlaying = m_OrigIsPlaying; + m_PreviewNodeIdx = -1; + } + + void AnimGraphPanel::ApplyPreview(AnimationGraphAsset* graph, int nodeIdx, Entity entity) + { + if (!graph || nodeIdx < 0 || nodeIdx >= (int)graph->Nodes.size()) + { + return; + } + if (!entity || !entity.HasComponent()) + { + return; + } + + auto& anim = entity.GetComponent(); + auto& node = graph->Nodes[nodeIdx]; + + // Save original state + m_OrigAnimIndex = anim.CurrentAnimationIndex; + m_OrigFrame = anim.CurrentFrame; + m_OrigStartFrame = anim.StartFrame; + m_OrigEndFrame = anim.EndFrame; + m_OrigSpeed = anim.Speed; + m_OrigIsLooping = anim.IsLooping; + m_OrigIsPlaying = anim.IsPlaying; + m_PreviewEntity = entity; + + // Override with node's animation + anim.CurrentNodeID = node.ID; + anim.CurrentAnimationIndex = node.AnimationIndex; + anim.StartFrame = node.StartFrame; + anim.EndFrame = node.EndFrame; + anim.Speed = (node.Speed > 0.0f) ? node.Speed : 1.0f; + anim.IsLooping = node.IsLooping; + anim.CurrentFrame = node.StartFrame; + anim.IsPlaying = true; + + m_PreviewNodeIdx = nodeIdx; + } + + void AnimGraphPanel::DrawProperties(AnimationGraphAsset* graph, Entity entity) + { + if (!entity || !entity.HasComponent()) + { + return; + } + auto& animComp = entity.GetComponent(); + + ImGui::Text("Is Playing"); + ImGui::SameLine(120); + ImGui::Checkbox("##isPlaying", &animComp.IsPlaying); + + ImGui::Separator(); + + ImGui::Text("Properties"); + ImGui::Separator(); + + // Find first selected node + int selectedIdx = -1; + for (size_t i = 0; i < m_Delegate.nodeSelected.size(); i++) + { + if (m_Delegate.nodeSelected[i]) + { + selectedIdx = (int)i; + break; + } + } + + if (selectedIdx != -1 && selectedIdx < (int)graph->Nodes.size()) + { + AnimNode& node = graph->Nodes[selectedIdx]; + + // Keep entity preview synced to selected node in Edit mode + bool isSimulation = EditorLayer::Get().GetSceneState() != SceneState::Edit; + if (!isSimulation) + { + animComp.CurrentNodeID = node.ID; + animComp.CurrentAnimationIndex = node.AnimationIndex; + animComp.StartFrame = node.StartFrame; + animComp.EndFrame = node.EndFrame; + animComp.Speed = (node.Speed > 0.0f) ? node.Speed : 1.0f; + animComp.IsLooping = node.IsLooping; + } + + char buffer[256]; + strncpy(buffer, node.Name.c_str(), sizeof(buffer)); + buffer[sizeof(buffer) - 1] = '\0'; + if (ImGui::InputText("Name", buffer, sizeof(buffer))) + { + node.Name = buffer; + m_ChangedGraph = true; + } + + if (node.ID == graph->EntryNodeID) + { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "Entry Node"); + } + else + { + if (ImGui::Button("Set as Entry")) + { + graph->EntryNodeID = node.ID; + m_ChangedGraph = true; + } + } + + // Animation picker + { + bool foundModel = false; + if (entity.HasComponent()) + { + auto& modelComp = entity.GetComponent(); + auto* assets = ServiceLocator::TryGet(); + if (assets) + { + auto modelAsset = assets->Get(modelComp.ModelPath); + if (modelAsset && modelAsset->GetAnimationCount() > 0) + { + foundModel = true; + int animCount = modelAsset->GetAnimationCount(); + int idx = node.AnimationIndex; + if (idx < 0 || idx >= animCount) + { + idx = 0; + } + + char labelBuf[128]; + std::string animName = modelAsset->GetAnimationName(idx); + if (animName.empty()) + { + snprintf(labelBuf, sizeof(labelBuf), "Animation %d", idx); + } + else + { + snprintf(labelBuf, sizeof(labelBuf), "%s (%d)", animName.c_str(), idx); + } + + if (ImGui::BeginCombo("Animation", labelBuf)) + { + for (int i = 0; i < animCount; i++) + { + bool isSel = (idx == i); + std::string name = modelAsset->GetAnimationName(i); + char itemBuf[128]; + if (name.empty()) + { + snprintf(itemBuf, sizeof(itemBuf), "Animation %d", i); + } + else + { + snprintf(itemBuf, sizeof(itemBuf), "%s (%d)", name.c_str(), i); + } + if (ImGui::Selectable(itemBuf, isSel)) + { + node.AnimationIndex = i; + node.StartFrame = 0; + node.EndFrame = -1; + m_ChangedGraph = true; + } + } + ImGui::EndCombo(); + } + + // Show frame range info + const auto& rawAnims = modelAsset->GetAnimations(); + if (idx >= 0 && idx < (int)rawAnims.size()) + { + int totalFrames = rawAnims[idx].frameCount; + float fps = rawAnims[idx].frameRate; + float duration = (float)totalFrames / fps; + ImGui::TextDisabled("Total: %d frames (%.2fs @ %.0f fps)", totalFrames, duration, fps); + + // Start Frame + int sf = node.StartFrame; + if (ImGui::DragInt("Start Frame", &sf, 1, 0, totalFrames - 1)) + { + node.StartFrame = std::clamp(sf, 0, totalFrames - 1); + if (node.EndFrame >= 0 && node.EndFrame < node.StartFrame) + { + node.EndFrame = node.StartFrame; + } + m_ChangedGraph = true; + } + + // End Frame + int ef = node.EndFrame; + const char* endLabel = (node.EndFrame < 0) ? "End Frame (auto)" : "End Frame"; + if (ImGui::DragInt(endLabel, &ef, 1, -1, totalFrames - 1)) + { + node.EndFrame = ef; + if (node.EndFrame >= 0 && node.EndFrame < node.StartFrame) + { + node.EndFrame = node.StartFrame; + } + m_ChangedGraph = true; + } + + // Duration preview + int endFrame = (node.EndFrame < 0) ? (totalFrames - 1) : node.EndFrame; + int clipLength = endFrame - node.StartFrame + 1; + float clipDuration = (float)clipLength / fps; + float speedDuration = (node.Speed > 0.0f) ? clipDuration / node.Speed : clipDuration; + ImGui::Text("Clip: %d frames (%.2fs)", clipLength, clipDuration); + if (node.Speed != 1.0f) + { + ImGui::Text("Effective: %.2fs (speed %.1fx)", speedDuration, node.Speed); + } + } + } + } + } + if (!foundModel) + { + if (ImGui::InputInt("Animation Index", &node.AnimationIndex)) + { + m_ChangedGraph = true; + } + if (ImGui::InputInt("Start Frame", &node.StartFrame)) + { + m_ChangedGraph = true; + } + if (ImGui::InputInt("End Frame", &node.EndFrame)) + { + m_ChangedGraph = true; + } + } + } + + if (ImGui::Checkbox("Is Looping", &node.IsLooping)) + { + m_ChangedGraph = true; + } + + if (ImGui::DragFloat("Speed", &node.Speed, 0.05f, 0.01f, 10.0f, "%.2f")) + { + m_ChangedGraph = true; + } + + if (m_ChangedGraph) + { + animComp.CurrentNodeID = node.ID; + animComp.CurrentAnimationIndex = node.AnimationIndex; + animComp.StartFrame = node.StartFrame; + animComp.EndFrame = node.EndFrame; + animComp.Speed = (node.Speed > 0.0f) ? node.Speed : 1.0f; + animComp.IsLooping = node.IsLooping; + if (animComp.CurrentFrame < animComp.StartFrame || + (animComp.EndFrame >= 0 && animComp.CurrentFrame > animComp.EndFrame)) + { + animComp.CurrentFrame = animComp.StartFrame; + } + } + + ImGui::Separator(); + if (ImGui::Button("Delete Node")) + { + int nodeId = node.ID; + graph->Nodes.erase(std::remove_if(graph->Nodes.begin(), graph->Nodes.end(), + [nodeId](const AnimNode& n) { return n.ID == nodeId; }), + graph->Nodes.end()); + graph->Transitions.erase(std::remove_if(graph->Transitions.begin(), graph->Transitions.end(), + [nodeId](const AnimTransition& t) { + return t.SourceNodeID == nodeId || t.TargetNodeID == nodeId; + }), + graph->Transitions.end()); + if (graph->EntryNodeID == nodeId) + { + graph->EntryNodeID = -1; + } + m_Delegate.SyncSelection(); + m_ChangedGraph = true; + } + } + else + { + ImGui::TextDisabled("Select a node to edit properties"); + } + + // Transitions section + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Text("Transitions"); + ImGui::Separator(); + + if (ImGui::Button("+ Add Transition")) + { + AnimTransition tr; + tr.ID = graph->NextLinkID++; + tr.BlendDuration = 0.2f; + graph->Transitions.push_back(tr); + m_ChangedGraph = true; + } + + for (size_t i = 0; i < graph->Transitions.size(); i++) + { + AnimTransition& tr = graph->Transitions[i]; + + auto findNodeName = [&](int nodeID) -> std::string { + for (auto& n : graph->Nodes) + { + if (n.ID == nodeID) + { + return n.Name; + } + } + return "Unknown"; + }; + + ImGui::PushID((int)i); + + bool open = ImGui::TreeNode("##tr", "%s -> %s", findNodeName(tr.SourceNodeID).c_str(), + findNodeName(tr.TargetNodeID).c_str()); + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) + { + // Select this transition + } + + if (open) + { + // Source node picker + { + int srcIdx = -1; + for (size_t j = 0; j < graph->Nodes.size(); j++) + { + if (graph->Nodes[j].ID == tr.SourceNodeID) + { + srcIdx = (int)j; + break; + } + } + + char srcLabel[128]; + snprintf(srcLabel, sizeof(srcLabel), "%s###src", + (srcIdx >= 0) ? graph->Nodes[srcIdx].Name.c_str() : "None"); + if (ImGui::BeginCombo("From", srcLabel)) + { + for (size_t j = 0; j < graph->Nodes.size(); j++) + { + bool isSel = ((int)j == srcIdx); + if (ImGui::Selectable(graph->Nodes[j].Name.c_str(), isSel)) + { + tr.SourceNodeID = graph->Nodes[j].ID; + m_ChangedGraph = true; + } + } + ImGui::EndCombo(); + } + } + + // Target node picker + { + int tgtIdx = -1; + for (size_t j = 0; j < graph->Nodes.size(); j++) + { + if (graph->Nodes[j].ID == tr.TargetNodeID) + { + tgtIdx = (int)j; + break; + } + } + + char tgtLabel[128]; + snprintf(tgtLabel, sizeof(tgtLabel), "%s###tgt", + (tgtIdx >= 0) ? graph->Nodes[tgtIdx].Name.c_str() : "None"); + if (ImGui::BeginCombo("To", tgtLabel)) + { + for (size_t j = 0; j < graph->Nodes.size(); j++) + { + bool isSel = ((int)j == tgtIdx); + if (ImGui::Selectable(graph->Nodes[j].Name.c_str(), isSel)) + { + tr.TargetNodeID = graph->Nodes[j].ID; + m_ChangedGraph = true; + } + } + ImGui::EndCombo(); + } + } + + if (ImGui::DragFloat("Blend Duration", &tr.BlendDuration, 0.01f, 0.0f, 5.0f, "%.2f s")) + { + m_ChangedGraph = true; + } + + if (ImGui::Checkbox("Has Exit Time", &tr.HasExitTime)) + { + m_ChangedGraph = true; + } + + if (tr.HasExitTime) + { + if (ImGui::DragFloat("Exit Time", &tr.ExitTime, 0.01f, 0.0f, 1.0f, "%.2f")) + { + m_ChangedGraph = true; + } + } + + // Conditions + ImGui::Text("Conditions:"); + if (ImGui::Button("+ Condition")) + { + AnimCondition cond; + cond.Op = AnimConditionOp::Greater; + tr.Conditions.push_back(cond); + m_ChangedGraph = true; + } + + for (size_t c = 0; c < tr.Conditions.size(); c++) + { + AnimCondition& cond = tr.Conditions[c]; + ImGui::PushID((int)c); + + char varBuf[128]; + strncpy(varBuf, cond.VariableName.c_str(), sizeof(varBuf)); + varBuf[sizeof(varBuf) - 1] = '\0'; + if (ImGui::InputText("Var", varBuf, sizeof(varBuf))) + { + cond.VariableName = varBuf; + m_ChangedGraph = true; + } + + const char* ops[] = {"==", "!=", ">", "<", ">=", "<="}; + int opIdx = (int)cond.Op; + if (ImGui::Combo("Op", &opIdx, ops, IM_ARRAYSIZE(ops))) + { + cond.Op = (AnimConditionOp)opIdx; + m_ChangedGraph = true; + } + + if (ImGui::DragFloat("Value", &cond.Value, 0.01f)) + { + m_ChangedGraph = true; + } + + ImGui::SameLine(); + if (ImGui::SmallButton("X")) + { + tr.Conditions.erase(tr.Conditions.begin() + c); + m_ChangedGraph = true; + ImGui::PopID(); + break; + } + + ImGui::PopID(); + } + + if (ImGui::SmallButton("Delete Transition")) + { + graph->Transitions.erase(graph->Transitions.begin() + i); + m_ChangedGraph = true; + ImGui::TreePop(); + ImGui::PopID(); + break; + } + + ImGui::TreePop(); + } + + ImGui::PopID(); + } + + // Variables section + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Text("Variables"); + ImGui::Separator(); + + // Two buttons: + Float | + Bool + if (ImGui::Button("+ Float")) + { + std::string key = "new_float"; + int idx = 0; + while (animComp.Variables.count(key)) + { + key = "new_float_" + std::to_string(++idx); + } + animComp.Variables[key] = 0.0f; + graph->DefaultVariables[key] = 0.0f; // sync to graph schema + m_VariableTypes[key] = VarType::Float; + m_ChangedGraph = true; + } + ImGui::SameLine(); + if (ImGui::Button("+ Bool")) + { + std::string key = "new_bool"; + int idx = 0; + while (animComp.Variables.count(key)) + { + key = "new_bool_" + std::to_string(++idx); + } + animComp.Variables[key] = 0.0f; + graph->DefaultVariables[key] = 0.0f; // sync to graph schema + m_VariableTypes[key] = VarType::Bool; + m_ChangedGraph = true; + } + + auto it = animComp.Variables.begin(); + while (it != animComp.Variables.end()) + { + ImGui::PushID(it->first.c_str()); + + // Infer type: if not in our map, guess by name + if (m_VariableTypes.find(it->first) == m_VariableTypes.end()) + { + std::string lowerKey = it->first; + std::transform(lowerKey.begin(), lowerKey.end(), lowerKey.begin(), ::tolower); + + bool looksLikeBool = + (lowerKey.rfind("is", 0) == 0 || lowerKey.rfind("has", 0) == 0 || lowerKey.rfind("can", 0) == 0 || + lowerKey.rfind("should", 0) == 0 || lowerKey.find("bool") != std::string::npos || + lowerKey.find("flag") != std::string::npos); + m_VariableTypes[it->first] = looksLikeBool ? VarType::Bool : VarType::Float; + } + + VarType varType = m_VariableTypes[it->first]; + + // Type badge button: clickable [F] or [B] to toggle type + if (varType == VarType::Float) + { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.4f, 0.7f, 1.0f)); + if (ImGui::Button("[F]")) + { + m_VariableTypes[it->first] = VarType::Bool; + } + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Float (Click to switch to Bool)"); + } + } + else + { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.8f, 0.5f, 0.1f, 1.0f)); + if (ImGui::Button("[B]")) + { + m_VariableTypes[it->first] = VarType::Float; + } + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Bool (Click to switch to Float)"); + } + } + ImGui::SameLine(); + + // Name field + char varBuf[128]; + strncpy(varBuf, it->first.c_str(), sizeof(varBuf)); + varBuf[sizeof(varBuf) - 1] = '\0'; + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputText("##name", varBuf, sizeof(varBuf), ImGuiInputTextFlags_EnterReturnsTrue)) + { + float val = it->second; + VarType t = m_VariableTypes[it->first]; + std::string oldKey = it->first; + std::string newKey = varBuf; + if (newKey == oldKey || (!graph->DefaultVariables.count(newKey) && !animComp.Variables.count(newKey))) + { + m_VariableTypes.erase(oldKey); + // Rename in graph DefaultVariables + graph->DefaultVariables.erase(oldKey); + graph->DefaultVariables[newKey] = val; + animComp.Variables.erase(it); + animComp.Variables[newKey] = val; + m_VariableTypes[newKey] = t; + m_ChangedGraph = true; + } + ImGui::PopID(); + break; + } + ImGui::SameLine(); + + // Value widget: checkbox for bool, drag for float + if (varType == VarType::Bool) + { + bool bval = (it->second >= 0.5f); + if (ImGui::Checkbox("##val", &bval)) + { + it->second = bval ? 1.0f : 0.0f; + } + } + else + { + float val = it->second; + ImGui::SetNextItemWidth(80.0f); + if (ImGui::DragFloat("##val", &val, 0.01f)) + { + it->second = val; + } + } + + ImGui::SameLine(); + if (ImGui::SmallButton("X")) + { + graph->DefaultVariables.erase(it->first); // sync to graph schema + m_VariableTypes.erase(it->first); + it = animComp.Variables.erase(it); + m_ChangedGraph = true; + ImGui::PopID(); + } + else + { + ++it; + ImGui::PopID(); + } + } + } + + void AnimGraphPanel::SaveGraph(AnimationGraphAsset* graph, const std::string& path) + { + if (!graph || path.empty()) + { + return; + } + + auto* assets = ServiceLocator::TryGet(); + if (assets) + { + std::string resolved = assets->ResolvePath(path); + AnimGraphLoader loader; + if (loader.Save(*graph, resolved)) + { + CH_CORE_INFO("Animation graph saved: {}", resolved); + } + else + { + CH_CORE_ERROR("Failed to save animation graph: {}", resolved); + } + } + } + +} // namespace Chained diff --git a/editor/panels/anim_graph_panel.h b/editor/panels/anim_graph_panel.h new file mode 100644 index 000000000..c466bf969 --- /dev/null +++ b/editor/panels/anim_graph_panel.h @@ -0,0 +1,87 @@ +#ifndef CH_ANIM_GRAPH_PANEL_H +#define CH_ANIM_GRAPH_PANEL_H + +#include "engine/scene/entity.h" +#include "engine/assets/types/animation_graph_asset.h" +#include +#include +#include +#include + +#include "editor/panels/panel.h" +#include + +namespace Chained +{ + + class AnimGraphPanel : public Panel + { + public: + AnimGraphPanel(); + ~AnimGraphPanel() override; + + void OnImGuiRender(bool readOnly = false) override; + + private: + bool m_ChangedGraph = false; + + enum class VarType + { + Float, + Bool + }; + std::unordered_map m_VariableTypes; // tracks type per variable name + bool m_AddVarAsFloat = true; // which type to add next + + GraphEditor::ViewState m_ViewState; + GraphEditor::Options m_Options; + GraphEditor::FitOnScreen m_Fit = GraphEditor::Fit_None; + + struct Delegate : public GraphEditor::Delegate + { + AnimGraphPanel* panel = nullptr; + AnimationGraphAsset* graph = nullptr; + bool* changedFlag = nullptr; + std::vector nodeSelected; + + void SyncSelection(); + + bool AllowedLink(GraphEditor::NodeIndex from, GraphEditor::NodeIndex to) override; + void SelectNode(GraphEditor::NodeIndex nodeIndex, bool selected) override; + void MoveSelectedNodes(const ImVec2 delta) override; + void AddLink(GraphEditor::NodeIndex inputNodeIndex, GraphEditor::SlotIndex inputSlotIndex, + GraphEditor::NodeIndex outputNodeIndex, GraphEditor::SlotIndex outputSlotIndex) override; + void DelLink(GraphEditor::LinkIndex linkIndex) override; + void RightClick(GraphEditor::NodeIndex nodeIndex, GraphEditor::SlotIndex slotIndexInput, + GraphEditor::SlotIndex slotIndexOutput) override; + void CustomDraw(ImDrawList* drawList, ImRect rectangle, GraphEditor::NodeIndex nodeIndex) override; + + const size_t GetTemplateCount() override; + const GraphEditor::Template GetTemplate(GraphEditor::TemplateIndex index) override; + const size_t GetNodeCount() override; + const GraphEditor::Node GetNode(GraphEditor::NodeIndex index) override; + const size_t GetLinkCount() override; + const GraphEditor::Link GetLink(GraphEditor::LinkIndex index) override; + }; + + Delegate m_Delegate; + + // Preview state: temporarily override AnimationComponent when node is selected + int m_PreviewNodeIdx = -1; + int m_OrigAnimIndex = 0; + int m_OrigFrame = 0; + int m_OrigStartFrame = 0; + int m_OrigEndFrame = -1; + float m_OrigSpeed = 1.0f; + bool m_OrigIsLooping = true; + bool m_OrigIsPlaying = true; + Entity m_PreviewEntity; + + void RestorePreview(); + void ApplyPreview(AnimationGraphAsset* graph, int nodeIdx, Entity entity); + void DrawProperties(AnimationGraphAsset* graph, Entity entity); + void SaveGraph(AnimationGraphAsset* graph, const std::string& path); + }; + +} // namespace Chained +#endif diff --git a/editor/panels/console_panel.cpp b/editor/panels/console_panel.cpp index a732023ba..b3eeae2d4 100644 --- a/editor/panels/console_panel.cpp +++ b/editor/panels/console_panel.cpp @@ -1,220 +1,199 @@ #include "console_panel.h" +#include "engine/app/application.h" #include "imgui.h" +#include +#include -namespace CHEngine +namespace Chained { -ConsolePanel* ConsolePanel::s_Instance = nullptr; -std::deque ConsolePanel::s_Buffer; -std::mutex ConsolePanel::s_BufferMutex; - -ConsolePanel::ConsolePanel() -{ - s_Instance = this; - m_Name = "Console"; - - // Flush static buffer to this instance - std::lock_guard lock(s_BufferMutex); - while (!s_Buffer.empty()) - { - m_Messages.push_back(std::move(s_Buffer.front())); - s_Buffer.pop_front(); - } -} - -ConsolePanel::~ConsolePanel() -{ - if (s_Instance == this) - { - s_Instance = nullptr; - } -} - -void ConsolePanel::OnImGuiRender(bool readOnly) -{ - if (!m_IsOpen) - { - return; - } - - ImGui::SetNextWindowSize(ImVec2(520, 600), ImGuiCond_FirstUseEver); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10, 10)); - - if (ImGui::Begin(m_Name.c_str(), &m_IsOpen)) - { - // Control Panel - ImGui::BeginDisabled(readOnly); - if (ImGui::Button("Clear")) - { - Clear(); - } - ImGui::SameLine(); - - ImGui::SetNextItemWidth(150); - ImGui::InputTextWithHint("##filter", "Filter...", m_FilterBuffer, sizeof(m_FilterBuffer)); - ImGui::SameLine(); - - const char* levels[] = {"TRACE", "INFO", "WARNING", "ERROR", "FATAL", "NONE"}; - ImGui::SetNextItemWidth(120); - ImGui::Combo("Level", &m_LogLevel, levels, IM_ARRAYSIZE(levels)); - - ImGui::EndDisabled(); - - ImGui::Separator(); - - // Rebuild visible indices if needed - { - std::lock_guard lock(m_LogMutex); - m_VisibleIndices.clear(); - std::string filterStr = m_FilterBuffer; - std::transform(filterStr.begin(), filterStr.end(), filterStr.begin(), ::tolower); - - for (int i = 0; i < (int)m_Messages.size(); i++) - { - if (m_LogLevel != (int)ConsoleLogLevel::None && (int)m_Messages[i].level < m_LogLevel) - { - continue; - } - - if (!filterStr.empty()) - { - std::string msgLower = m_Messages[i].message; - std::transform(msgLower.begin(), msgLower.end(), msgLower.begin(), ::tolower); - if (msgLower.find(filterStr) == std::string::npos) - { - continue; - } - } - m_VisibleIndices.push_back(i); - } - } - - const float footer_height_to_reserve = ImGui::GetStyle().ItemSpacing.y + ImGui::GetFrameHeightWithSpacing(); - ImGui::BeginChild("ScrollingRegion", ImVec2(0, -footer_height_to_reserve), false, - ImGuiWindowFlags_HorizontalScrollbar); - - { - std::lock_guard lock(m_LogMutex); - - ImGuiListClipper clipper; - clipper.Begin((int)m_VisibleIndices.size()); - - while (clipper.Step()) - { - for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) - { - int msgIdx = m_VisibleIndices[i]; - const auto& msg = m_Messages[msgIdx]; - - ImVec4 color; - switch (msg.level) - { - case ConsoleLogLevel::Trace: - color = {0.7f, 0.7f, 0.7f, 1.0f}; - break; - case ConsoleLogLevel::Info: - color = {1.0f, 1.0f, 1.0f, 1.0f}; - break; - case ConsoleLogLevel::Warn: - color = {1.0f, 0.8f, 0.0f, 1.0f}; - break; - case ConsoleLogLevel::Error: - color = {1.0f, 0.2f, 0.2f, 1.0f}; - break; - case ConsoleLogLevel::Fatal: - color = {1.0f, 0.0f, 1.0f, 1.0f}; - break; - default: - color = {1.0f, 1.0f, 1.0f, 1.0f}; - break; - } - - // Timestamp - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.5f, 0.5f, 0.5f, 1.0f)); - ImGui::TextUnformatted(msg.timestamp.c_str()); - ImGui::PopStyleColor(); - ImGui::SameLine(); - - ImGui::PushStyleColor(ImGuiCol_Text, color); - ImGui::TextUnformatted(msg.message.c_str()); - ImGui::PopStyleColor(); - } - } - } - - if (m_ScrollToBottom || (ImGui::GetScrollY() >= ImGui::GetScrollMaxY())) - { - ImGui::SetScrollHereY(1.0f); - } - m_ScrollToBottom = false; - - ImGui::EndChild(); - } - ImGui::End(); - ImGui::PopStyleVar(); -} -void ConsolePanel::Log(const std::string& message, ConsoleLogLevel level) -{ - std::lock_guard lock(m_LogMutex); - - // Форматуємо час (наприклад, "14:20:05") - std::string timeStr = GetCurrentTimestamp(); - - m_Messages.push_back({level, message, timeStr}); - - if (m_Messages.size() > MAX_MESSAGES) - { - m_Messages.pop_front(); - } - - m_ScrollToBottom = true; // Сигнал для OnImGuiRender -} - -void ConsolePanel::Clear() -{ - std::lock_guard lock(m_LogMutex); - m_Messages.clear(); -} - -void ConsolePanel::AddLog(const char* message, int level) -{ - if (s_Instance) - { - s_Instance->Log(message, (ConsoleLogLevel)level); - } - else - { - // Buffer logs until ConsolePanel is initialized - std::lock_guard lock(s_BufferMutex); - if (s_Buffer.size() < MAX_MESSAGES) - { - s_Buffer.push_back({(ConsoleLogLevel)level, message, GetCurrentTimestamp()}); - } - } -} - -std::string ConsolePanel::GetCurrentTimestamp() -{ - using namespace std::chrono; - - auto now = system_clock::now(); - auto in_time_t = system_clock::to_time_t(now); - - std::tm time_info; -#if defined(_MSC_VER) - localtime_s(&time_info, &in_time_t); -#elif defined(_WIN32) - std::tm* tm_ptr = std::localtime(&in_time_t); - if (tm_ptr) - { - time_info = *tm_ptr; - } -#else - localtime_r(&in_time_t, &time_info); -#endif - - std::stringstream ss; - ss << "[" << std::put_time(&time_info, "%H:%M:%S") << "] "; - return ss.str(); -} - -} // namespace CHEngine + ConsolePanel::ConsolePanel() + { + m_Name = "Console"; + + // Consume initially buffered messages before UI loop starts + auto bufferedMessages = Log::ConsumeBufferedMessages(); + for (auto& entry : bufferedMessages) + { + m_Messages.push_back(std::move(entry)); + } + + m_ScrollToBottom = !m_Messages.empty(); + } + + ConsolePanel::~ConsolePanel() = default; + + void ConsolePanel::OnImGuiRender(bool readOnly) + { + if (!m_IsOpen) + { + return; + } + + ImGui::SetNextWindowSize(ImVec2(520, 600), ImGuiCond_FirstUseEver); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10, 10)); + + if (ImGui::Begin(m_Name.c_str(), &m_IsOpen)) + { + bool filtersChanged = false; + + // --- 1. Control Panel --- + ImGui::BeginDisabled(readOnly); + + if (ImGui::Button("Clear")) + { + Clear(); + filtersChanged = true; + } + ImGui::SameLine(); + + ImGui::SetNextItemWidth(150); + // If user types into filter, we flag that index rebuild is required + if (ImGui::InputTextWithHint("##filter", "Filter...", m_FilterBuffer, sizeof(m_FilterBuffer))) + { + filtersChanged = true; + } + ImGui::SameLine(); + + const char* levels[] = {"TRACE", "INFO", "WARNING", "ERROR", "FATAL", "NONE"}; + ImGui::SetNextItemWidth(120); + if (ImGui::Combo("Level", &m_LogLevel, levels, IM_ARRAYSIZE(levels))) + { + filtersChanged = true; + } + + ImGui::EndDisabled(); + ImGui::Separator(); + + // --- 2. Ingest New Logs --- + auto bufferedMessages = Log::ConsumeBufferedMessages(); + bool hasNewMessages = !bufferedMessages.empty(); + + if (hasNewMessages || filtersChanged) + { + std::lock_guard lock(m_LogMutex); + + if (hasNewMessages) + { + for (auto& entry : bufferedMessages) + { + m_Messages.push_back(std::move(entry)); + } + + while (m_Messages.size() > MAX_MESSAGES) + { + m_Messages.pop_front(); + } + + m_ScrollToBottom = true; + } + + // --- 3. Optimized Rebuild of Visible Indices --- + // We only rebuild when new logs arrive OR UI filter parameters change. + m_VisibleIndices.clear(); + + std::string filterStr = m_FilterBuffer; + std::transform(filterStr.begin(), filterStr.end(), filterStr.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + + for (int i = 0; i < static_cast(m_Messages.size()); ++i) + { + if (m_LogLevel != static_cast(LogLevel::LogNone) && + static_cast(m_Messages[i].level) < m_LogLevel) + { + continue; + } + + if (!filterStr.empty()) + { + // Case-insensitive substring match without heavy allocation + auto it = + std::search(m_Messages[i].message.begin(), m_Messages[i].message.end(), filterStr.begin(), + filterStr.end(), [](unsigned char ch1, unsigned char ch2) { + return std::tolower(ch1) == ch2; // filterStr is already lower + }); + + if (it == m_Messages[i].message.end()) + { + continue; + } + } + m_VisibleIndices.push_back(i); + } + } + + // --- 4. Content Scrolling Region --- + const float footerHeight = ImGui::GetStyle().ItemSpacing.y + ImGui::GetFrameHeightWithSpacing(); + ImGui::BeginChild("ScrollingRegion", ImVec2(0, -footerHeight), false, ImGuiWindowFlags_HorizontalScrollbar); + + { + std::lock_guard lock(m_LogMutex); + + ImGuiListClipper clipper; + clipper.Begin(static_cast(m_VisibleIndices.size())); + + while (clipper.Step()) + { + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) + { + const int msgIdx = m_VisibleIndices[i]; + const auto& msg = m_Messages[msgIdx]; + + ImVec4 color; + switch (msg.level) + { + case LogLevel::LogTrace: + color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); + break; + case LogLevel::LogInfo: + color = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + break; + case LogLevel::LogWarning: + color = ImVec4(1.0f, 0.8f, 0.0f, 1.0f); + break; + case LogLevel::LogError: + color = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); + break; + case LogLevel::LogFatal: + color = ImVec4(1.0f, 0.0f, 1.0f, 1.0f); + break; + default: + color = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + break; + } + + // Render Timestamp (Gray color) + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.5f, 0.5f, 0.5f, 1.0f)); + ImGui::TextUnformatted(msg.timestamp.c_str()); + ImGui::PopStyleColor(); + + ImGui::SameLine(); + + // Render Log Message + ImGui::PushStyleColor(ImGuiCol_Text, color); + ImGui::TextUnformatted(msg.message.c_str()); + ImGui::PopStyleColor(); + } + } + } + + // Stick to bottom if scrolled down or explicitly triggered + if (m_ScrollToBottom || (ImGui::GetScrollY() >= ImGui::GetScrollMaxY())) + { + ImGui::SetScrollHereY(1.0f); + } + m_ScrollToBottom = false; + + ImGui::EndChild(); + } + ImGui::End(); + ImGui::PopStyleVar(); + } + + void ConsolePanel::Clear() + { + std::lock_guard lock(m_LogMutex); + m_Messages.clear(); + m_VisibleIndices.clear(); + } + +} // namespace Chained \ No newline at end of file diff --git a/editor/panels/console_panel.h b/editor/panels/console_panel.h index ba64735cc..430ab30a5 100644 --- a/editor/panels/console_panel.h +++ b/editor/panels/console_panel.h @@ -1,71 +1,75 @@ #ifndef CH_CONSOLE_PANEL_H #define CH_CONSOLE_PANEL_H -#include +#include "engine/core/log.h" +#include "panel.h" #include +#include #include #include #include -#include -#include -#include -#include - -#include "panel.h" -namespace CHEngine +namespace Chained { -// Extend log levels for engine/editor compatibility + /** + * @class ConsolePanel + * @brief A developer tool panel that displays system log messages in real-time using ImGui. + * + * This panel supports live filtering by log severity levels (LogLevel) and text substrings, + * handles automatic scrolling, and ensures thread-safe access to underlying log entries. + */ + class ConsolePanel : public Panel + { + public: + /** + * @brief Constructs the ConsolePanel and initializes the UI state. + */ + ConsolePanel(); -enum class ConsoleLogLevel : uint32_t -{ - Trace = 0, - Info, - Warn, - Error, - Fatal, - None -}; + /** + * @brief Destructs the ConsolePanel and releases its resources. + */ + ~ConsolePanel(); -struct ConsoleLogEntry -{ - ConsoleLogLevel level; - std::string message; - std::string timestamp; // Додаємо час для зручності -}; + /** + * @brief Renders the console interface using ImGui commands. + * @param readOnly If true, disables control elements such as the clear button and filters input. + */ + void OnImGuiRender(bool readOnly = false) override; -class ConsolePanel : public Panel -{ -public: - ConsolePanel(); - ~ConsolePanel(); + /** + * @brief Clears all accumulated log messages from the buffer and resets visible indices. + */ + void Clear(); + + private: + /// @brief Thread-safe double-ended queue storing raw log entries. Deque enables efficient pop_front when + /// exceeding limits. + std::deque m_Messages; - void OnImGuiRender(bool readOnly = false) override; - void Log(const std::string& message, ConsoleLogLevel level = ConsoleLogLevel::Info); - void Clear(); + /// @brief Cached indices of messages from m_Messages that successfully passed active filters. Optimizes render + /// passes. + std::vector m_VisibleIndices; - static void AddLog(const char* message, int level = (int)ConsoleLogLevel::Info); + /// @brief Mutex to guarantee thread-safe operations when background threads submit logs while the main thread + /// renders them. + std::mutex m_LogMutex; - static ConsolePanel* s_Instance; - static std::deque s_Buffer; - static std::mutex s_BufferMutex; + // --- UI State --- -private: - - std::deque m_Messages; - std::vector m_VisibleIndices; // Indices of messages that pass the filter - std::mutex m_LogMutex; + /// @brief Currently selected minimum severity level cutoff for rendering messages. + int m_LogLevel = (int)LogLevel::LogInfo; - // UI State - int m_LogLevel = (int)ConsoleLogLevel::Info; - bool m_ScrollToBottom = false; - char m_FilterBuffer[128] = { 0 }; + /// @brief Flag to trigger the ImGui container window to scroll down to the latest message. + bool m_ScrollToBottom = false; - static constexpr size_t MAX_MESSAGES = 10000; + /// @brief Character buffer containing the text substring query for filtering. + char m_FilterBuffer[128] = {0}; - // Допоміжний метод для отримання поточного часу - static std::string GetCurrentTimestamp(); -}; -} // namespace CHEngine + /// @brief The hard limit of history retention. Prevents memory leaks by discarding the oldest logs upon + /// overflow. + const size_t MAX_MESSAGES = 25000; + }; +} // namespace Chained #endif // CH_CONSOLE_PANEL_H \ No newline at end of file diff --git a/editor/panels/content_browser_panel.cpp b/editor/panels/content_browser_panel.cpp index c2e74d2aa..96a7d68bc 100644 --- a/editor/panels/content_browser_panel.cpp +++ b/editor/panels/content_browser_panel.cpp @@ -1,492 +1,496 @@ -#include "content_browser_panel.h" - -#include "engine/core/base.h" -#include "editor/editor_layer.h" +#include "editor/panels/content_browser_panel.h" +#include "editor/action_commands.h" +#include "editor/events.h" +#include "editor/layer.h" #include "engine/core/log.h" -#include "engine/scene/project.h" +#include "engine/project/project.h" +#include "engine/scene/components.h" +#include "engine/scene/prefab_serializer.h" #include "engine/scene/scene_events.h" -#include "IconsFontAwesome6.h" - #include "imgui.h" +#include "thirdparty/IconsFontAwesome6.h" #include -#include #include -namespace CHEngine -{ -ContentBrowserPanel::ContentBrowserPanel() -{ - m_Name = "Content Browser"; - - auto project = Project::GetActive(); - if (project) - { - m_RootDirectory = Project::GetAssetDirectory(); - } - else - { - // Fallback: use project root if project not loaded yet - m_RootDirectory = std::filesystem::current_path() / "assets"; - } - - m_CurrentDirectory = m_RootDirectory; - RefreshDirectory(); -} - -ContentBrowserPanel::~ContentBrowserPanel() -{ - // Unload textures if they were loaded -} - -void ContentBrowserPanel::OnImGuiRender(bool readOnly) -{ - if (!m_IsOpen) - { - return; - } - ImGui::Begin(m_Name.c_str(), &m_IsOpen); - - ImGui::BeginDisabled(readOnly); - RenderToolbar(); - ImGui::Separator(); - RenderGridView(); - - ImGui::EndDisabled(); - ImGui::End(); -} - -void ContentBrowserPanel::OnEvent(Event& e) +namespace Chained { - EventDispatcher dispatcher(e); - dispatcher.Dispatch([this](ProjectOpenedEvent& e) { - SetRootDirectory(Project::GetAssetDirectory()); - return false; - }); -} - -void ContentBrowserPanel::RenderToolbar() -{ - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 2)); - ImGui::PushStyleVar(ImGuiStyleVar_ItemInnerSpacing, ImVec2(0, 0)); - - // Navigation Buttons - if (m_CurrentDirectory != m_RootDirectory) - { - if (ImGui::Button(ICON_FA_ARROW_LEFT)) - { - m_CurrentDirectory = m_CurrentDirectory.parent_path(); - RefreshDirectory(); - } - } - else - { - ImGui::BeginDisabled(true); - ImGui::Button(ICON_FA_ARROW_LEFT); - ImGui::EndDisabled(); - } - - ImGui::SameLine(); - ImGui::SetNextItemWidth(200); - - // Search Filter - if (ImGui::InputTextWithHint("##Search", ICON_FA_MAGNIFYING_GLASS " Search...", m_FilterBuffer, - sizeof(m_FilterBuffer))) - { - RefreshDirectory(); - } - - ImGui::SameLine(); - - // Type Filter Dropdown - ImGui::SetNextItemWidth(150); - const char* filterNames[] = {"All Types", "Scenes", "Prefabs", "Models", "Textures", "Scripts", "Audio"}; - if (ImGui::BeginCombo("##TypeFilter", filterNames[m_FilterType])) - { - for (int i = 0; i < IM_ARRAYSIZE(filterNames); i++) - { - bool isSelected = (m_FilterType == i); - if (ImGui::Selectable(filterNames[i], isSelected)) - { - m_FilterType = i; - RefreshDirectory(); - } - if (isSelected) - { - ImGui::SetItemDefaultFocus(); - } - } - ImGui::EndCombo(); - } - - ImGui::SameLine(); - - // Breadcrumbs - std::error_code ec; - auto relPath = std::filesystem::relative(m_CurrentDirectory, m_RootDirectory, ec); - - if (ImGui::Button("Assets")) - { - m_CurrentDirectory = m_RootDirectory; - RefreshDirectory(); - } - - if (!ec && !relPath.empty() && relPath != ".") - { - std::filesystem::path accumulated = m_RootDirectory; - for (const auto& part : relPath) - { - ImGui::SameLine(); - ImGui::Text("/"); - ImGui::SameLine(); - - accumulated /= part; - if (ImGui::Button(part.string().c_str())) - { - m_CurrentDirectory = accumulated; - RefreshDirectory(); - break; - } - } - } - - // Icon Scale Slider (Right aligned) - ImGui::SameLine(ImGui::GetWindowWidth() - 160.0f); - ImGui::SetNextItemWidth(150.0f); - ImGui::SliderFloat("##IconScale", &m_IconScale, 0.5f, 2.0f, ICON_FA_IMAGE); - - ImGui::PopStyleVar(2); -} - -void ContentBrowserPanel::RenderGridView() -{ - float cellSize = (m_ThumbnailSize * m_IconScale) + m_Padding; - float panelWidth = ImGui::GetContentRegionAvail().x; - int columnCount = (int)(panelWidth / cellSize); - if (columnCount < 1) - { - columnCount = 1; - } - - ImGui::Columns(columnCount, nullptr, false); - - int i = 0; - for (auto& asset : m_CurrentAssets) - { - ImGui::PushID(i++); - - const char* icon = asset.isDirectory ? ICON_FA_FOLDER : ICON_FA_FILE; - - // Custom Icons per type - if (!asset.isDirectory) - { - switch (asset.type) - { - case EditorAssetType::Scene: - icon = ICON_FA_CUBES; - break; - case EditorAssetType::Prefab: - icon = ICON_FA_CUBE; - break; - case EditorAssetType::Model: - icon = ICON_FA_SHAPES; - break; - case EditorAssetType::Texture: - icon = ICON_FA_IMAGE; - break; - case EditorAssetType::Script: - icon = ICON_FA_FILE_CODE; - break; - case EditorAssetType::Audio: - icon = ICON_FA_MUSIC; - break; - default: - icon = ICON_FA_FILE; - break; - } - } - - ImGui::BeginGroup(); - - // Thumbnail/Icon - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); - float currentThumbnailSize = m_ThumbnailSize * m_IconScale; - - if (ImGui::Button(icon, {cellSize - m_Padding, currentThumbnailSize})) - { - // Clicked - } - - if (ImGui::BeginPopupContextItem()) - { - if (ImGui::MenuItem(ICON_FA_PEN " Rename")) - { - m_RenamingPath = asset.path; - strncpy(m_RenameBuffer, asset.name.c_str(), sizeof(m_RenameBuffer)); - ImGui::OpenPopup("RenameAsset"); - } - if (ImGui::MenuItem(ICON_FA_TRASH " Delete")) - { - m_PathToDelete = asset.path; - ImGui::OpenPopup("DeleteAsset?"); - } - ImGui::EndPopup(); - } - - if (ImGui::BeginPopupModal("RenameAsset", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) - { - ImGui::Text("Enter new name for %s:", m_RenamingPath.filename().string().c_str()); - ImGui::InputText("##NewName", m_RenameBuffer, sizeof(m_RenameBuffer)); - if (ImGui::Button("OK", {120, 0})) - { - std::filesystem::path newPath = m_RenamingPath.parent_path() / m_RenameBuffer; - std::error_code ec; - std::filesystem::rename(m_RenamingPath, newPath, ec); - RefreshDirectory(); - ImGui::CloseCurrentPopup(); - } - ImGui::SameLine(); - if (ImGui::Button("Cancel", {120, 0})) { ImGui::CloseCurrentPopup(); } - ImGui::EndPopup(); - } - - if (ImGui::BeginPopupModal("DeleteAsset?", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) - { - ImGui::Text("Are you sure you want to delete %s?\nThis operation cannot be undone!", m_PathToDelete.filename().string().c_str()); - if (ImGui::Button("Delete", {120, 0})) - { - std::error_code ec; - std::filesystem::remove_all(m_PathToDelete, ec); - RefreshDirectory(); - ImGui::CloseCurrentPopup(); - } - ImGui::SameLine(); - if (ImGui::Button("Cancel", {120, 0})) { ImGui::CloseCurrentPopup(); } - ImGui::EndPopup(); - } - - if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) - { - OnAssetDoubleClicked(asset); - } - - if (ImGui::BeginDragDropSource()) - { - std::string pathStr = asset.path.string(); - ImGui::SetDragDropPayload("CONTENT_BROWSER_ITEM", pathStr.c_str(), pathStr.size() + 1); - ImGui::Text("%s %s", icon, asset.name.c_str()); - ImGui::EndDragDropSource(); - } - - ImGui::PopStyleColor(); - - // Metadata/Label - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 2)); - - ImGui::SetNextItemWidth(cellSize - m_Padding); - ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + cellSize - m_Padding); - - float textWidth = ImGui::CalcTextSize(asset.name.c_str()).x; - if (textWidth < cellSize - m_Padding) - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (cellSize - m_Padding - textWidth) * 0.5f); - - ImGui::TextUnformatted(asset.name.c_str()); - ImGui::PopTextWrapPos(); - - // Type Label - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.5f, 0.5f, 0.5f, 1.0f)); - const char* typeLabel = asset.isDirectory ? "FOLDER" : "FILE"; - if (asset.type == EditorAssetType::Model) typeLabel = "MESH"; - else if (asset.type == EditorAssetType::Scene) typeLabel = "SCENE"; - else if (asset.type == EditorAssetType::Script) typeLabel = "SCRIPT"; - - float typeLabelWidth = ImGui::CalcTextSize(typeLabel).x; - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (cellSize - m_Padding - typeLabelWidth) * 0.5f); - ImGui::TextDisabled("%s", typeLabel); - ImGui::PopStyleColor(); - - ImGui::PopStyleVar(); - ImGui::EndGroup(); - - ImGui::NextColumn(); - ImGui::PopID(); - } - - ImGui::Columns(1); - - // Empty space context menu - if (ImGui::BeginPopupContextWindow(0, 1 | ImGuiPopupFlags_NoOpenOverItems)) - { - if (ImGui::BeginMenu(ICON_FA_PLUS " Create")) - { - if (ImGui::MenuItem(ICON_FA_FOLDER " New Folder")) - { - std::filesystem::path newDir = m_CurrentDirectory / "New Folder"; - int i = 1; - while (std::filesystem::exists(newDir)) - newDir = m_CurrentDirectory / ("New Folder " + std::to_string(i++)); - std::filesystem::create_directory(newDir); - RefreshDirectory(); - } - if (ImGui::MenuItem(ICON_FA_FILE_CODE " New C# Script")) - { - std::filesystem::path newScript = m_CurrentDirectory / "NewScript.cs"; - int i = 1; - while (std::filesystem::exists(newScript)) - newScript = m_CurrentDirectory / ("NewScript" + std::to_string(i++) + ".cs"); - - std::string className = newScript.stem().string(); - std::string templateContent = - "using CHEngine;\n\n" - "namespace ChainedDecos.Scripts\n" - "{\n" - " public class " + className + " : Script\n" - " {\n" - " public override void OnCreate()\n" - " {\n" - " }\n\n" - " public override void OnUpdate(float deltaTime)\n" - " {\n" - " }\n" - " }\n" - "}\n"; - - std::ofstream ofs(newScript); - ofs << templateContent; - ofs.close(); - RefreshDirectory(); - } - ImGui::EndMenu(); - } - ImGui::EndPopup(); - } -} - -void ContentBrowserPanel::OnAssetDoubleClicked(AssetEntry& entry) -{ - if (entry.isDirectory) - { - m_CurrentDirectory = entry.path; - RefreshDirectory(); - } - else if (entry.type == EditorAssetType::Scene) - { - EditorLayer::Get().GetSceneManager().OpenScene(entry.path); - } -} - -void ContentBrowserPanel::RefreshDirectory() -{ - ScanCurrentDirectory(); -} -void ContentBrowserPanel::ScanCurrentDirectory() -{ - m_CurrentAssets.clear(); - std::error_code ec; - - if (!std::filesystem::exists(m_CurrentDirectory, ec)) - { - return; - } - - std::string searchFilter = m_FilterBuffer; - // Case-insensitive search - std::transform(searchFilter.begin(), searchFilter.end(), searchFilter.begin(), ::tolower); - - for (auto& p : std::filesystem::directory_iterator(m_CurrentDirectory, ec)) - { - AssetEntry entry; - entry.name = p.path().filename().string(); - entry.path = p.path(); - entry.isDirectory = p.is_directory(); - entry.type = DetermineAssetType(p.path()); - - // 1. Name Filter - std::string nameLower = entry.name; - std::transform(nameLower.begin(), nameLower.end(), nameLower.begin(), ::tolower); - if (!searchFilter.empty() && nameLower.find(searchFilter) == std::string::npos) - { - continue; - } - - // 2. Type Filter (Directories always shown) - if (!entry.isDirectory && m_FilterType > 0) - { - // "All Types", "Scenes", "Prefabs", "Models", "Textures", "Scripts", "Audio" - bool match = false; - switch (m_FilterType) - { - case 1: - match = (entry.type == EditorAssetType::Scene); - break; - case 2: - match = (entry.type == EditorAssetType::Prefab); - break; - case 3: - match = (entry.type == EditorAssetType::Model); - break; - case 4: - match = (entry.type == EditorAssetType::Texture); - break; - case 5: - match = (entry.type == EditorAssetType::Script); - break; - case 6: - match = (entry.type == EditorAssetType::Audio); - break; - } - if (!match) - { - continue; - } - } - - m_CurrentAssets.push_back(entry); - } - - // Sort: Directories first, then alphabetical - std::sort(m_CurrentAssets.begin(), m_CurrentAssets.end(), [](const AssetEntry& a, const AssetEntry& b) { - if (a.isDirectory != b.isDirectory) - { - return a.isDirectory > b.isDirectory; - } - return a.name < b.name; - }); -} - -EditorAssetType ContentBrowserPanel::DetermineAssetType(const std::filesystem::path& path) -{ - if (std::filesystem::is_directory(path)) - { - return EditorAssetType::Directory; - } - - static const std::unordered_map s_ExtensionMap = { - {".chscene", EditorAssetType::Scene}, {".chmap", EditorAssetType::Scene}, - {".chprefab", EditorAssetType::Prefab}, {".h", EditorAssetType::Script}, - {".cpp", EditorAssetType::Script}, {".obj", EditorAssetType::Model}, - {".gltf", EditorAssetType::Model}, {".glb", EditorAssetType::Model}, - {".png", EditorAssetType::Texture}, {".jpg", EditorAssetType::Texture}, - {".tga", EditorAssetType::Texture}, {".wav", EditorAssetType::Audio}, - {".ogg", EditorAssetType::Audio}, {".mp3", EditorAssetType::Audio}}; - - std::string ext = path.extension().string(); - // ToLower extension - std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); - - auto it = s_ExtensionMap.find(ext); - if (it != s_ExtensionMap.end()) - { - return it->second; - } - - return EditorAssetType::Other; -} - -void ContentBrowserPanel::SetRootDirectory(const std::filesystem::path& path) -{ - m_RootDirectory = path; - m_CurrentDirectory = path; - RefreshDirectory(); -} -} // namespace CHEngine + ContentBrowserPanel::ContentBrowserPanel() + { + m_Name = "Content Browser"; + + if (auto project = Project::GetActive()) + { + SetRoot(project->GetConfig().ProjectDirectory / project->GetConfig().AssetDirectory); + } + else + { + SetRoot(std::filesystem::current_path() / "assets"); + } + + m_ThumbnailSize = EditorLayer::Get().GetConfig().DefaultThumbnailSize; + } + + ContentBrowserPanel::~ContentBrowserPanel() = default; + + void ContentBrowserPanel::OnImGuiRender(bool readOnly) + { + if (!m_NextDirectory.empty()) + { + Navigate(m_NextDirectory); + m_NextDirectory.clear(); + } + + if (!m_IsOpen) + { + return; + } + + ImGui::Begin(m_Name.c_str(), &m_IsOpen); + ImGui::BeginDisabled(readOnly); + + RenderToolbar(); + ImGui::Separator(); + RenderGridView(); + + ImGui::EndDisabled(); + ImGui::End(); + } + + void ContentBrowserPanel::OnEvent(Event& e) + { + EventDispatcher dispatcher(e); + dispatcher.Dispatch([this](ProjectOpenedEvent& e) { + if (auto project = Project::GetActive()) + { + SetRoot(project->GetConfig().ProjectDirectory / project->GetConfig().AssetDirectory); + } + return false; + }); + } + + void ContentBrowserPanel::RenderToolbar() + { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 2)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemInnerSpacing, ImVec2(0, 0)); + + if (GetCurrentDirectory() != GetRootDirectory()) + { + if (ImGui::Button(ICON_FA_ARROW_LEFT)) + { + GoUp(); + } + } + else + { + ImGui::BeginDisabled(true); + ImGui::Button(ICON_FA_ARROW_LEFT); + ImGui::EndDisabled(); + } + + ImGui::SameLine(); + ImGui::SetNextItemWidth(200); + + if (ImGui::InputTextWithHint("##Search", ICON_FA_MAGNIFYING_GLASS " Search...", m_FilterBuffer, + sizeof(m_FilterBuffer))) + { + SetFilter(m_FilterBuffer, m_FilterType); + } + + ImGui::SameLine(); + ImGui::SetNextItemWidth(150); + const char* filterNames[] = {"All Types", "Scenes", "Prefabs", "Models", "Textures", "Scripts", "Audio"}; + if (ImGui::BeginCombo("##TypeFilter", filterNames[m_FilterType])) + { + for (int i = 0; i < IM_ARRAYSIZE(filterNames); i++) + { + if (ImGui::Selectable(filterNames[i], m_FilterType == i)) + { + m_FilterType = i; + SetFilter(m_FilterBuffer, m_FilterType); + } + } + ImGui::EndCombo(); + } + + ImGui::SameLine(); + if (ImGui::Button("Assets")) + { + GoToRoot(); + } + + // Breadcrumbs + std::error_code ec; + auto relPath = std::filesystem::relative(GetCurrentDirectory(), GetRootDirectory(), ec); + if (!ec && !relPath.empty() && relPath != ".") + { + std::filesystem::path accumulated = GetRootDirectory(); + for (const auto& part : relPath) + { + ImGui::SameLine(); + ImGui::Text("/"); + ImGui::SameLine(); + accumulated /= part; + if (ImGui::Button(part.string().c_str())) + { + Navigate(accumulated); + break; + } + } + } + + ImGui::SameLine(ImGui::GetWindowWidth() - 160.0f); + ImGui::SetNextItemWidth(150.0f); + ImGui::SliderFloat("##IconScale", &m_IconScale, 0.5f, 2.0f, ICON_FA_IMAGE); + + ImGui::PopStyleVar(2); + } + + void ContentBrowserPanel::RenderGridView() + { + float cellSize = (m_ThumbnailSize * m_IconScale) + m_Padding; + float panelWidth = ImGui::GetContentRegionAvail().x; + int columnCount = std::max(1, (int)(panelWidth / cellSize)); + + ImGui::Columns(columnCount, nullptr, false); + + const auto& assets = GetAssets(); + if (assets.empty()) + { + ImGui::TextDisabled("Empty directory or No assets found matching filters."); + ImGui::Columns(1); + } + else + { + int i = 0; + for (const auto& asset : assets) + { + ImGui::PushID(i++); + const char* icon = ICON_FA_FOLDER; + if (!asset.isDirectory) + { + switch (asset.type) + { + case EditorAssetType::Scene: + icon = ICON_FA_CUBES; + break; + case EditorAssetType::Script: + icon = ICON_FA_FILE_CODE; + break; + case EditorAssetType::Model: + icon = ICON_FA_SHAPES; + break; + case EditorAssetType::Texture: + icon = ICON_FA_IMAGE; + break; + case EditorAssetType::Audio: + icon = ICON_FA_MUSIC; + break; + case EditorAssetType::Prefab: + icon = ICON_FA_CUBE; + break; + case EditorAssetType::Shader: + icon = ICON_FA_CODE; + break; + default: + icon = ICON_FA_FILE; + break; + } + } + + ImGui::BeginGroup(); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); + ImGui::Button(icon, {cellSize - m_Padding, m_ThumbnailSize * m_IconScale}); + + if (ImGui::BeginPopupContextItem()) + { + if (ImGui::MenuItem(ICON_FA_PEN " Rename")) + { + m_RenamingPath = asset.path; + strncpy(m_RenameBuffer, asset.name.c_str(), sizeof(m_RenameBuffer) - 1); + m_RenameBuffer[sizeof(m_RenameBuffer) - 1] = '\0'; + m_OpenRenamePopup = true; + } + if (ImGui::MenuItem(ICON_FA_TRASH " Delete")) + { + m_PathToDelete = asset.path; + m_OpenDeletePopup = true; + } + ImGui::EndPopup(); + } + + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) + { + OnAssetDoubleClicked(asset); + } + + if (ImGui::BeginDragDropSource()) + { + std::string pathStr = asset.path.string(); + ImGui::SetDragDropPayload("CONTENT_BROWSER_ITEM", pathStr.c_str(), pathStr.size() + 1); + ImGui::Text("%s %s", icon, asset.name.c_str()); + ImGui::EndDragDropSource(); + } + + ImGui::PopStyleColor(); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 2)); + ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + cellSize - m_Padding); + ImGui::TextUnformatted(asset.name.c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopStyleVar(); + ImGui::EndGroup(); + + ImGui::NextColumn(); + ImGui::PopID(); + } + ImGui::Columns(1); + } + + if (m_OpenRenamePopup) + { + ImGui::OpenPopup("RenameAsset"); + m_OpenRenamePopup = false; + } + if (m_OpenDeletePopup) + { + ImGui::OpenPopup("DeleteAsset?"); + m_OpenDeletePopup = false; + } + + if (ImGui::BeginPopupModal("RenameAsset", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::Text("Enter new name:"); + ImGui::InputText("##NewName", m_RenameBuffer, sizeof(m_RenameBuffer)); + if (ImGui::Button("OK", {120, 0})) + { + EditorActionCommands::RenameAsset(m_RenamingPath, m_RenameBuffer); + m_PendingRefresh = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", {120, 0})) + { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + if (ImGui::BeginPopupModal("DeleteAsset?", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::Text("Delete %s?", m_PathToDelete.filename().string().c_str()); + if (ImGui::Button("Delete", {120, 0})) + { + EditorActionCommands::DeleteAsset(m_PathToDelete); + m_PendingRefresh = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", {120, 0})) + { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + if (ImGui::BeginPopupContextWindow(0, ImGuiPopupFlags_MouseButtonRight | ImGuiPopupFlags_NoOpenOverItems)) + { + if (ImGui::BeginMenu(ICON_FA_PLUS " Create")) + { + if (ImGui::MenuItem(ICON_FA_FOLDER " New Folder")) + { + EditorActionCommands::CreateFolder(GetCurrentDirectory()); + m_PendingRefresh = true; + } + ImGui::EndMenu(); + } + ImGui::EndPopup(); + } + + if (m_PendingRefresh) + { + m_PendingRefresh = false; + Refresh(); + } + } + + void ContentBrowserPanel::OnAssetDoubleClicked(const AssetEntry& entry) + { + if (entry.isDirectory) + { + m_NextDirectory = entry.path; + return; + } + + if (entry.type == EditorAssetType::Scene) + { + EditorLayer::Get().GetSceneManager().OpenScene(entry.path); + return; + } + + auto scene = EditorLayer::Get().GetSceneManager().GetActiveScene(); + if (!scene) + { + return; + } + + if (entry.type == EditorAssetType::Prefab) + { + PrefabSerializer::Deserialize(scene.get(), entry.path.string()); + } + if (entry.type == EditorAssetType::Model) + { + Entity entity = scene->CreateEntity(entry.name); + auto& modelcomp = entity.AddComponent(); + modelcomp.ModelPath = Project::GetActive()->GetRelativePath(entry.path); + SelectEntity(entity, scene.get()); + } + if (entry.type == EditorAssetType::Texture) + { + Entity entity = scene->CreateEntity(entry.name); + auto& sprite = entity.AddComponent(); + sprite.TexturePath = Project::GetActive()->GetRelativePath(entry.path); + SelectEntity(entity, scene.get()); + } + if (entry.type == EditorAssetType::Audio) + { + Entity entity = scene->CreateEntity(entry.name); + auto& audiocomp = entity.AddComponent(); + audiocomp.SoundPath = entry.path.string(); + SelectEntity(entity, scene.get()); + } + if (entry.type == EditorAssetType::Shader) + { + Entity entity = scene->CreateEntity(entry.name); + auto& shader = entity.AddComponent(); + shader.ShaderPath = Project::GetActive()->GetRelativePath(entry.path); + SelectEntity(entity, scene.get()); + } + } + + void ContentBrowserPanel::SetRoot(const std::filesystem::path& path) + { + m_RootDirectory = path; + m_CurrentDirectory = path; + Scan(); + } + + void ContentBrowserPanel::SetFilter(const std::string& query, int typeFilter) + { + m_FilterQuery = query; + std::transform(m_FilterQuery.begin(), m_FilterQuery.end(), m_FilterQuery.begin(), ::tolower); + m_ContentFilterType = typeFilter; + Scan(); + } + + void ContentBrowserPanel::Refresh() + { + Scan(); + } + + void ContentBrowserPanel::Navigate(const std::filesystem::path& path) + { + m_CurrentDirectory = path; + Scan(); + } + + void ContentBrowserPanel::GoUp() + { + if (m_CurrentDirectory != m_RootDirectory) + { + m_CurrentDirectory = m_CurrentDirectory.parent_path(); + Scan(); + } + } + + void ContentBrowserPanel::GoToRoot() + { + m_CurrentDirectory = m_RootDirectory; + Scan(); + } + + void ContentBrowserPanel::Scan() + { + m_CurrentAssets.clear(); + std::error_code ec; + + if (!std::filesystem::exists(m_CurrentDirectory, ec)) + { + return; + } + + for (auto& p : std::filesystem::directory_iterator(m_CurrentDirectory, ec)) + { + AssetEntry entry; + entry.name = p.path().filename().string(); + entry.path = p.path(); + entry.isDirectory = p.is_directory(); + entry.type = DetermineAssetType(p.path()); + + if (!m_FilterQuery.empty()) + { + std::string nameLower = entry.name; + std::transform(nameLower.begin(), nameLower.end(), nameLower.begin(), ::tolower); + if (nameLower.find(m_FilterQuery) == std::string::npos) + { + continue; + } + } + + if (!entry.isDirectory && m_ContentFilterType > 0) + { + static constexpr EditorAssetType kFilterTypes[] = {EditorAssetType::Scene, EditorAssetType::Prefab, + EditorAssetType::Model, EditorAssetType::Texture, + EditorAssetType::Script, EditorAssetType::Audio}; + static constexpr int kFilterTypeCount = sizeof(kFilterTypes) / sizeof(kFilterTypes[0]); + + bool match = false; + if (m_ContentFilterType - 1 < kFilterTypeCount) + { + match = (entry.type == kFilterTypes[m_ContentFilterType - 1]); + } + if (!match) + { + continue; + } + } + + m_CurrentAssets.push_back(entry); + } + + std::sort(m_CurrentAssets.begin(), m_CurrentAssets.end(), [](const AssetEntry& a, const AssetEntry& b) { + if (a.isDirectory != b.isDirectory) + { + return a.isDirectory > b.isDirectory; + } + return a.name < b.name; + }); + } + + EditorAssetType ContentBrowserPanel::DetermineAssetType(const std::filesystem::path& path) + { + if (std::filesystem::is_directory(path)) + { + return EditorAssetType::Directory; + } + + static const std::unordered_map s_ExtensionMap = { + {".chscene", EditorAssetType::Scene}, {".chmap", EditorAssetType::Scene}, + {".chprefab", EditorAssetType::Prefab}, {".h", EditorAssetType::Script}, + {".cpp", EditorAssetType::Script}, {".cs", EditorAssetType::Script}, + {".obj", EditorAssetType::Model}, {".gltf", EditorAssetType::Model}, + {".glb", EditorAssetType::Model}, {".png", EditorAssetType::Texture}, + {".jpg", EditorAssetType::Texture}, {".tga", EditorAssetType::Texture}, + {".bmp", EditorAssetType::Texture}, {".wav", EditorAssetType::Audio}, + {".ogg", EditorAssetType::Audio}, {".mp3", EditorAssetType::Audio}, + {".glsl", EditorAssetType::Shader}, {".vs", EditorAssetType::Shader}, + {".fs", EditorAssetType::Shader}, {".vert", EditorAssetType::Shader}, + {".frag", EditorAssetType::Shader}}; + + std::string ext = path.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + + auto it = s_ExtensionMap.find(ext); + return (it != s_ExtensionMap.end()) ? it->second : EditorAssetType::Other; + } + +} // namespace Chained diff --git a/editor/panels/content_browser_panel.h b/editor/panels/content_browser_panel.h index 5f784a580..e704d36de 100644 --- a/editor/panels/content_browser_panel.h +++ b/editor/panels/content_browser_panel.h @@ -1,78 +1,75 @@ #ifndef CH_CONTENT_BROWSER_PANEL_H #define CH_CONTENT_BROWSER_PANEL_H +#include "editor/asset_types.h" #include "panel.h" -#include -#include -#include #include -namespace CHEngine +namespace Chained { -enum class EditorAssetType -{ - Directory, - Scene, - Script, - Model, - Texture, - Audio, - Prefab, - Other -}; - -struct AssetEntry -{ - std::string name; - std::filesystem::path path; - EditorAssetType type; - uint32_t icon; - bool isDirectory; -}; - -class ContentBrowserPanel : public Panel -{ -public: - ContentBrowserPanel(); - ~ContentBrowserPanel(); - - virtual void OnImGuiRender(bool readOnly = false) override; - virtual void OnEvent(Event& e) override; - void SetRootDirectory(const std::filesystem::path& path); - -private: - void RenderToolbar(); - void RenderGridView(); - void RefreshDirectory(); - void ScanCurrentDirectory(); - -private: - EditorAssetType DetermineAssetType(const std::filesystem::path& path); - void LoadDefaultIcons(); - uint32_t GetIconForAsset(const AssetEntry& entry); - void OnAssetDoubleClicked(AssetEntry& entry); - -private: - std::filesystem::path m_RootDirectory; - std::filesystem::path m_CurrentDirectory; - std::vector m_CurrentAssets; - - float m_ThumbnailSize = 96.0f; - float m_Padding = 16.0f; - - // Filtering - char m_FilterBuffer[128] = ""; - int m_FilterType = 0; // 0 = All, or specific type - - uint32_t m_FolderIcon = 0; - uint32_t m_FileIcon = 0; - float m_IconScale = 1.0f; - - // Asset Management - std::filesystem::path m_RenamingPath; - char m_RenameBuffer[256] = ""; - std::filesystem::path m_PathToDelete; -}; -} // namespace CHEngine + + class ContentBrowserPanel : public Panel + { + public: + ContentBrowserPanel(); + ~ContentBrowserPanel() override; + + void OnImGuiRender(bool readOnly = false) override; + void OnEvent(Event& e) override; + + private: + void RenderToolbar(); + void RenderGridView(); + + void OnAssetDoubleClicked(const AssetEntry& entry); + + void Scan(); + EditorAssetType DetermineAssetType(const std::filesystem::path& path); + + const std::vector& GetAssets() const + { + return m_CurrentAssets; + } + const std::filesystem::path& GetCurrentDirectory() const + { + return m_CurrentDirectory; + } + const std::filesystem::path& GetRootDirectory() const + { + return m_RootDirectory; + } + + void SetRoot(const std::filesystem::path& path); + void SetFilter(const std::string& query, int typeFilter); + void Refresh(); + void Navigate(const std::filesystem::path& path); + void GoUp(); + void GoToRoot(); + + private: + std::filesystem::path m_RootDirectory; + std::filesystem::path m_CurrentDirectory; + std::vector m_CurrentAssets; + + std::string m_FilterQuery; + int m_ContentFilterType = 0; + + float m_ThumbnailSize = 96.0f; + float m_Padding = 16.0f; + float m_IconScale = 1.0f; + + char m_FilterBuffer[128] = ""; + int m_FilterType = 0; + + std::filesystem::path m_RenamingPath; + char m_RenameBuffer[256] = ""; + std::filesystem::path m_PathToDelete; + std::filesystem::path m_NextDirectory; + bool m_OpenRenamePopup = false; + bool m_OpenDeletePopup = false; + bool m_PendingRefresh = false; + }; + +} // namespace Chained #endif // CH_CONTENT_BROWSER_PANEL_H diff --git a/editor/panels/effects_panel.cpp b/editor/panels/effects_panel.cpp index 70396c02d..235cfac72 100644 --- a/editor/panels/effects_panel.cpp +++ b/editor/panels/effects_panel.cpp @@ -1,68 +1,75 @@ #include "effects_panel.h" -#include "editor/editor_layer.h" +#include "editor/layer.h" #include "engine/graphics/pipeline/renderer.h" #include "scene/scene.h" +#include "engine/core/service_locator.h" -namespace CHEngine +namespace Chained { -EffectsPanel::EffectsPanel() -{ - m_Name = "Effects & Debug"; -} - -void EffectsPanel::OnImGuiRender(bool readOnly) -{ - if (!m_IsOpen) - { - return; - } + EffectsPanel::EffectsPanel() + { + m_Name = "Effects & Debug"; + m_IsOpen = false; + } - ImGui::Begin(m_Name.c_str(), &m_IsOpen); + void EffectsPanel::OnImGuiRender(bool readOnly) + { + if (!m_IsOpen) + { + return; + } - if (!m_Context) - { - ImGui::Text("No active scene."); - ImGui::End(); - return; - } + ImGui::Begin(m_Name.c_str(), &m_IsOpen); - if (ImGui::CollapsingHeader("Viewport Settings", ImGuiTreeNodeFlags_DefaultOpen)) - { - const char* diagnosticModes[] = {"Full Render", "Normals", "Lighting only", "Albedo only"}; - int currentDiag = (int)m_Context->GetSettings().DiagnosticMode; - if (ImGui::Combo("Diagnostic Mode", ¤tDiag, diagnosticModes, 4)) - { - m_Context->GetSettings().DiagnosticMode = (float)currentDiag; - Renderer::Get().SetDiagnosticMode((float)currentDiag); - } - } + if (!m_Context) + { + ImGui::Text("No active scene."); + ImGui::End(); + return; + } - if (ImGui::CollapsingHeader("Debug Visualization", ImGuiTreeNodeFlags_DefaultOpen)) - { - auto& debugFlags = m_Context->GetSettings().DebugFlags; - ImGui::Checkbox("Physics (Colliders)", &debugFlags.DrawColliders); - if (debugFlags.DrawColliders) - { - const char* wireModes[] = {"Wireframe", "Solid"}; - ImGui::Combo("Wireframe Mode", &debugFlags.SetCollisionWireframeMode, wireModes, 2); - } - ImGui::Checkbox("AABB Boxes", &debugFlags.DrawCollisionModelBox); - ImGui::Checkbox("Lights", &debugFlags.DrawLights); - ImGui::Checkbox("Spawn Zones", &debugFlags.DrawSpawnZones); - ImGui::Checkbox("Draw Grid", &debugFlags.DrawGrid); + if (ImGui::CollapsingHeader("Viewport Settings", ImGuiTreeNodeFlags_DefaultOpen)) + { + const char* diagnosticModes[] = {"Full Render", "Normals", "Lighting only", "Albedo only"}; + int currentDiag = (int)m_Context->GetSettings().DiagnosticMode; + if (ImGui::Combo("Diagnostic Mode", ¤tDiag, diagnosticModes, 4)) + { + m_Context->GetSettings().DiagnosticMode = (float)currentDiag; + if (auto* renderer = ServiceLocator::TryGet()) + { + renderer->SetDiagnosticMode((float)currentDiag); + } + } + } - if (debugFlags.DrawGrid) - { - auto& grid = m_Context->GetSettings().Grid; - ImGui::Indent(12.0f); - ImGui::DragInt("Slices", &grid.Slices, 1, 4, 200); - ImGui::DragFloat("Spacing", &grid.Spacing, 0.1f, 0.1f, 50.0f); - ImGui::Unindent(12.0f); - } - } + if (ImGui::CollapsingHeader("Debug Visualization", ImGuiTreeNodeFlags_DefaultOpen)) + { + auto& debugFlags = m_Context->GetSettings().DebugFlags; + ImGui::Checkbox("Physics (Colliders)", &debugFlags.DrawColliders); + if (debugFlags.DrawColliders) + { + const char* wireModes[] = {"Wireframe", "Solid", "Solid + Wireframe"}; + ImGui::Combo("Visual Mode", &debugFlags.SetCollisionWireframeMode, wireModes, 3); + } + ImGui::Checkbox("Lights", &debugFlags.DrawLights); + ImGui::Checkbox("Spawn Zones", &debugFlags.DrawSpawnZones); + ImGui::Checkbox("Draw Grid", &debugFlags.DrawGrid); + if (debugFlags.DrawGrid) + { + auto& grid = m_Context->GetSettings().Grid; + ImGui::Indent(12.0f); + ImGui::DragFloat("Spacing", &grid.Spacing, 0.1f, 0.01f, 50.0f); + ImGui::DragFloat("Secondary Spacing", &grid.SecondarySpacing, 1.0f, 1.0f, 100.0f); + ImGui::ColorEdit4("Color", &grid.Color.x); + ImGui::DragFloat("Fade Start", &grid.FadeStart, 10.0f, 0.0f, 10000.0f); + ImGui::DragFloat("Fade End", &grid.FadeEnd, 10.0f, 100.0f, 50000.0f); + ImGui::DragFloat("Plane Size", &grid.PlaneSize, 100.0f, 100.0f, 50000.0f); + ImGui::Unindent(12.0f); + } + } - ImGui::End(); -} + ImGui::End(); + } -} // namespace CHEngine +} // namespace Chained diff --git a/editor/panels/effects_panel.h b/editor/panels/effects_panel.h index 3637234f9..d1e2704a3 100644 --- a/editor/panels/effects_panel.h +++ b/editor/panels/effects_panel.h @@ -3,16 +3,16 @@ #include "panel.h" -namespace CHEngine +namespace Chained { -class EffectsPanel : public Panel -{ -public: - EffectsPanel(); + class EffectsPanel : public Panel + { + public: + EffectsPanel(); -public: - virtual void OnImGuiRender(bool readOnly = false) override; -}; -} // namespace CHEngine + public: + virtual void OnImGuiRender(bool readOnly = false) override; + }; +} // namespace Chained #endif // CH_EFFECTS_PANEL_H diff --git a/editor/panels/inspector_panel.cpp b/editor/panels/inspector_panel.cpp index 204bced7c..0b90bce70 100644 --- a/editor/panels/inspector_panel.cpp +++ b/editor/panels/inspector_panel.cpp @@ -1,94 +1,96 @@ #include "inspector_panel.h" -#include "IconsFontAwesome6.h" -#include "editor_gui.h" -#include "engine/core/assets/asset_manager.h" -#include "engine/graphics/assets/model_asset.h" +#include "thirdparty/IconsFontAwesome6.h" +#include "gui.h" +#include "engine/assets/asset_manager.h" +#include "engine/assets/types/model_asset.h" #include "engine/scene/components.h" -#include "engine/scene/project.h" +#include "engine/project/project.h" #include "engine/scene/scene_events.h" #include "imgui.h" #include "property_editor.h" -namespace CHEngine +namespace Chained { -InspectorPanel::InspectorPanel() -{ - m_Name = "Inspector"; -} + InspectorPanel::InspectorPanel() + { + m_Name = "Inspector"; + } -void InspectorPanel::OnImGuiRender(bool readOnly) -{ - if (!m_IsOpen) - { - return; - } + void InspectorPanel::OnImGuiRender(bool readOnly) + { + if (!m_IsOpen) + { + return; + } - ImGui::Begin(m_Name.c_str(), &m_IsOpen); + ImGui::Begin(m_Name.c_str(), &m_IsOpen); - if (m_SelectedEntity && !m_SelectedEntity.IsValid()) - { - m_SelectedEntity = {}; - } + if (m_SelectedEntity && (!m_Context || m_SelectedEntity.GetRegistryPtr() != m_Context->GetRegistryPtr() || + !m_SelectedEntity.IsValid())) + { + m_SelectedEntity = {}; + } - if (m_SelectedEntity && m_SelectedEntity.IsValid()) - { + if (m_SelectedEntity) + { - DrawComponents(m_SelectedEntity, readOnly); - } - else - { - ImGui::Text("Selection: None"); - ImGui::TextDisabled("Select an entity in the Hierarchy to view its components."); - } - ImGui::End(); -} + bool isTransitioning = EditorLayer::Get().GetSceneManager().IsTransitioning(); + DrawComponents(m_SelectedEntity, readOnly || isTransitioning); + } + else + { + ImGui::Text("Selection: None"); + ImGui::TextDisabled("Select an entity in the Hierarchy to view its components."); + } + ImGui::End(); + } -void InspectorPanel::OnEvent(Event& e) -{ - EventDispatcher dispatcher(e); - dispatcher.Dispatch([this](EntitySelectedEvent& ev) { - if (m_Context && ev.GetEntity() != entt::null) - { - m_SelectedEntity = Entity(ev.GetEntity(), m_Context->GetRegistry()); - } - else - { - m_SelectedEntity = {}; - } - m_SelectedMeshIndex = ev.GetMeshIndex(); - return false; - }); -} + void InspectorPanel::OnEvent(Event& e) + { + EventDispatcher dispatcher(e); + dispatcher.Dispatch([this](EntitySelectedEvent& ev) { + if (m_Context && ev.GetEntity() != entt::null) + { + m_SelectedEntity = Entity(ev.GetEntity(), m_Context->GetRegistryPtr()); + } + else + { + m_SelectedEntity = {}; + } + m_SelectedMeshIndex = ev.GetMeshIndex(); + return false; + }); + } -void InspectorPanel::SetContext(const std::shared_ptr& context) -{ - if (m_Context.get() != context.get()) - { - m_SelectedEntity = {}; - } - Panel::SetContext(context); -} + void InspectorPanel::SetContext(const std::shared_ptr& context) + { + if (m_Context.get() != context.get()) + { + m_SelectedEntity = {}; + } + Panel::SetContext(context); + } -void InspectorPanel::DrawComponents(Entity entity, bool readOnly) -{ - ImGui::PushID((uint32_t)entity); + void InspectorPanel::DrawComponents(Entity entity, bool readOnly) + { + ImGui::PushID((uint32_t)entity); - if (entity.HasComponent()) - { - uint64_t uuid = (uint64_t)entity.GetComponent().ID; - ImGui::TextDisabled("UUID: %llu", uuid); - } + if (entity.HasComponent()) + { + uint64_t uuid = (uint64_t)entity.GetComponent().ID; + ImGui::TextDisabled("UUID: %llu", uuid); + } - PropertyEditor::DrawEntityHeader(entity); + PropertyEditor::DrawEntityHeader(entity); - // Delegate all component drawing logic to PropertyEditor registry - PropertyEditor::DrawEntityProperties(entity); + // Delegate all component drawing logic to PropertyEditor registry + PropertyEditor::DrawEntityProperties(entity); - ImGui::PopID(); -} -void InspectorPanel::SetSelectedMeshIndex(int index) -{ - m_SelectedMeshIndex = index; -} -} // namespace CHEngine + ImGui::PopID(); + } + void InspectorPanel::SetSelectedMeshIndex(int index) + { + m_SelectedMeshIndex = index; + } +} // namespace Chained diff --git a/editor/panels/inspector_panel.h b/editor/panels/inspector_panel.h index 22e804a9a..fada1b47e 100644 --- a/editor/panels/inspector_panel.h +++ b/editor/panels/inspector_panel.h @@ -3,25 +3,26 @@ #include "panel.h" -namespace CHEngine +namespace Chained { -class InspectorPanel : public Panel -{ -public: - InspectorPanel(); - virtual void OnImGuiRender(bool readOnly = false) override; - virtual void OnEvent(Event& e) override; - virtual void SetContext(const std::shared_ptr& context) override; -public: - void SetSelectedMeshIndex(int index); + class InspectorPanel : public Panel + { + public: + InspectorPanel(); + virtual void OnImGuiRender(bool readOnly = false) override; + virtual void OnEvent(Event& e) override; + virtual void SetContext(const std::shared_ptr& context) override; + + public: + void SetSelectedMeshIndex(int index); -private: - void DrawComponents(Entity entity, bool readOnly); + private: + void DrawComponents(Entity entity, bool readOnly); -private: - Entity m_SelectedEntity; - int m_SelectedMeshIndex = -1; -}; -} // namespace CHEngine + private: + Entity m_SelectedEntity; + int m_SelectedMeshIndex = -1; + }; +} // namespace Chained #endif // CH_INSPECTOR_PANEL_H diff --git a/editor/panels/material_panel.cpp b/editor/panels/material_panel.cpp index 19d75ea57..14d462c8d 100644 --- a/editor/panels/material_panel.cpp +++ b/editor/panels/material_panel.cpp @@ -1,163 +1,539 @@ #include "material_panel.h" -#include "engine/scene/components/mesh_component.h" +#include "engine/scene/components/render/model_component.h" #include "engine/scene/scene_events.h" #include "imgui.h" #include "property_editor.h" #include "ui_properties.h" -#include "engine/graphics/texture_system.h" +#include "engine/assets/types/texture_asset.h" +#include "engine/core/service_locator.h" +#include "engine/assets/asset_manager.h" +#include "engine/assets/types/model_asset.h" +#include "engine/assets/types/material_asset.h" +#include "editor/layer.h" +#include -namespace CHEngine +namespace Chained { -MaterialPanel::MaterialPanel() -{ - m_Name = "Material Editor"; -} + MaterialPanel::MaterialPanel() + { + m_Name = "Material Editor"; + } -static uint32_t GetTextureID(const std::string& path) -{ - if (path.empty()) return 0; - auto textureHandle = TextureSystem::Get().LoadTexture(path); - return TextureSystem::Get().GetRendererID(textureHandle); -} + static uint32_t GetTextureID(const std::shared_ptr& map, const std::string& path) + { + if (map) + { + return map->GetNativeHandle(); + } + if (path.empty()) + { + return 0; + } + auto* am = ServiceLocator::TryGet(); + if (am) + { + auto texAsset = am->Get(path); + if (texAsset && texAsset->IsReady()) + { + auto gpuTex = texAsset->GetTexture(); + if (gpuTex) + { + return gpuTex->GetNativeHandle(); + } + } + } + return 0; + } -void MaterialPanel::DrawMaterialSlot(MaterialSlot& slot) -{ - MaterialInstance& mat = slot.Material; - - if (ImGui::CollapsingHeader(ICON_FA_IMAGE " Albedo", ImGuiTreeNodeFlags_DefaultOpen)) - { - EditorGUI::Property("Color", mat.AlbedoColor); - mat.OverrideAlbedo |= EditorGUI::FileProperty("Texture", mat.AlbedoPath, GetTextureID(mat.AlbedoPath), "png,jpg,tga"); - ImGui::Checkbox("Override Albedo", &mat.OverrideAlbedo); - } - - if (ImGui::CollapsingHeader(ICON_FA_WATER " Normals", ImGuiTreeNodeFlags_DefaultOpen)) - { - mat.OverrideNormal |= EditorGUI::FileProperty("Normal Map", mat.NormalMapPath, GetTextureID(mat.NormalMapPath), "png,jpg,tga"); - ImGui::Checkbox("Override Normal", &mat.OverrideNormal); - } - - if (ImGui::CollapsingHeader(ICON_FA_CIRCLE_HALF_STROKE " PBR (Metal/Rough)", ImGuiTreeNodeFlags_DefaultOpen)) - { - ImGui::Columns(2); - ImGui::SetColumnWidth(0, 100.0f); - - ImGui::Text("Metalness"); ImGui::NextColumn(); - ImGui::SliderFloat("##metal", &mat.Metalness, 0.0f, 1.0f); ImGui::NextColumn(); - - ImGui::Text("Roughness"); ImGui::NextColumn(); - ImGui::SliderFloat("##rough", &mat.Roughness, 0.0f, 1.0f); ImGui::NextColumn(); - - ImGui::Columns(1); - - mat.OverrideMetallicRoughness |= EditorGUI::FileProperty("PBR Map", mat.MetallicRoughnessPath, GetTextureID(mat.MetallicRoughnessPath), "png,jpg,tga"); - ImGui::Checkbox("Override PBR", &mat.OverrideMetallicRoughness); - } - - if (ImGui::CollapsingHeader(ICON_FA_SUN " Emissive", ImGuiTreeNodeFlags_DefaultOpen)) - { - EditorGUI::Property("Emissive Color", mat.EmissiveColor); - EditorGUI::Property("Intensity", mat.EmissiveIntensity); - mat.OverrideEmissive |= EditorGUI::FileProperty("Emissive Map", mat.EmissivePath, GetTextureID(mat.EmissivePath), "png,jpg,tga"); - ImGui::Checkbox("Override Emissive", &mat.OverrideEmissive); - } - - if (ImGui::CollapsingHeader(ICON_FA_GEARS " Settings")) - { - ImGui::Checkbox("Double Sided", &mat.DoubleSided); - ImGui::Checkbox("Transparent", &mat.Transparent); - ImGui::SliderFloat("Alpha", &mat.Alpha, 0.0f, 1.0f); - } -} - -void MaterialPanel::OnImGuiRender(bool readOnly) -{ - if (!m_IsOpen) return; - - ImGui::Begin(m_Name.c_str(), &m_IsOpen); - - if (m_SelectedEntity && (!m_SelectedEntity.IsValid() || m_SelectedEntity.GetRegistry().ctx().get() != m_Context.get())) - { - m_SelectedEntity = {}; - } - - if (m_SelectedEntity && m_SelectedEntity.IsValid()) - { - ImGui::BeginDisabled(readOnly); - - std::vector* materials = nullptr; - - if (m_SelectedEntity.HasComponent()) - materials = &m_SelectedEntity.GetComponent().Materials; - else if (m_SelectedEntity.HasComponent()) - materials = &m_SelectedEntity.GetComponent().Materials; - - if (materials && !materials->empty()) - { - // Material Selection Sidebar / List - ImGui::BeginChild("MaterialList", ImVec2(150, 0), true); - for (int i = 0; i < (int)materials->size(); i++) - { - std::string label = (*materials)[i].Name; - if (label.empty()) label = "Material " + std::to_string(i); - - if (ImGui::Selectable(label.c_str(), m_SelectedMaterialIndex == i)) - m_SelectedMaterialIndex = i; - } - ImGui::EndChild(); - - ImGui::SameLine(); - - // Material Properties - ImGui::BeginChild("MaterialProperties"); - if (m_SelectedMaterialIndex < (int)materials->size()) - { - ImGui::TextColored({0.2f, 0.8f, 1.0f, 1.0f}, "Editing: %s", (*materials)[m_SelectedMaterialIndex].Name.c_str()); - ImGui::Separator(); - DrawMaterialSlot((*materials)[m_SelectedMaterialIndex]); - } - else - { - m_SelectedMaterialIndex = 0; - } - ImGui::EndChild(); - } - else - { - ImGui::TextColored({0.8f, 0.8f, 0.2f, 1.0f}, ICON_FA_CIRCLE_INFO " No Materials found for this entity"); - if (ImGui::Button(ICON_FA_PLUS " Add Materials Override Component")) - { - m_SelectedEntity.AddComponent(); - } - } - - ImGui::EndDisabled(); - } - else - { - ImGui::Text("No entity selected."); - ImGui::TextDisabled("Select an entity in the Hierarchy to edit its materials."); - } - - ImGui::End(); -} - -void MaterialPanel::OnEvent(Event& e) -{ - EventDispatcher dispatcher(e); - dispatcher.Dispatch([this](EntitySelectedEvent& ev) { - m_SelectedEntity = Entity(ev.GetEntity(), &ev.GetScene()->GetRegistry()); - m_SelectedMeshIndex = ev.GetMeshIndex(); - m_SelectedMaterialIndex = 0; - return false; - }); -} - -void MaterialPanel::SetContext(const std::shared_ptr& context) -{ - Panel::SetContext(context); - m_SelectedEntity = {}; -} + static void UpdateTextureFromPath(std::shared_ptr& outMap, const std::string& path) + { + auto* am = ServiceLocator::TryGet(); + if (!am || path.empty()) + { + return; + } + auto texAsset = am->Get(path); + if (texAsset && texAsset->IsReady()) + { + outMap = texAsset->GetTexture(); + } + } + + static bool DrawSectionHeader(const char* icon, const char* label) + { + ImGui::PushStyleColor(ImGuiCol_Header, {0.2f, 0.25f, 0.35f, 0.8f}); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, {0.3f, 0.4f, 0.6f, 1.0f}); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, {0.25f, 0.35f, 0.5f, 1.0f}); + bool open = ImGui::CollapsingHeader(label, ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_Framed); + ImGui::PopStyleColor(3); + return open; + } + + void MaterialPanel::DrawMaterialSlot(Material& mat) + { + if (DrawSectionHeader(ICON_FA_IMAGE, ICON_FA_IMAGE " Albedo")) + { + ImGui::Indent(); + EditorGUI::BeginPropertyGrid(); + EditorGUI::PropertyColor("Color", mat.AlbedoColor); + if (EditorGUI::FileProperty("Texture", mat.AlbedoPath, GetTextureID(mat.AlbedoMap, mat.AlbedoPath), + "png,jpg,tga")) + { + UpdateTextureFromPath(mat.AlbedoMap, mat.AlbedoPath); + } + EditorGUI::EndPropertyGrid(); + ImGui::Unindent(); + } + + if (DrawSectionHeader(ICON_FA_WATER, ICON_FA_WATER " Normals")) + { + ImGui::Indent(); + EditorGUI::BeginPropertyGrid(); + if (EditorGUI::FileProperty("Normal Map", mat.NormalPath, GetTextureID(mat.NormalMap, mat.NormalPath), + "png,jpg,tga")) + { + UpdateTextureFromPath(mat.NormalMap, mat.NormalPath); + } + EditorGUI::EndPropertyGrid(); + ImGui::Unindent(); + } + + if (DrawSectionHeader(ICON_FA_CIRCLE_HALF_STROKE, ICON_FA_CIRCLE_HALF_STROKE " PBR (Metal/Rough)")) + { + ImGui::Indent(); + EditorGUI::BeginPropertyGrid(); + EditorGUI::Property("Metalness", mat.Metalness, 0.01f, 0.0f, 1.0f); + EditorGUI::Property("Roughness", mat.Roughness, 0.01f, 0.0f, 1.0f); + if (EditorGUI::FileProperty("PBR Map", mat.MetallicRoughnessPath, + GetTextureID(mat.MetallicRoughnessMap, mat.MetallicRoughnessPath), + "png,jpg,tga")) + { + UpdateTextureFromPath(mat.MetallicRoughnessMap, mat.MetallicRoughnessPath); + } + EditorGUI::EndPropertyGrid(); + ImGui::TextDisabled("PBR map: G = Roughness, B = Metalness (glTF convention)"); + ImGui::Unindent(); + } + + if (DrawSectionHeader(ICON_FA_SUN, ICON_FA_SUN " Emissive")) + { + ImGui::Indent(); + EditorGUI::BeginPropertyGrid(); + EditorGUI::PropertyColor("Color", mat.EmissiveColor, /*hdr*/ true); + EditorGUI::Property("Intensity", mat.EmissiveIntensity, 0.05f, 0.0f, 1000.0f); + if (EditorGUI::FileProperty("Emissive Map", mat.EmissivePath, + GetTextureID(mat.EmissiveMap, mat.EmissivePath), "png,jpg,tga")) + { + UpdateTextureFromPath(mat.EmissiveMap, mat.EmissivePath); + } + EditorGUI::EndPropertyGrid(); + ImGui::Unindent(); + } + + if (DrawSectionHeader(ICON_FA_GEARS, ICON_FA_GEARS " Settings")) + { + ImGui::Indent(); + EditorGUI::BeginPropertyGrid(); + EditorGUI::Property("Transparent", mat.Transparent); + ImGui::BeginDisabled(!mat.Transparent); + EditorGUI::Property("Alpha", mat.Alpha, 0.01f, 0.0f, 1.0f); + ImGui::EndDisabled(); + EditorGUI::EndPropertyGrid(); + ImGui::Unindent(); + } + } + + void MaterialPanel::OnImGuiRender(bool readOnly) + { + if (!m_IsOpen) + { + return; + } + + ImGui::Begin(m_Name.c_str(), &m_IsOpen); + + if (m_SelectedEntity && (!m_Context || m_SelectedEntity.GetRegistryPtr() != m_Context->GetRegistryPtr() || + !m_SelectedEntity.IsValid())) + { + m_SelectedEntity = {}; + } + + if (m_SelectedEntity) + { + ImGui::BeginDisabled(readOnly); + + std::vector* materials = nullptr; + + if (m_SelectedEntity.HasComponent()) + { + auto& mc = m_SelectedEntity.GetComponent(); + + // Reload materials if: path changed, or materials not yet loaded while asset is ready. + bool pathChanged = (mc.ModelPath != m_LoadedModelPath); + bool shouldLoad = !mc.ModelPath.empty() && (pathChanged || m_Materials.empty()); + if (shouldLoad) + { + auto* assetMgr = ServiceLocator::TryGet(); + if (assetMgr) + { + auto asset = assetMgr->Get(mc.ModelPath); + if (asset && asset->IsReady()) + { + m_Materials = asset->GetMaterials(); + m_LoadedModelPath = mc.ModelPath; + + std::filesystem::path modelPath(mc.ModelPath); + std::string modelName = modelPath.stem().string(); + std::filesystem::path modelDir = modelPath.parent_path(); + + for (size_t i = 0; i < m_Materials.size(); ++i) + { + if (i < mc.MaterialPaths.size() && !mc.MaterialPaths[i].empty()) + { + auto matAsset = assetMgr->Get(mc.MaterialPaths[i]); + if (matAsset && matAsset->IsReady()) + { + m_Materials[i] = matAsset->GetMaterial(); + } + } + else + { + std::string matFileName = modelName + "_material_" + std::to_string(i) + ".chmat"; + std::string autoMatRel = (modelDir / matFileName).generic_string(); + if (assetMgr->FileExists(autoMatRel)) + { + auto matAsset = assetMgr->Get(autoMatRel); + if (matAsset && matAsset->IsReady()) + { + m_Materials[i] = matAsset->GetMaterial(); + if (i >= mc.MaterialPaths.size()) + { + mc.MaterialPaths.resize(i + 1); + } + mc.MaterialPaths[i] = autoMatRel; + } + } + } + } + } + } + } + + materials = &m_Materials; + } + + if (materials && !materials->empty()) + { + // Material Selection Sidebar / List + ImGui::BeginChild("MaterialList", ImVec2(180, 0), true); + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputTextWithHint("##MatFilter", ICON_FA_MAGNIFYING_GLASS " Search...", m_FilterBuffer, + sizeof(m_FilterBuffer)); + ImGui::Separator(); + + std::string filterStr = m_FilterBuffer; + std::transform(filterStr.begin(), filterStr.end(), filterStr.begin(), ::tolower); + + for (int i = 0; i < (int)materials->size(); i++) + { + const Material& m = (*materials)[i]; + std::string label = m.Name; + if (label.empty()) + { + label = "Material " + std::to_string(i); + } + + if (!filterStr.empty()) + { + std::string lowerLabel = label; + std::transform(lowerLabel.begin(), lowerLabel.end(), lowerLabel.begin(), ::tolower); + if (lowerLabel.find(filterStr) == std::string::npos) + { + continue; + } + } + + ImGui::PushID(i); + uint32_t texHandle = GetTextureID(m.AlbedoMap, m.AlbedoPath); + if (texHandle != 0) + { + ImGui::Image((ImTextureID)(uintptr_t)texHandle, ImVec2(14, 14)); + } + else + { + ImVec4 swatch = {m.AlbedoColor.r, m.AlbedoColor.g, m.AlbedoColor.b, 1.0f}; + ImGui::ColorButton("##swatch", swatch, + ImGuiColorEditFlags_NoTooltip | ImGuiColorEditFlags_NoPicker | + ImGuiColorEditFlags_NoBorder, + {14, 14}); + } + ImGui::SameLine(); + if (ImGui::Selectable(label.c_str(), m_SelectedMaterialIndex == i)) + { + m_SelectedMaterialIndex = i; + } + ImGui::PopID(); + } + ImGui::EndChild(); + + ImGui::SameLine(); + + // Material Properties — leave room for Save button + float availH = ImGui::GetContentRegionAvail().y; + float buttonArea = 50.0f; + ImGui::BeginChild("MaterialProperties", ImVec2(0, availH - buttonArea)); + if (m_SelectedMaterialIndex < (int)materials->size()) + { + Material& selected = (*materials)[m_SelectedMaterialIndex]; + std::string title = + selected.Name.empty() ? ("Material " + std::to_string(m_SelectedMaterialIndex)) : selected.Name; + ImGui::TextColored({0.2f, 0.8f, 1.0f, 1.0f}, ICON_FA_PALETTE " Editing: %s", title.c_str()); + ImGui::Separator(); + ImGui::Spacing(); + DrawMaterialSlot(selected); + + // Live update in memory so the viewport reflects changes immediately + if (auto* assetMgr = ServiceLocator::TryGet()) + { + if (m_SelectedEntity.HasComponent()) + { + auto& mc = m_SelectedEntity.GetComponent(); + auto modelAsset = assetMgr->Get(mc.ModelPath); + if (modelAsset) + { + modelAsset->GetMaterials() = m_Materials; + } + if (m_SelectedMaterialIndex < (int)mc.MaterialPaths.size() && + !mc.MaterialPaths[m_SelectedMaterialIndex].empty()) + { + auto matAsset = assetMgr->Get(mc.MaterialPaths[m_SelectedMaterialIndex]); + if (matAsset) + { + matAsset->SetMaterial(selected); + } + } + } + } + } + else + { + m_SelectedMaterialIndex = 0; + } + ImGui::EndChild(); + + // Save & Delete buttons + ImGui::Separator(); + float buttonWidth = ImGui::GetContentRegionAvail().x * 0.5f - 4.0f; + if (ImGui::Button(ICON_FA_FLOPPY_DISK " Save Materials", ImVec2(buttonWidth, 0))) + { + SaveMaterials(); + ImGui::OpenPopup("Materials Saved"); + } + + if (ImGui::BeginPopup("Materials Saved")) + { + ImGui::Text("Materials saved successfully!"); + ImGui::EndPopup(); + } + + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, {0.7f, 0.2f, 0.2f, 1.0f}); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, {0.85f, 0.3f, 0.3f, 1.0f}); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, {0.6f, 0.15f, 0.15f, 1.0f}); + if (ImGui::Button(ICON_FA_TRASH " Delete .chmat", ImVec2(-1, 0))) + { + DeleteMaterials(); + ImGui::OpenPopup("Materials Deleted"); + } + ImGui::PopStyleColor(3); + + if (ImGui::BeginPopup("Materials Deleted")) + { + ImGui::Text("Deleted .chmat files and restored model defaults!"); + ImGui::EndPopup(); + } + } + else + { + ImGui::TextColored({0.8f, 0.8f, 0.2f, 1.0f}, ICON_FA_CIRCLE_INFO " No Materials found for this entity"); + } + + ImGui::EndDisabled(); + } + else + { + ImGui::Text("No entity selected."); + ImGui::TextDisabled("Select an entity in the Hierarchy to edit its materials."); + } + + ImGui::End(); + } + + void MaterialPanel::SaveMaterials() + { + if (!m_SelectedEntity || !m_SelectedEntity.IsValid()) + { + return; + } + + if (!m_SelectedEntity.HasComponent()) + { + return; + } + if (m_Materials.empty()) + { + return; + } + + auto& mc = m_SelectedEntity.GetComponent(); + std::filesystem::path modelPath(mc.ModelPath); + std::string modelName = modelPath.stem().string(); + std::filesystem::path modelDir = modelPath.parent_path(); + + auto* assets = ServiceLocator::TryGet(); + if (!assets) + { + return; + } + + for (int i = 0; i < (int)m_Materials.size(); i++) + { + std::string matFileName = modelName + "_material_" + std::to_string(i) + ".chmat"; + std::string matPath; + + if (i < (int)mc.MaterialPaths.size() && !mc.MaterialPaths[i].empty()) + { + matPath = mc.MaterialPaths[i]; + } + else + { + matPath = (modelDir / matFileName).generic_string(); + } + + auto matAsset = std::make_shared(); + matAsset->SetMaterial(m_Materials[i]); + matAsset->SaveToFile(assets->ResolvePath(matPath)); + + if (i >= (int)mc.MaterialPaths.size()) + { + mc.MaterialPaths.resize(i + 1); + } + mc.MaterialPaths[i] = matPath; + + // Invalidate asset cache and update loaded instance + assets->Invalidate(matPath); + auto loadedMat = assets->Get(matPath); + if (loadedMat) + { + loadedMat->SetMaterial(m_Materials[i]); + } + } + + // Write back to ModelAsset so renderer picks up changes immediately + auto modelAsset = assets->Get(mc.ModelPath); + if (modelAsset) + { + modelAsset->GetMaterials() = m_Materials; + } + + m_SelectedEntity.GetRegistry().patch(m_SelectedEntity, [](ModelComponent&) {}); + EditorLayer::Get().GetSceneManager().MarkSceneDirty(); + } + + void MaterialPanel::DeleteMaterials() + { + if (!m_SelectedEntity || !m_SelectedEntity.IsValid()) + { + return; + } + + if (!m_SelectedEntity.HasComponent()) + { + return; + } + + auto& mc = m_SelectedEntity.GetComponent(); + auto* assets = ServiceLocator::TryGet(); + if (!assets) + { + return; + } + + // Delete all .chmat and .meta files associated with this component + for (const auto& matPath : mc.MaterialPaths) + { + if (!matPath.empty()) + { + std::string resolved = assets->ResolvePath(matPath); + std::error_code ec; + std::filesystem::remove(resolved, ec); + std::filesystem::remove(resolved + ".meta", ec); + assets->Invalidate(matPath); + } + } + + // Also clean standard naming pattern _material_*.chmat + std::filesystem::path modelPath(mc.ModelPath); + std::string modelName = modelPath.stem().string(); + std::filesystem::path modelDir = modelPath.parent_path(); + for (int i = 0; i < 64; ++i) + { + std::string matFileName = modelName + "_material_" + std::to_string(i) + ".chmat"; + std::string matRel = (modelDir / matFileName).generic_string(); + std::string resolved = assets->ResolvePath(matRel); + if (std::filesystem::exists(resolved)) + { + std::error_code ec; + std::filesystem::remove(resolved, ec); + std::filesystem::remove(resolved + ".meta", ec); + assets->Invalidate(matRel); + } + } + + mc.MaterialPaths.clear(); + + // Invalidate model asset and reload its original native materials + assets->Invalidate(mc.ModelPath); + auto modelAsset = assets->Get(mc.ModelPath); + if (modelAsset && modelAsset->IsReady()) + { + m_Materials = modelAsset->GetMaterials(); + } + else + { + m_Materials.clear(); + } + + m_SelectedEntity.GetRegistry().patch(m_SelectedEntity, [](ModelComponent&) {}); + EditorLayer::Get().GetSceneManager().MarkSceneDirty(); + } + + void MaterialPanel::OnEvent(Event& e) + { + EventDispatcher dispatcher(e); + dispatcher.Dispatch([this](EntitySelectedEvent& ev) { + if (Scene* scene = ev.GetScene()) + { + m_SelectedEntity = Entity(ev.GetEntity(), &scene->GetRegistry()); + } + m_SelectedMeshIndex = ev.GetMeshIndex(); + m_SelectedMaterialIndex = 0; + m_Materials.clear(); + m_LoadedModelPath.clear(); + return false; + }); + } + + void MaterialPanel::SetContext(const std::shared_ptr& context) + { + if (m_Context != context) + { + Panel::SetContext(context); + m_SelectedEntity = {}; + m_Materials.clear(); + m_LoadedModelPath.clear(); + } + } -} // namespace CHEngine +} // namespace Chained diff --git a/editor/panels/material_panel.h b/editor/panels/material_panel.h index 5348fa7f6..90117d468 100644 --- a/editor/panels/material_panel.h +++ b/editor/panels/material_panel.h @@ -3,26 +3,32 @@ #include "panel.h" #include "engine/scene/entity.h" -#include "engine/scene/components/mesh_component.h" +#include "engine/scene/components/render/model_component.h" +#include "engine/graphics/api/renderer_types.h" -namespace CHEngine +namespace Chained { -class MaterialPanel : public Panel -{ -public: - MaterialPanel(); - virtual void OnImGuiRender(bool readOnly = false) override; - virtual void OnEvent(Event& e) override; - virtual void SetContext(const std::shared_ptr& context) override; + class MaterialPanel : public Panel + { + public: + MaterialPanel(); + virtual void OnImGuiRender(bool readOnly = false) override; + virtual void OnEvent(Event& e) override; + virtual void SetContext(const std::shared_ptr& context) override; -private: - void DrawMaterialSlot(MaterialSlot& slot); + private: + void DrawMaterialSlot(Material& slot); + void SaveMaterials(); + void DeleteMaterials(); -private: - Entity m_SelectedEntity; - int m_SelectedMeshIndex = -1; - int m_SelectedMaterialIndex = 0; -}; -} // namespace CHEngine + private: + Entity m_SelectedEntity; + std::vector m_Materials; + std::string m_LoadedModelPath; ///< Model path whose materials are currently in m_Materials. + int m_SelectedMeshIndex = -1; + int m_SelectedMaterialIndex = 0; + char m_FilterBuffer[128] = ""; + }; +} // namespace Chained #endif // CH_MATERIAL_PANEL_H diff --git a/editor/panels/network_panel.cpp b/editor/panels/network_panel.cpp new file mode 100644 index 000000000..b2bc6afcb --- /dev/null +++ b/editor/panels/network_panel.cpp @@ -0,0 +1,510 @@ +#include "network_panel.h" +#include "engine/networking/network_service.h" +#include "engine/scene/systems/network_system.h" +#include "engine/core/service_locator.h" +#include "imgui.h" +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +#pragma comment(lib, "winhttp.lib") +#endif + +namespace Chained +{ + + /// Performs a blocking HTTP GET to api.ipify.org and returns the plain-text + /// public IP. Called from a background thread via std::async — must not touch + /// ImGui or any engine state. + /// @param useIPv6 If true, fetches IPv6 address from ipv6.api.ipify.org + static std::string FetchPublicIPBlocking(bool useIPv6 = false) + { +#ifdef _WIN32 + const wchar_t* host = useIPv6 ? L"ipv6.api.ipify.org" : L"api.ipify.org"; + HINTERNET hSession = WinHttpOpen(L"ChainedEditor/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!hSession) + { + return "(error: WinHttpOpen)"; + } + + HINTERNET hConnect = WinHttpConnect(hSession, host, INTERNET_DEFAULT_HTTP_PORT, 0); + if (!hConnect) + { + WinHttpCloseHandle(hSession); + return "(error: WinHttpConnect)"; + } + + HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"GET", L"/?format=text", nullptr, WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, 0); + if (!hRequest) + { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return "(error: WinHttpOpenRequest)"; + } + + BOOL sent = WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0); + if (!sent || !WinHttpReceiveResponse(hRequest, nullptr)) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return "(error: send/receive)"; + } + + std::string result; + DWORD bytesAvail = 0; + while (WinHttpQueryDataAvailable(hRequest, &bytesAvail) && bytesAvail > 0) + { + std::string chunk(bytesAvail, '\0'); + DWORD bytesRead = 0; + if (WinHttpReadData(hRequest, chunk.data(), bytesAvail, &bytesRead)) + { + result.append(chunk.data(), bytesRead); + } + } + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return result.empty() ? "(empty response)" : result; +#else + const char* url = useIPv6 ? "https://ipv6.api.ipify.org" : "https://api.ipify.org"; + char cmd[256]; + snprintf(cmd, sizeof(cmd), "curl -s --max-time 5 %s 2>/dev/null", url); + FILE* pipe = popen(cmd, "r"); + if (!pipe) + { + return "(install curl)"; + } + char buf[128] = {}; + fgets(buf, sizeof(buf), pipe); + pclose(pipe); + std::string r(buf); + while (!r.empty() && (r.back() == '\n' || r.back() == '\r')) + { + r.pop_back(); + } + return r.empty() ? "(empty response)" : r; +#endif + } + + NetworkPanel::NetworkPanel() + { + m_Name = "Network"; + } + + NetworkPanel::~NetworkPanel() = default; + + void NetworkPanel::OnImGuiRender(bool /*readOnly*/) + { + if (!m_IsOpen) + { + return; + } + + ImGui::SetNextWindowSize(ImVec2(420, 380), ImGuiCond_FirstUseEver); + + if (ImGui::Begin(m_Name.c_str(), &m_IsOpen)) + { + auto* net = ServiceLocator::TryGet(); + + // --- Status bar --- + if (net && net->IsConnected()) + { + const char* roleStr = net->IsHost() ? "HOST" : "CLIENT"; + ImGui::Text("Status: %s", roleStr); + ImGui::SameLine(); + ImGui::TextDisabled("(%zu connected)", net->GetClientCount()); + } + else + { + ImGui::TextDisabled("Status: Offline"); + } + + ImGui::Separator(); + + // --- Tab Bar --- + if (ImGui::BeginTabBar("##NetworkTabs")) + { + if (ImGui::BeginTabItem("Host")) + { + DrawHostTab(); + ImGui::EndTabItem(); + } + + if (ImGui::BeginTabItem("Connect")) + { + DrawConnectTab(); + ImGui::EndTabItem(); + } + + if (ImGui::BeginTabItem("Players")) + { + DrawPlayersTab(); + ImGui::EndTabItem(); + } + + ImGui::EndTabBar(); + } + + // --- Status message --- + if (!m_StatusMessage.empty()) + { + ImGui::Separator(); + if (m_StatusIsError) + { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "%s", m_StatusMessage.c_str()); + } + else + { + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "%s", m_StatusMessage.c_str()); + } + } + } + + ImGui::End(); + } + + void NetworkPanel::DrawHostTab() + { + auto* net = ServiceLocator::TryGet(); + + // --- Poll async public IP result --- + if (m_FetchingIP && m_IpFuture.valid()) + { + if (m_IpFuture.wait_for(std::chrono::seconds(0)) == std::future_status::ready) + { + m_PublicIP = m_IpFuture.get(); + m_FetchingIP = false; + } + } + + // --- Poll async IPv6 public IP result --- + if (m_FetchingIPv6 && m_IpFutureIPv6.valid()) + { + if (m_IpFutureIPv6.wait_for(std::chrono::seconds(0)) == std::future_status::ready) + { + m_PublicIPv6 = m_IpFutureIPv6.get(); + m_FetchingIPv6 = false; + } + } + + if (net && net->IsHost()) + { + ImGui::Text("Server is running."); + ImGui::Separator(); + + ImGui::Text("Port: %u", net->GetPort()); + ImGui::Text("Max clients: %d", m_MaxClients); + ImGui::Text("Connected: %zu", net->GetClientCount()); + + ImGui::Separator(); + + // ── Public IPv4 block ─────────────────────────────────────────────── + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "Public IPv4 (for internet play):"); + ImGui::SameLine(); + if (m_FetchingIP) + { + ImGui::TextDisabled("fetching..."); + } + else if (m_PublicIP.empty()) + { + ImGui::TextDisabled("(not fetched)"); + ImGui::SameLine(); + if (ImGui::SmallButton("Fetch##ipv4")) + { + m_FetchingIP = true; + m_IpFuture = std::async(std::launch::async, []() { return FetchPublicIPBlocking(false); }); + } + } + else + { + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.6f, 1.0f), "%s:%u", m_PublicIP.c_str(), net->GetPort()); + ImGui::SameLine(); + if (ImGui::SmallButton("Copy##ipv4")) + { + std::string full = m_PublicIP + ":" + std::to_string(net->GetPort()); + ImGui::SetClipboardText(full.c_str()); + m_StatusMessage = "Copied: " + full; + m_StatusIsError = false; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Refresh##ipv4")) + { + m_PublicIP.clear(); + m_FetchingIP = true; + m_IpFuture = std::async(std::launch::async, []() { return FetchPublicIPBlocking(false); }); + } + } + ImGui::TextDisabled("Forward UDP port %u on your router to play over the internet.", net->GetPort()); + + ImGui::Separator(); + + // ── Public IPv6 block ─────────────────────────────────────────────── + ImGui::TextColored(ImVec4(0.6f, 0.4f, 1.0f, 1.0f), "Public IPv6 (if available):"); + ImGui::SameLine(); + if (m_FetchingIPv6) + { + ImGui::TextDisabled("fetching..."); + } + else if (m_PublicIPv6.empty()) + { + ImGui::TextDisabled("(not fetched)"); + ImGui::SameLine(); + if (ImGui::SmallButton("Fetch##ipv6")) + { + m_FetchingIPv6 = true; + m_IpFutureIPv6 = std::async(std::launch::async, []() { return FetchPublicIPBlocking(true); }); + } + } + else if (m_PublicIPv6.find("error") != std::string::npos || m_PublicIPv6.find("empty") != std::string::npos) + { + ImGui::TextDisabled("IPv6 not available (CGNAT or no IPv6 support)"); + } + else + { + // IPv6 addresses need brackets in URLs + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.6f, 1.0f), "[%s]:%u", m_PublicIPv6.c_str(), net->GetPort()); + ImGui::SameLine(); + if (ImGui::SmallButton("Copy##ipv6")) + { + std::string full = "[" + m_PublicIPv6 + "]:" + std::to_string(net->GetPort()); + ImGui::SetClipboardText(full.c_str()); + m_StatusMessage = "Copied IPv6: " + full; + m_StatusIsError = false; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Refresh##ipv6")) + { + m_PublicIPv6.clear(); + m_FetchingIPv6 = true; + m_IpFutureIPv6 = std::async(std::launch::async, []() { return FetchPublicIPBlocking(true); }); + } + ImGui::TextDisabled("IPv6 bypasses CGNAT — share this address with friends!"); + } + + ImGui::Separator(); + + if (net->IsUpnpAvailable()) + { + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "UPnP: Active — port forwarded automatically"); + } + else + { + ImGui::TextDisabled("UPnP: not available (manual port forwarding needed)"); + } + + if (net->IsFirewallRuleActive()) + { + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Firewall: Rule added for UDP %u", net->GetPort()); + } + else + { + ImGui::TextDisabled("Firewall: no auto-rule (run as admin to enable)"); + } + + if (!net->IsUpnpAvailable()) + { + ImGui::Separator(); + ImGui::TextColored(ImVec4(0.3f, 0.8f, 1.0f, 1.0f), "For internet play:"); + ImGui::BulletText("Install Radmin VPN, create/join a network"); + ImGui::BulletText("Share your Radmin IP (e.g. 26.xx.xx.xx:7777) with friends"); + ImGui::BulletText("Or forward UDP port %u on your router manually", net->GetPort()); + } + + ImGui::Separator(); + + if (ImGui::Button("Stop Server", ImVec2(ImGui::GetContentRegionAvail().x, 0))) + { + net->Shutdown(); + m_StatusMessage = "Server stopped."; + m_StatusIsError = false; + m_PublicIP.clear(); + m_PublicIPv6.clear(); + m_FetchingIP = false; + m_FetchingIPv6 = false; + } + } + else + { + ImGui::Text("Start a server to host a game."); + ImGui::Separator(); + + ImGui::SetNextItemWidth(120); + ImGui::InputText("Port", m_HostPort, sizeof(m_HostPort), ImGuiInputTextFlags_CharsDecimal); + + ImGui::SetNextItemWidth(120); + ImGui::InputInt("Max Clients", &m_MaxClients, 1, 10); + if (m_MaxClients < 1) + { + m_MaxClients = 1; + } + if (m_MaxClients > 32) + { + m_MaxClients = 32; + } + + ImGui::Separator(); + + ImGui::TextDisabled("Direct IP connection — no NAT traversal."); + ImGui::TextDisabled("For internet play, use Radmin VPN or forward UDP port on your router."); + + ImGui::Separator(); + + if (ImGui::Button("Start Server", ImVec2(ImGui::GetContentRegionAvail().x, 0))) + { + uint16_t port = static_cast(std::atoi(m_HostPort)); + if (port == 0) + { + port = 7777; + } + + if (net) + { + NetworkSystem::GetInstance().SetPlayerPrefab("prefab/player.chprefab"); + net->HostGame(port, m_MaxClients); + // Auto-fetch public IP when server starts + m_PublicIP.clear(); + m_PublicIPv6.clear(); + m_FetchingIP = true; + m_FetchingIPv6 = true; + m_IpFuture = std::async(std::launch::async, []() { return FetchPublicIPBlocking(false); }); + m_IpFutureIPv6 = std::async(std::launch::async, []() { return FetchPublicIPBlocking(true); }); + + m_StatusMessage = "Server started on port " + std::string(m_HostPort); + m_StatusIsError = false; + } + else + { + m_StatusMessage = "Network service not available!"; + m_StatusIsError = true; + } + } + } + } + + void NetworkPanel::DrawConnectTab() + { + auto* net = ServiceLocator::TryGet(); + + if (net && net->IsClient()) + { + ImGui::Text("Connected to server."); + ImGui::Separator(); + + ImGui::Text("Server: %s:%s", m_ConnectIP, m_ConnectPort); + + ImGui::Separator(); + + if (ImGui::Button("Disconnect", ImVec2(ImGui::GetContentRegionAvail().x, 0))) + { + net->Disconnect(); + m_StatusMessage = "Disconnected."; + m_StatusIsError = false; + } + } + else + { + ImGui::Text("Connect to an existing server."); + ImGui::Separator(); + + ImGui::SetNextItemWidth(200); + ImGui::InputText("IP", m_ConnectIP, sizeof(m_ConnectIP)); + + ImGui::SetNextItemWidth(120); + ImGui::InputText("Port", m_ConnectPort, sizeof(m_ConnectPort), ImGuiInputTextFlags_CharsDecimal); + + ImGui::Separator(); + + if (ImGui::Button("Connect", ImVec2(ImGui::GetContentRegionAvail().x, 0))) + { + uint16_t port = static_cast(std::atoi(m_ConnectPort)); + if (port == 0) + { + port = 7777; + } + + if (net) + { + NetworkSystem::GetInstance().SetPlayerPrefab("prefab/player.chprefab"); + net->ConnectTo(m_ConnectIP, port); + m_StatusMessage = + "Connecting to " + std::string(m_ConnectIP) + ":" + std::string(m_ConnectPort) + "..."; + m_StatusIsError = false; + } + else + { + m_StatusMessage = "Network service not available!"; + m_StatusIsError = true; + } + } + } + } + + void NetworkPanel::DrawPlayersTab() + { + auto* net = ServiceLocator::TryGet(); + + if (!net || net->GetRole() == Role::Offline) + { + ImGui::TextDisabled("Not connected."); + return; + } + + ImGui::Text("Connected players:"); + ImGui::Separator(); + + // Table header + ImGui::Columns(3, "##PlayerColumns", true); + ImGui::SetColumnWidth(0, 60); + ImGui::SetColumnWidth(1, 180); + ImGui::SetColumnWidth(2, 100); + + ImGui::Text("ID"); + ImGui::NextColumn(); + ImGui::Text("Role"); + ImGui::NextColumn(); + ImGui::Text("Status"); + ImGui::NextColumn(); + ImGui::Separator(); + + // Host entry (self) + if (net->IsHost()) + { + ImGui::Text("0"); + ImGui::NextColumn(); + ImGui::Text("Host (You)"); + ImGui::NextColumn(); + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Playing"); + ImGui::NextColumn(); + } + + // Client entries + size_t clientCount = net->GetClientCount(); + for (size_t i = 0; i < clientCount; ++i) + { + ImGui::Text("%zu", i + 1); + ImGui::NextColumn(); + ImGui::Text("Client"); + ImGui::NextColumn(); + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Connected"); + ImGui::NextColumn(); + } + + ImGui::Columns(1); + + if (clientCount == 0 && net->IsHost()) + { + ImGui::TextDisabled("No clients connected yet."); + } + } + +} // namespace Chained diff --git a/editor/panels/network_panel.h b/editor/panels/network_panel.h new file mode 100644 index 000000000..f91065e6b --- /dev/null +++ b/editor/panels/network_panel.h @@ -0,0 +1,48 @@ +#ifndef CH_NETWORK_PANEL_H +#define CH_NETWORK_PANEL_H + +#include "panel.h" +#include +#include +#include + +namespace Chained +{ + + class NetworkPanel : public Panel + { + public: + NetworkPanel(); + ~NetworkPanel() override; + + void OnImGuiRender(bool readOnly = false) override; + + private: + void DrawHostTab(); + void DrawConnectTab(); + void DrawPlayersTab(); + + // Host settings + char m_HostPort[16] = "7777"; + int m_MaxClients = 8; + + // Connect settings + char m_ConnectIP[128] = "127.0.0.1"; + char m_ConnectPort[16] = "7777"; + + // Status + std::string m_StatusMessage; + bool m_StatusIsError = false; + + // Public IP fetch + std::string m_PublicIP; ///< cached IPv4 result from api.ipify.org + std::string m_PublicIPv6; ///< cached IPv6 result from ipv6.api.ipify.org + std::future m_IpFuture; + std::future m_IpFutureIPv6; + bool m_FetchingIP = false; + bool m_FetchingIPv6 = false; + }; + +} // namespace Chained + +#endif // CH_NETWORK_PANEL_H diff --git a/editor/panels/panel.h b/editor/panels/panel.h index 28d97986d..25839c3ab 100644 --- a/editor/panels/panel.h +++ b/editor/panels/panel.h @@ -1,58 +1,62 @@ #ifndef CH_PANEL_H #define CH_PANEL_H -#include "engine/core/base.h" -#include "engine/core/events.h" -#include "engine/core/timestep.h" +#include "engine/common/base.h" +#include "engine/core/events/events.h" +#include "engine/common/timestep.h" #include "engine/scene/scene.h" +#include +#include +#include -namespace CHEngine +namespace Chained { -// Base class for dockable editor panels with optional scene context. -class Panel -{ -public: - virtual ~Panel() = default; - - // Draws the panel UI. readOnly is used when the panel should avoid editing. - virtual void OnImGuiRender(bool readOnly = false) = 0; - // Optional per-frame update hook. - virtual void OnUpdate(Timestep ts) - { - } - // Optional event hook. - virtual void OnEvent(Event& e) - { - } - // Optional configuration hook used by settings panels. - virtual void OnConfiguration() - { - } - // Updates the scene context used by the panel. - virtual void SetContext(const std::shared_ptr& context) - { - m_Context = context; - } - - bool& IsOpen() - { - return m_IsOpen; - } - bool& ShowSettings() - { - return m_ShowSettings; - } - const std::string& GetName() const - { - return m_Name; - } - -protected: - std::string m_Name; - std::shared_ptr m_Context; - bool m_IsOpen = true; - bool m_ShowSettings = false; -}; -} // namespace CHEngine + // Base class for dockable editor panels with optional scene context. + class Panel + { + public: + Panel() = default; + virtual ~Panel() = default; + + virtual void OnImGuiRender(bool readOnly = false) + { + } + virtual void OnUpdate(Timestep ts) + { + } + virtual void OnEvent(Event& e) + { + } + + virtual void SetContext(const std::shared_ptr& context) + { + m_Context = context; + } + + bool& IsOpen() + { + return m_IsOpen; + } + const std::string& GetName() const + { + return m_Name; + } + + bool IsPendingKill() const + { + return m_PendingKill; + } + void MarkForDelete() + { + m_PendingKill = true; + } + + protected: + std::string m_Name; + std::shared_ptr m_Context; + bool m_IsOpen = true; + bool m_PendingKill = false; + }; +} // namespace Chained #endif // CH_PANEL_H diff --git a/editor/panels/profiler_panel.cpp b/editor/panels/profiler_panel.cpp index 9f78341d7..3c3393e9b 100644 --- a/editor/panels/profiler_panel.cpp +++ b/editor/panels/profiler_panel.cpp @@ -1,139 +1,133 @@ #include "profiler_panel.h" #include "engine/core/profiler.h" +#include "imgui.h" #include #include -#include "imgui.h" - -namespace CHEngine -{ -ProfilerPanel::ProfilerPanel() -{ - m_Name = "Profiler"; - m_FrameTimeHistory.reserve(100); - for (int i = 0; i < 100; i++) - { - m_FrameTimeHistory.push_back(0.0f); - } -} - -void ProfilerPanel::OnImGuiRender(bool readOnly) -{ - if (!m_IsOpen) - { - return; - } - - UpdateHistory(); - - ImGui::Begin(m_Name.c_str(), &m_IsOpen); - - const auto& stats = Profiler::GetStats(); - - if (ImGui::CollapsingHeader("Hardware & System", ImGuiTreeNodeFlags_DefaultOpen)) - { - ImGui::Text("GPU: %s", glGetString(GL_RENDERER)); - ImGui::Text("Driver: %s", glGetString(GL_VERSION)); - } - - if (ImGui::CollapsingHeader("Scene Statistics", ImGuiTreeNodeFlags_DefaultOpen)) - { - ImGui::Columns(2); - ImGui::Text("Entities:"); - ImGui::NextColumn(); - ImGui::Text("%u", stats.EntityCount); - ImGui::NextColumn(); - ImGui::Text("Draw Calls:"); - ImGui::NextColumn(); - ImGui::Text("%u", stats.DrawCalls); - ImGui::NextColumn(); - ImGui::Text("Meshes:"); - ImGui::NextColumn(); - ImGui::Text("%u", stats.MeshCount); - ImGui::NextColumn(); - - // Format polys (K, M) - std::string polyStr; - if (stats.PolyCount > 1000000) - { - polyStr = std::format("{:.2f} M", stats.PolyCount / 1000000.0f); - } - else if (stats.PolyCount > 1000) - { - polyStr = std::format("{:.1f} K", stats.PolyCount / 1000.0f); - } - else - { - polyStr = std::to_string(stats.PolyCount); - } - - ImGui::Text("Polygons:"); - ImGui::NextColumn(); - ImGui::Text("%s", polyStr.c_str()); - ImGui::NextColumn(); - ImGui::Text("Colliders:"); - ImGui::NextColumn(); - ImGui::Text("%u", stats.ColliderCount); - ImGui::NextColumn(); - ImGui::Columns(1); - } - - const auto& results = Profiler::GetLastFrameResults(); - if (ImGui::CollapsingHeader("Execution Timeline", ImGuiTreeNodeFlags_DefaultOpen)) - { - if (!m_FrameTimeHistory.empty()) - { - float maxTime = 0.0f; - for (float f : m_FrameTimeHistory) - { - if (f > maxTime) - { - maxTime = f; - } - } - - ImGui::PushStyleColor(ImGuiCol_PlotLines, ImVec4(0.2f, 0.7f, 1.0f, 1.0f)); - ImGui::PlotLines("##FrameTime", m_FrameTimeHistory.data(), (int)m_FrameTimeHistory.size(), 0, - std::format("Max: {:.2f}ms", maxTime).c_str(), 0.0f, 33.3f, ImVec2(0, 80)); - ImGui::PopStyleColor(); - } - - for (const auto& result : results) - { - DrawProfileResult(result); - } - } - - ImGui::End(); -} - -void ProfilerPanel::DrawProfileResult(const ProfileResult& result) -{ - std::string label = std::format("{} - {:.3f}ms", result.Name, result.DurationMS); - ImGui::Text("%s", label.c_str()); -} -void ProfilerPanel::UpdateHistory() +namespace Chained { - const auto& results = Profiler::GetLastFrameResults(); - float frameMS = 0.0f; - - for (const auto& res : results) - { - if (res.Name == "MainThread_Frame") - { - frameMS = res.DurationMS; - break; - } - } - - if (frameMS > 0) - { - for (size_t i = 1; i < m_FrameTimeHistory.size(); i++) - { - m_FrameTimeHistory[i - 1] = m_FrameTimeHistory[i]; - } - m_FrameTimeHistory.back() = frameMS; - } -} - -} // namespace CHEngine + ProfilerPanel::ProfilerPanel() + { + m_Name = "Profiler"; + m_IsOpen = false; + m_FrameTimeHistory.reserve(100); + for (int i = 0; i < 100; i++) + { + m_FrameTimeHistory.push_back(0.0f); + } + } + + void ProfilerPanel::OnImGuiRender(bool readOnly) + { + if (!m_IsOpen) + { + return; + } + + UpdateHistory(); + + ImGui::Begin(m_Name.c_str(), &m_IsOpen); + + const auto& stats = Instrumentor::Get().GetStats(); + + if (ImGui::CollapsingHeader("Hardware & System", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::Text("GPU: %s", glGetString(GL_RENDERER)); + ImGui::Text("Driver: %s", glGetString(GL_VERSION)); + } + + if (ImGui::CollapsingHeader("Scene Statistics", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::Columns(2); + ImGui::Text("Entities:"); + ImGui::NextColumn(); + ImGui::Text("%u", stats.EntityCount); + ImGui::NextColumn(); + ImGui::Text("Draw Calls:"); + ImGui::NextColumn(); + ImGui::Text("%u", stats.DrawCalls); + ImGui::NextColumn(); + ImGui::Text("Meshes:"); + ImGui::NextColumn(); + ImGui::Text("%u", stats.MeshCount); + ImGui::NextColumn(); + + ImGui::Text("Colliders:"); + ImGui::NextColumn(); + ImGui::Text("%u", stats.ColliderCount); + ImGui::NextColumn(); + ImGui::Columns(1); + } + + const auto& results = Instrumentor::Get().GetLastFrameResults(); + if (ImGui::CollapsingHeader("Execution Timeline", ImGuiTreeNodeFlags_DefaultOpen)) + { + if (!m_FrameTimeHistory.empty()) + { + float maxTime = 0.0f; + for (float f : m_FrameTimeHistory) + { + if (f > maxTime) + { + maxTime = f; + } + } + + ImGui::PushStyleColor(ImGuiCol_PlotLines, ImVec4(0.2f, 0.7f, 1.0f, 1.0f)); + ImGui::PlotLines("##FrameTime", m_FrameTimeHistory.data(), (int)m_FrameTimeHistory.size(), 0, + std::format("Max: {:.2f}ms", maxTime).c_str(), 0.0f, 33.3f, ImVec2(0, 80)); + ImGui::PopStyleColor(); + } + + for (const auto& result : results) + { + DrawProfileResult(result); + } + } + + ImGui::End(); + } + + void ProfilerPanel::DrawProfileResult(const ProfileResult& result) + { + std::string label = std::format("{} - {:.3f}ms", result.Name, result.DurationMS); + ImGui::Text("%s", label.c_str()); + } + + void ProfilerPanel::UpdateHistory() + { + const auto& results = Instrumentor::Get().GetLastFrameResults(); + float frameMS = 0.0f; + + for (const auto& res : results) + { + if (res.Name == "Run") + { + frameMS = res.DurationMS; + break; + } + } + + if (frameMS <= 0) + { + for (const auto& res : results) + { + if (res.Name == "MainThread_Frame") + { + frameMS = res.DurationMS; + break; + } + } + } + + if (frameMS > 0) + { + for (size_t i = 1; i < m_FrameTimeHistory.size(); i++) + { + m_FrameTimeHistory[i - 1] = m_FrameTimeHistory[i]; + } + m_FrameTimeHistory.back() = frameMS; + } + } + +} // namespace Chained diff --git a/editor/panels/profiler_panel.h b/editor/panels/profiler_panel.h index ed08e7728..e5eec3500 100644 --- a/editor/panels/profiler_panel.h +++ b/editor/panels/profiler_panel.h @@ -3,21 +3,21 @@ #include "panel.h" -namespace CHEngine +namespace Chained { -class ProfilerPanel : public Panel -{ -public: - ProfilerPanel(); - virtual void OnImGuiRender(bool readOnly = false) override; + class ProfilerPanel : public Panel + { + public: + ProfilerPanel(); + virtual void OnImGuiRender(bool readOnly = false) override; -private: - void DrawProfileResult(const struct ProfileResult& result); - void UpdateHistory(); + private: + void DrawProfileResult(const struct ProfileResult& result); + void UpdateHistory(); -private: - std::vector m_FrameTimeHistory; -}; -} // namespace CHEngine + private: + std::vector m_FrameTimeHistory; + }; +} // namespace Chained #endif // CH_PROFILER_PANEL_H diff --git a/editor/panels/project_browser_panel.cpp b/editor/panels/project_browser_panel.cpp deleted file mode 100644 index 248b503be..000000000 --- a/editor/panels/project_browser_panel.cpp +++ /dev/null @@ -1,264 +0,0 @@ -#include "project_browser_panel.h" - -#include "editor_layer.h" -#include "engine/scene/scene_events.h" -#include "IconsFontAwesome6.h" -#include "engine/platform/utils/dialogs.h" -#include "panel.h" -#include - -namespace CHEngine -{ -ProjectBrowserPanel::ProjectBrowserPanel() -{ - m_Name = "Project Browser"; - std::string cwd = std::filesystem::current_path().string(); - memset(m_ProjectLocationBuffer, 0, sizeof(m_ProjectLocationBuffer)); - cwd.copy(m_ProjectLocationBuffer, sizeof(m_ProjectLocationBuffer) - 1); - - m_NewProjectIconHandle = TextureSystem::Get().LoadTexture("engine/resources/icons/newproject.jpg"); - m_OpenProjectIconHandle = TextureSystem::Get().LoadTexture("engine/resources/icons/folder.png"); -} - -ProjectBrowserPanel::~ProjectBrowserPanel() -{ - // Textures are cached globally by the texture system. -} - -void ProjectBrowserPanel::OnImGuiRender(bool readOnly) -{ - ImGuiViewport* viewport = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos(viewport->WorkPos); - ImGui::SetNextWindowSize(viewport->WorkSize); - ImGui::SetNextWindowViewport(viewport->ID); - - ImGuiWindowFlags windowFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoNavFocus | - ImGuiWindowFlags_NoScrollbar; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); - - ImGui::Begin("Project Browser", nullptr, windowFlags); - - if (m_OpenCreatePopupRequest) - { - ImGui::OpenPopup("Create New Project"); - m_OpenCreatePopupRequest = false; - } - - DrawWelcomeScreen(); - - if (m_ShowCreateDialog) - { - DrawCreateProjectDialog(); - } - - ImGui::End(); - ImGui::PopStyleVar(3); -} - -void ProjectBrowserPanel::DrawWelcomeScreen() -{ - // 1. Sidebar (Recent Projects) - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.02f, 0.02f, 0.02f, 1.0f)); - ImGui::BeginChild("Sidebar", ImVec2(300, 0), true); - - ImGui::Spacing(); - ImGui::SetCursorPosX(20); - ImGui::SetWindowFontScale(1.5f); - ImGui::TextColored(ImVec4(0.2f, 0.7f, 1.0f, 1.0f), ICON_FA_LINK " Chained"); - ImGui::SameLine(); - ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 1.0f), "Engine"); - ImGui::SetWindowFontScale(1.0f); - ImGui::Separator(); - - ImGui::Spacing(); - ImGui::TextDisabled(" RECENT PROJECTS"); - ImGui::Spacing(); - - const auto& recentProjects = EditorLayer::Get().GetConfig().RecentProjects; - if (recentProjects.empty()) - { - ImGui::TextDisabled(" No recent projects."); - } - else - { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 1.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.05f, 0.5f)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 5.0f); - - for (const auto& projectPath : recentProjects) - { - std::string fileName = std::filesystem::path(projectPath).filename().string(); - std::string dirName = std::filesystem::path(projectPath).parent_path().filename().string(); - - std::string label = ICON_FA_FOLDER_OPEN " " + fileName + "\n " + dirName; - if (ImGui::Button(label.c_str(), ImVec2(-1, 50))) - { - EditorLayer::Get().GetProjectManager().OpenProject(projectPath); - } - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("%s", projectPath.c_str()); - } - ImGui::Spacing(); - } - - ImGui::PopStyleVar(2); - ImGui::PopStyleColor(); - } - - - ImGui::EndChild(); - ImGui::PopStyleColor(); - - ImGui::SameLine(); - - // 2. Main Area (Actions) - // Darker background for main area to match screenshot - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.08f, 0.08f, 0.08f, 1.0f)); - ImGui::BeginChild("MainArea", ImVec2(0, 0), false); - - float centerX = ImGui::GetContentRegionAvail().x * 0.5f; - float centerY = ImGui::GetContentRegionAvail().y * 0.5f; - - ImGui::SetCursorPos(ImVec2(centerX - 350, centerY - 150)); - - // Large Card Style - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(20, 20)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 12.0f); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.12f, 0.13f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.18f, 0.18f, 0.19f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.1f, 0.1f, 0.1f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.9f, 0.9f, 0.9f, 1.0f)); - - ImGui::BeginGroup(); - { - ImTextureID newProjTex = 0; - if (m_NewProjectIconHandle != 0) - newProjTex = (ImTextureID)(uintptr_t)TextureSystem::Get().GetRendererID(m_NewProjectIconHandle); - - if (ImGui::ImageButton("##NewProject", newProjTex, {300, 300}, {0, 1}, {1, 0})) - { - m_OpenCreatePopupRequest = true; - m_ShowCreateDialog = true; - } - } - ImGui::SetWindowFontScale(1.3f); - ImGui::Text("New Project"); - ImGui::SetWindowFontScale(1.0f); - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 280); - ImGui::TextDisabled("Start a fresh journey with a dedicated"); - ImGui::TextDisabled("project folder and optimized settings."); - ImGui::PopTextWrapPos(); - ImGui::EndGroup(); - - ImGui::SameLine(0, 40); - - ImGui::BeginGroup(); - { - ImTextureID openProjTex = 0; - if (m_OpenProjectIconHandle != 0) - openProjTex = (ImTextureID)(uintptr_t)TextureSystem::Get().GetRendererID(m_OpenProjectIconHandle); - - if (ImGui::ImageButton("##OpenProject", openProjTex, {300, 300}, {0, 1}, {1, 0})) - { - EditorLayer::Get().GetProjectManager().OpenProject(); - } - } - ImGui::SetWindowFontScale(1.3f); - ImGui::Text("Open Project"); - ImGui::SetWindowFontScale(1.0f); - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 280); - ImGui::TextDisabled("Browse and load an existing Chained"); - ImGui::TextDisabled("Engine project (.chproject) file."); - ImGui::PopTextWrapPos(); - ImGui::EndGroup(); - - ImGui::PopStyleColor(4); - ImGui::PopStyleVar(2); - - ImGui::EndChild(); - ImGui::PopStyleColor(); // ChildBg -} - -void ProjectBrowserPanel::DrawCreateProjectDialog() -{ - ImVec2 center = ImGui::GetMainViewport()->GetCenter(); - ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); - ImGui::SetNextWindowSize(ImVec2(500, 250)); - - if (ImGui::BeginPopupModal("Create New Project", &m_ShowCreateDialog, - ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) - { - ImGui::Text("Project Name:"); - ImGui::InputText("##ProjectName", m_ProjectNameBuffer, sizeof(m_ProjectNameBuffer)); - - ImGui::Spacing(); - ImGui::Text("Location:"); - ImGui::InputText("##ProjectLocation", m_ProjectLocationBuffer, sizeof(m_ProjectLocationBuffer)); - ImGui::SameLine(); - if (ImGui::Button("Browse...")) - { - auto result = Dialogs::PickFolder(); - if (result) - { - memset(m_ProjectLocationBuffer, 0, sizeof(m_ProjectLocationBuffer)); - std::string path = result->string(); - path.copy(m_ProjectLocationBuffer, sizeof(m_ProjectLocationBuffer) - 1); - } - } - - ImGui::Spacing(); - - // Path Preview - std::filesystem::path root(m_ProjectLocationBuffer); - std::filesystem::path finalPath; - if (root.filename().string() == m_ProjectNameBuffer) - { - finalPath = root / (std::string(m_ProjectNameBuffer) + ".chproject"); - } - else - { - finalPath = root / m_ProjectNameBuffer / (std::string(m_ProjectNameBuffer) + ".chproject"); - } - - ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "Resulting Project File:"); - ImGui::SetWindowFontScale(0.9f); - ImGui::TextWrapped("%s", finalPath.string().c_str()); - ImGui::SetWindowFontScale(1.0f); - - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - - if (ImGui::Button("Create", ImVec2(120, 0))) - { - if (strlen(m_ProjectNameBuffer) == 0) - { - CH_CORE_ERROR("Project Name cannot be empty!"); - } - else if (m_EventCallback) - { - CH_CORE_INFO("ProjectBrowser: Dispatching ProjectCreatedEvent - Name: {0}, Location: {1}", - m_ProjectNameBuffer, m_ProjectLocationBuffer); - ProjectCreatedEvent e(m_ProjectNameBuffer, m_ProjectLocationBuffer); - m_EventCallback(e); - m_ShowCreateDialog = false; - ImGui::CloseCurrentPopup(); - } - } - - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(120, 0))) - { - m_ShowCreateDialog = false; - ImGui::CloseCurrentPopup(); - } - - ImGui::EndPopup(); - } -} -} // namespace CHEngine diff --git a/editor/panels/project_browser_panel.h b/editor/panels/project_browser_panel.h deleted file mode 100644 index ae4fa77fc..000000000 --- a/editor/panels/project_browser_panel.h +++ /dev/null @@ -1,58 +0,0 @@ -#ifndef CH_PROJECT_BROWSER_PANEL_H -#define CH_PROJECT_BROWSER_PANEL_H - -#include "panel.h" -#include "engine/graphics/texture_system.h" -#include -#include -#include -#include - -namespace CHEngine -{ -class ProjectBrowserPanel : public Panel -{ -public: - ProjectBrowserPanel(); - ~ProjectBrowserPanel(); - - virtual void OnImGuiRender(bool readOnly = false) override; - - using EventCallbackFn = std::function; - void SetEventCallback(const EventCallbackFn& callback) - { - m_EventCallback = callback; - } - - // Testing accessors - bool IsCreateDialogVisible() const - { - return m_ShowCreateDialog; - } - void SetCreateDialogVisible(bool visible) - { - m_ShowCreateDialog = visible; - } - bool HasPendingCreatePopupRequest() const - { - return m_OpenCreatePopupRequest; - } - -private: - void DrawWelcomeScreen(); - void DrawCreateProjectDialog(); - - EventCallbackFn m_EventCallback; - - // Simplified state - bool m_ShowCreateDialog = false; - bool m_OpenCreatePopupRequest = false; - char m_ProjectNameBuffer[256] = "MyProject"; - char m_ProjectLocationBuffer[512] = ""; - - TextureHandle m_NewProjectIconHandle = 0; - TextureHandle m_OpenProjectIconHandle = 0; -}; -} // namespace CHEngine - -#endif // CH_PROJECT_BROWSER_PANEL_H diff --git a/editor/panels/project_settings_panel.cpp b/editor/panels/project_settings_panel.cpp index d1b36c3cb..bae0fdc5f 100644 --- a/editor/panels/project_settings_panel.cpp +++ b/editor/panels/project_settings_panel.cpp @@ -1,324 +1,314 @@ #include "project_settings_panel.h" -#include "engine/scene/project.h" -#include "editor_layer.h" -#include "engine/scene/project_serializer.h" +#include "engine/core/platform.h" +#include "engine/platform/dialogs/dialogs.h" +#include "engine/project/project.h" #include "imgui.h" -#include "engine/platform/utils/dialogs.h" -#include "IconsFontAwesome6.h" -#include +#include "layer.h" +#include "project/project_serializer.h" +#include "project_manager.h" +#include "thirdparty/IconsFontAwesome6.h" +#include +#include -namespace CHEngine +namespace Chained { -ProjectSettingsPanel::ProjectSettingsPanel() -{ - m_Name = "Project Settings"; - m_IsOpen = false; -} + ProjectSettingsPanel::ProjectSettingsPanel() + { + m_Name = "Project Settings"; + m_IsOpen = false; + } -void ProjectSettingsPanel::OnImGuiRender(bool readOnly) -{ - if (!m_IsOpen) - { - return; - } - - auto project = Project::GetActive(); - if (!project) - { - m_IsOpen = false; - return; - } - - if (ImGui::Begin("Project Settings", &m_IsOpen)) - { - auto& config = project->GetConfig(); - - static int selectedCategory = 0; - const char* categories[] = { - ICON_FA_GEARS " General", - ICON_FA_CODE " Scripting", - ICON_FA_CUBES " Physics", - ICON_FA_WINDOW_RESTORE " Window", - ICON_FA_PLAY " Runtime", - ICON_FA_CAMERA " Editor", - ICON_FA_MOUNTAIN_SUN " Rendering", - ICON_FA_BOXES_STACKED " Assets" - }; - - ImGui::Columns(2, "ProjectSettingsColumns", true); - ImGui::SetColumnWidth(0, 200.0f); - - // Sidebar - for (int i = 0; i < IM_ARRAYSIZE(categories); i++) - { - if (ImGui::Selectable(categories[i], selectedCategory == i)) - { - selectedCategory = i; - } - } - - ImGui::NextColumn(); - - // Content - if (selectedCategory == 0) // General - { - ImGui::TextDisabled("General Settings"); - char nameBuf[256]; - strncpy(nameBuf, config.Name.c_str(), 255); - if (ImGui::InputText("Project Name", nameBuf, 255)) - { - config.Name = nameBuf; - } - - char iconBuf[512]; - strncpy(iconBuf, config.IconPath.c_str(), 511); - iconBuf[511] = '\0'; - if (ImGui::InputText("Icon Path", iconBuf, 511)) - { - config.IconPath = iconBuf; - } - ImGui::SameLine(); - if (ImGui::Button("...###IconBrowse")) - { - std::vector filters = {{"Image Files", "png,jpg,jpeg"}}; - auto result = Dialogs::OpenFile(filters); - if (result) - { - config.IconPath = Project::GetRelativePath(result->string()); - } - } - - auto availableScenes = Project::GetAvailableScenes(); - const char* currentScene = config.StartScene.c_str(); - - if (ImGui::BeginCombo("Start Scene", currentScene)) - { - for (const auto& scenePath : availableScenes) - { - bool isSelected = (config.StartScene == scenePath); - if (ImGui::Selectable(scenePath.c_str(), isSelected)) - { - config.StartScene = scenePath; - } - if (isSelected) - { - ImGui::SetItemDefaultFocus(); - } - } - ImGui::EndCombo(); - } - - ImGui::Separator(); - ImGui::Text(ICON_FA_ROCKET " Launch Profiles"); - - if (config.LaunchProfiles.empty()) - { - if (ImGui::Button("Add Default Profile")) - { - LaunchProfile debug; - debug.Name = "Debug Runtime"; - debug.BinaryPath = "${BUILD}/ChainedRuntime.exe"; - debug.Arguments = "--project \"${PROJECT_FILE}\""; - config.LaunchProfiles.push_back(debug); - } - } - - for (int i = 0; i < (int)config.LaunchProfiles.size(); i++) - { - auto& profile = config.LaunchProfiles[i]; - ImGui::PushID(i); - - bool isActive = (config.ActiveLaunchProfileIndex == i); - if (ImGui::RadioButton("Active", isActive)) - { - config.ActiveLaunchProfileIndex = i; - } - ImGui::SameLine(); - - if (ImGui::CollapsingHeader(std::format("{}###Header", profile.Name).c_str())) - { - char profileNameBuf[128]; - strncpy(profileNameBuf, profile.Name.c_str(), 127); - profileNameBuf[127] = '\0'; - if (ImGui::InputText("Profile Name", profileNameBuf, 127)) - { - profile.Name = profileNameBuf; - } - - char pathBuf[512]; - strncpy(pathBuf, profile.BinaryPath.c_str(), 511); - pathBuf[511] = '\0'; - if (ImGui::InputText("Binary Path", pathBuf, 511)) - { - profile.BinaryPath = pathBuf; - } - - ImGui::SameLine(); - if (ImGui::Button("...")) - { - std::vector filters = {{"Runtime Executable", "exe"}}; - auto result = Dialogs::OpenFile(filters); - if (result) - { - profile.BinaryPath = result->string(); - } - } - - char argBuf[512]; - strncpy(argBuf, profile.Arguments.c_str(), 511); - argBuf[511] = '\0'; - if (ImGui::InputText("Arguments", argBuf, 511)) - { - profile.Arguments = argBuf; - } - - ImGui::Checkbox("Use Default Project Args", &profile.UseDefaultArgs); - - if (ImGui::Button("Remove Profile")) - { - config.LaunchProfiles.erase(config.LaunchProfiles.begin() + i); - if (config.ActiveLaunchProfileIndex >= (int)config.LaunchProfiles.size()) - { - config.ActiveLaunchProfileIndex = std::max(0, (int)config.LaunchProfiles.size() - 1); - } - ImGui::PopID(); - break; - } - } - ImGui::PopID(); - } - - if (ImGui::Button(ICON_FA_PLUS " Add New Profile")) - { - config.LaunchProfiles.push_back({"New Profile", "", ""}); - if (config.LaunchProfiles.size() == 1) - { - config.ActiveLaunchProfileIndex = 0; - } - } - } - else if (selectedCategory == 1) // Scripting - { - ImGui::TextDisabled("Scripting Settings"); - char moduleNameBuf[256]; - strncpy(moduleNameBuf, config.Scripting.ModuleName.c_str(), 255); - moduleNameBuf[255] = '\0'; - if (ImGui::InputText("Module Name", moduleNameBuf, 255)) - { - config.Scripting.ModuleName = moduleNameBuf; - } - - char moduleDirBuf[512]; - strncpy(moduleDirBuf, config.Scripting.ModuleDirectory.string().c_str(), 511); - moduleDirBuf[511] = '\0'; - if (ImGui::InputText("Module Directory", moduleDirBuf, 511)) - { - config.Scripting.ModuleDirectory = moduleDirBuf; - } - ImGui::SameLine(); - if (ImGui::Button("...###ModuleDirBrowse")) - { - auto result = Dialogs::PickFolder(); - if (result) - { - config.Scripting.ModuleDirectory = Project::GetRelativePath(result->string()); - } - } - - ImGui::Checkbox("Auto Load Module", &config.Scripting.AutoLoad); - } - else if (selectedCategory == 2) // Physics - { - ImGui::TextDisabled("Physics Settings"); - ImGui::DragFloat("World Gravity", &config.Physics.Gravity, 0.1f); - ImGui::DragFloat("Fixed Timestep", &config.Physics.FixedTimestep, 0.001f, 0.001f, 0.1f, "%.4f"); - } - else if (selectedCategory == 3) // Window - { - ImGui::TextDisabled("Window Settings"); - ImGui::DragInt("Width", &config.Window.Width, 1, 800, 3840); - ImGui::DragInt("Height", &config.Window.Height, 1, 600, 2160); - ImGui::Checkbox("VSync", &config.Window.VSync); - ImGui::Checkbox("Resizable", &config.Window.Resizable); - } - else if (selectedCategory == 4) // Runtime - { - ImGui::TextDisabled("Runtime Settings"); - ImGui::Checkbox("Fullscreen", &config.Runtime.Fullscreen); - ImGui::Checkbox("Show Stats", &config.Runtime.ShowStats); - ImGui::Checkbox("Enable Console", &config.Runtime.EnableConsole); - } - else if (selectedCategory == 5) // Editor - { - ImGui::TextDisabled("Editor Settings"); - ImGui::DragFloat("Camera Speed", &config.Editor.CameraMoveSpeed, 0.1f, 0.1f, 100.0f); - ImGui::DragFloat("Rotation Speed", &config.Editor.CameraRotationSpeed, 0.01f, 0.01f, 1.0f); - ImGui::DragFloat("Boost Multiplier", &config.Editor.CameraBoostMultiplier, 0.1f, 1.0f, 200.0f); - - ImGui::Separator(); - ImGui::TextDisabled("Auto-Save Settings"); - auto& editorConfig = EditorLayer::Get().GetConfig(); - ImGui::Checkbox("Enable Auto-Save", &editorConfig.AutoSaveEnabled); - ImGui::DragFloat("Auto-Save Interval (s)", &editorConfig.AutoSaveInterval, 1.0f, 10.0f, 3600.0f); - } - else if (selectedCategory == 6) // Rendering - { - ImGui::TextDisabled("Rendering Settings"); - ImGui::DragFloat("Ambient Intensity", &config.Render.AmbientIntensity, 0.01f, 0.0f, 1.0f); - ImGui::DragFloat("Default Exposure", &config.Render.DefaultExposure, 0.01f, 0.0f, 10.0f); - - ImGui::Separator(); - ImGui::Text("Texture Quality"); - ImGui::Checkbox("Generate Mipmaps", &config.Texture.GenerateMipmaps); - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("Generates mip chain on texture upload.\nReduces aliasing and bandwidth.\nApplied to newly loaded textures."); - } - - const char* filterNames[] = {"Point (No Filter)", "Bilinear", "Trilinear", "Anisotropic 4x", - "Anisotropic 8x", "Anisotropic 16x"}; - int currentFilter = (int)config.Texture.Filter; - if (ImGui::Combo("Texture Filter", ¤tFilter, filterNames, 6)) - { - config.Texture.Filter = (TextureFilter)currentFilter; - } - } - else if (selectedCategory == 7) // Assets - { - ImGui::TextDisabled("Asset Management Settings"); - - ImGui::Separator(); - ImGui::Text("Mesh Import Settings"); - ImGui::Checkbox("Import Materials", &config.Mesh.ImportMaterials); - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("If disabled, .mtl files or embedded materials will be ignored.\nModels will use a default material."); - } - - ImGui::Checkbox("Calculate Tangents", &config.Mesh.CalculateTangents); - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("Generates tangent and bitangent vectors for normal mapping."); - } - - ImGui::Checkbox("Flip UVs", &config.Mesh.FlipUVs); - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("Flips the Y-coordinate of texture coordinates."); - } - } - - ImGui::Columns(1); - ImGui::Separator(); - - if (ImGui::Button("Save Project Settings")) - { - ProjectSerializer serializer(project); - std::filesystem::path path = project->GetProjectDirectory() / (project->GetConfig().Name + ".chproject"); - serializer.Serialize(path); - } - - } - ImGui::End(); -} -} // namespace CHEngine + void ProjectSettingsPanel::OnImGuiRender(bool readOnly) + { + if (!m_IsOpen) + { + return; + } + + auto project = Project::GetActive(); + if (!project) + { + m_IsOpen = false; + return; + } + + if (ImGui::Begin("Project Settings", &m_IsOpen)) + { + auto& config = project->GetConfig(); + + const char* categories[] = {ICON_FA_GEARS " General", + ICON_FA_CODE " Scripting", + ICON_FA_CUBES " Physics", + ICON_FA_WINDOW_RESTORE " Window", + ICON_FA_MOUNTAIN_SUN " Rendering", + ICON_FA_VOLUME_HIGH " Audio", + ICON_FA_CUBE " Mesh", + ICON_FA_PLAY " Runtime"}; + + // Two-column layout: sidebar left, content right + ImGui::Columns(2, "ProjectSettingsColumns", true); + + if (!m_WidthSet) + { + ImGui::SetColumnWidth(0, 200.0f); + m_WidthSet = true; + } + + // --- Left sidebar --- + ImGui::BeginChild("SettingsSidebar", ImVec2(0, 0), ImGuiChildFlags_NavFlattened); + for (int i = 0; i < IM_ARRAYSIZE(categories); i++) + { + if (ImGui::Selectable(categories[i], m_SelectedCategory == i, ImGuiSelectableFlags_DontClosePopups)) + { + m_SelectedCategory = i; + } + } + ImGui::EndChild(); + + ImGui::NextColumn(); + + // --- Right content panel --- + ImGui::BeginChild("SettingsContent", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), + ImGuiChildFlags_NavFlattened); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4, 4)); + + switch (m_SelectedCategory) + { + case 0: // General + { + ImGui::TextDisabled("General Settings"); + ImGui::Separator(); + ImGui::Spacing(); + + char nameBuf[256]; + snprintf(nameBuf, sizeof(nameBuf), "%s", config.Name.c_str()); + if (ImGui::InputText("Project Name", nameBuf, sizeof(nameBuf))) + { + config.Name = nameBuf; + } + + char iconBuf[512]; + snprintf(iconBuf, sizeof(iconBuf), "%s", config.IconPath.c_str()); + if (ImGui::InputText("Icon Path", iconBuf, sizeof(iconBuf))) + { + config.IconPath = iconBuf; + } + ImGui::SameLine(); + if (ImGui::Button("...###IconBrowse")) + { + std::vector filters = {{"Image Files", "png,jpg,jpeg"}}; + auto result = Chained::Dialogs::OpenFile(filters); + if (result) + { + config.IconPath = project->GetRelativePath(result->string()); + } + } + + auto availableScenes = project->GetAvailableScenes(); + const char* currentScene = config.StartScene.c_str(); + + if (ImGui::BeginCombo("Start Scene", currentScene)) + { + for (const auto& scenePath : availableScenes) + { + bool isSelected = (config.StartScene == scenePath); + if (ImGui::Selectable(scenePath.c_str(), isSelected)) + { + config.StartScene = scenePath; + } + if (isSelected) + { + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndCombo(); + } + break; + } + case 1: // Scripting + { + ImGui::TextDisabled("Scripting Settings"); + ImGui::Separator(); + ImGui::Spacing(); + + char moduleNameBuf[256]; + snprintf(moduleNameBuf, sizeof(moduleNameBuf), "%s", config.Scripting.ModuleName.c_str()); + if (ImGui::InputText("Module Name", moduleNameBuf, sizeof(moduleNameBuf))) + { + config.Scripting.ModuleName = moduleNameBuf; + } + + char moduleDirBuf[512]; + snprintf(moduleDirBuf, sizeof(moduleDirBuf), "%s", config.Scripting.ModuleDirectory.string().c_str()); + if (ImGui::InputText("Module Directory", moduleDirBuf, sizeof(moduleDirBuf))) + { + config.Scripting.ModuleDirectory = moduleDirBuf; + } + ImGui::SameLine(); + if (ImGui::Button("...###ModuleDirBrowse")) + { + auto result = Chained::Dialogs::PickFolder(); + if (result) + { + config.Scripting.ModuleDirectory = project->GetRelativePath(result->string()); + } + } + + ImGui::Checkbox("Auto Load Module", &config.Scripting.AutoLoad); + break; + } + case 2: // Physics + { + ImGui::TextDisabled("Physics Settings"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::DragFloat("World Gravity", &config.Physics.Gravity, 0.1f); + ImGui::DragFloat("Fixed Timestep", &config.Physics.FixedTimestep, 0.001f, 0.001f, 0.1f, "%.4f"); + break; + } + case 3: // Window + { + ImGui::TextDisabled("Window Settings"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::DragInt("Width", &config.Window.Width, 1, 800, 3840); + ImGui::DragInt("Height", &config.Window.Height, 1, 600, 2160); + ImGui::Checkbox("VSync", &config.Window.VSync); + break; + } + case 4: // Rendering + { + ImGui::TextDisabled("Rendering Settings"); + ImGui::Separator(); + ImGui::TextDisabled("Visual Quality"); + + static constexpr int kShadowResValues[] = {512, 1024, 2048, 4096}; + static constexpr int kShadowResCount = sizeof(kShadowResValues) / sizeof(kShadowResValues[0]); + const char* shadowResNames[kShadowResCount] = {"512", "1024", "2048", "4096"}; + int currentShadowResIdx = 2; // Default 2048 + for (int i = 0; i < kShadowResCount; i++) + { + if (config.Render.ShadowResolution == kShadowResValues[i]) + { + currentShadowResIdx = i; + break; + } + } + + if (ImGui::Combo("Shadow Resolution", ¤tShadowResIdx, shadowResNames, kShadowResCount)) + { + config.Render.ShadowResolution = kShadowResValues[currentShadowResIdx]; + } + + static constexpr int kAAValues[] = {0, 2, 4, 8}; + static constexpr int kAACount = sizeof(kAAValues) / sizeof(kAAValues[0]); + const char* aaNames[kAACount] = {"None", "2x MSAA", "4x MSAA", "8x MSAA"}; + int currentAAIdx = 0; + for (int i = 0; i < kAACount; i++) + { + if (config.Render.AntiAliasingSamples == kAAValues[i]) + { + currentAAIdx = i; + break; + } + } + + if (ImGui::Combo("Anti-Aliasing", ¤tAAIdx, aaNames, kAACount)) + { + config.Render.AntiAliasingSamples = kAAValues[currentAAIdx]; + } + + ImGui::Checkbox("Enable Shadows", &config.Render.EnableShadows); + break; + } + case 5: // Audio + { + ImGui::TextDisabled("Audio Settings"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::SliderFloat("Master Volume", &config.Audio.MasterVolume, 0.0f, 1.0f); + ImGui::SliderFloat("Music Volume", &config.Audio.MusicVolume, 0.0f, 1.0f); + ImGui::SliderFloat("SFX Volume", &config.Audio.SFXVolume, 0.0f, 1.0f); + break; + } + case 6: // Mesh + { + ImGui::TextDisabled("Mesh Import Settings"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::Checkbox("Import Materials", &config.Mesh.ImportMaterials); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Automatically import materials when loading meshes"); + } + + ImGui::Checkbox("Calculate Tangents", &config.Mesh.CalculateTangents); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Calculate tangent vectors for normal mapping"); + } + + ImGui::Checkbox("Flip UVs", &config.Mesh.FlipUVs); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Flip texture UV coordinates vertically on import"); + } + break; + } + case 7: // Runtime + { + ImGui::TextDisabled("Runtime Settings"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::Checkbox("Fullscreen", &config.Runtime.Fullscreen); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Start the game in fullscreen mode"); + } + + ImGui::Checkbox("Show Stats Overlay", &config.Runtime.ShowStats); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Display FPS and performance stats in the runtime window"); + } + + ImGui::Checkbox("Enable Console", &config.Runtime.EnableConsole); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Enable the in-game developer console"); + } + + ImGui::DragInt("Target FPS", &config.Runtime.TargetFPS, 1, 0, 240); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("0 = Uncapped framerate"); + } + break; + } + } + + ImGui::PopStyleVar(); + ImGui::EndChild(); + + ImGui::Columns(1); + ImGui::Separator(); + + float buttonWidth = + ImGui::CalcTextSize("Save Project Settings").x + ImGui::GetStyle().FramePadding.x * 2.0f; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - buttonWidth); + + if (ImGui::Button("Save Project Settings")) + { + std::filesystem::path path = + project->GetConfig().ProjectDirectory / (project->GetName() + ".chproject"); + EditorProjectSerializer::Serialize(project, path); + } + } + ImGui::End(); + } +} // namespace Chained diff --git a/editor/panels/project_settings_panel.h b/editor/panels/project_settings_panel.h index 582ee2a3a..b4391b22a 100644 --- a/editor/panels/project_settings_panel.h +++ b/editor/panels/project_settings_panel.h @@ -3,16 +3,20 @@ #include "panel.h" -namespace CHEngine +namespace Chained { -class ProjectSettingsPanel : public Panel -{ -public: - ProjectSettingsPanel(); + class ProjectSettingsPanel : public Panel + { + public: + ProjectSettingsPanel(); + + public: + virtual void OnImGuiRender(bool readOnly = false) override; -public: - virtual void OnImGuiRender(bool readOnly = false) override; -}; -} // namespace CHEngine + private: + int m_SelectedCategory = 0; + bool m_WidthSet = false; + }; +} // namespace Chained #endif // CH_PROJECT_SETTINGS_PANEL_H diff --git a/editor/panels/property_editor.cpp b/editor/panels/property_editor.cpp index ed5ef50ea..75fc2453d 100644 --- a/editor/panels/property_editor.cpp +++ b/editor/panels/property_editor.cpp @@ -1,392 +1,1500 @@ #include "property_editor.h" -#include "IconsFontAwesome6.h" -#include "editor/editor_layer.h" +#include "engine/reflection/reflection_rfl.h" +#include "engine/reflection/reflection_rfl_impl.h" +#include "engine/scene/components/render/primitive_component.h" +#include "engine/scene/component_registry.h" +#include "thirdparty/IconsFontAwesome6.h" +#include "editor/layer.h" #include "editor/undo/component_commands.h" #include "editor/undo/modify_component_command.h" -#include "editor_gui.h" -#include "engine/core/assets/asset_manager.h" -#include "engine/graphics/assets/model_asset.h" -#include "engine/graphics/assets/texture_asset.h" +#include "engine/core/service_locator.h" +#include "gui.h" + #include "engine/physics/physics.h" -#include "engine/scene/components.h" -#include "engine/scene/project.h" -#include "engine/scene/scene.h" #include "engine/scene/scene_settings.h" #include "imgui.h" -#include "panel.h" +#include "misc/cpp/imgui_stdlib.h" #include "ui_properties.h" // Included here to break circular dependency #include - -#include "nfd.h" -#include "scripting/scriptengine.h" +#include "engine/scripting/scriptengine.h" #include -#include -#include +#include "engine/app/application.h" #include +#include "engine/assets/asset_manager.h" +#include "engine/assets/types/model_asset.h" +#include "engine/scene/components/animation/animation_component.h" +#include "engine/assets/loaders/anim_graph_loader.h" +#include "engine/assets/types/animation_graph_asset.h" +#include "engine/ui/ui_font_registry.h" +#include "engine/ui/widget_renderer.h" -namespace CHEngine +namespace Chained { -std::unordered_map PropertyEditor::s_ComponentRegistry; + // --- UI Widget Data Drawers (extracted from UIControlComponent lambda) --- -// --- Template Implementations (Moved from Header) --- + static bool DrawButtonData(ButtonData& data, UIProperties& ui) + { + bool changed = false; + if (ui.Property("Label", data.Label)) + { + changed = true; + } + if (ui.Property("Interactable", data.IsInteractable)) + { + changed = true; + } + if (ui.Property("Auto Size", data.AutoSize)) + { + changed = true; + } + return changed; + } -template -void PropertyEditor::DrawComponentReflection(const std::string& name, const char* icon, Entity entity) -{ - static std::unordered_map s_InitialStates; - entt::entity e = (entt::entity)entity; - - DrawComponentContainer(name, icon, entity, [&](T& comp, Entity ent) { - UIProperties ui; - Properties props(ui); - comp.Reflect(props); - - if (ui.HasStarted()) - { - s_InitialStates[e] = entity.GetComponent(); - } - - if (ui.HasFinished()) - { - if (s_InitialStates.contains(e)) - { - auto oldState = s_InitialStates[e]; - auto newState = comp; - EditorLayer::GetCommandHistory().PushCommand( - std::make_unique>(entity, oldState, newState, "Modify " + name)); - s_InitialStates.erase(e); - } - } - - return props.HasChanged(); - }); -} - -template -void PropertyEditor::DrawComponentContainer(const std::string& name, const char* icon, Entity entity, - std::function drawer) -{ - if (entity.HasComponent()) - { - DrawComponentInternal( - entt::type_hash::value(), name, icon, entity, - [&]() { - auto& component = entity.GetComponent(); - T componentCopy = component; - if (drawer(componentCopy, entity)) - { - // Live preview / immediate update - entity.GetRegistry().template patch(entity, [&componentCopy](T& comp) { comp = componentCopy; }); - return true; - } - return false; - }, - [&]() { - EditorLayer::GetCommandHistory().PushCommand(std::make_unique>(entity)); - }); - } -} - -template void PropertyEditor::Register(const std::string& name, const char* icon) -{ - ComponentMetadata metadata; - metadata.Name = name; - metadata.Icon = icon; - metadata.Draw = [name, icon](Entity e) { DrawComponentReflection(name, icon, e); }; - metadata.Add = [](Entity e) { - if (!e.HasComponent()) - { - EditorLayer::GetCommandHistory().PushCommand(std::make_unique>(e)); - return true; - } - return false; - }; - RegisterComponent(entt::type_hash::value(), metadata); -} - -template -void PropertyEditor::RegisterCustom(const std::string& name, std::function drawer, const char* icon) -{ - ComponentMetadata metadata; - metadata.Name = name; - metadata.Icon = icon; - metadata.Draw = [name, icon, drawer](Entity e) { DrawComponentContainer(name, icon, e, drawer); }; - metadata.Add = [](Entity e) { - if (!e.HasComponent()) - { - EditorLayer::GetCommandHistory().PushCommand(std::make_unique>(e)); - return true; - } - return false; - }; - RegisterComponent(entt::type_hash::value(), metadata); -} - -// --- Implementation --- - -void PropertyEditor::RegisterComponent(entt::id_type typeId, const ComponentMetadata& metadata) -{ - s_ComponentRegistry[typeId] = metadata; -} + static bool DrawLabelData(LabelData& data, UIProperties& ui) + { + bool changed = false; + if (ui.Property("Text", data.Text)) + { + changed = true; + } + if (ui.Property("Auto Size", data.AutoSize)) + { + changed = true; + } + return changed; + } -void PropertyEditor::Init() -{ - // --- Core Components --- - Register("Transform", ICON_FA_ARROWS_UP_DOWN_LEFT_RIGHT); - s_ComponentRegistry[entt::type_hash::value()].AllowAdd = false; - - Register("Tag", ICON_FA_TAG); - Register("Camera", ICON_FA_VIDEO); - Register("Light", ICON_FA_LIGHTBULB); - Register("RigidBody", ICON_FA_CUBES); - Register("Collider", ICON_FA_SHIELD); - Register("Model", ICON_FA_CUBE); - Register("Materials", ICON_FA_DROPLET); - Register("Sprite", ICON_FA_IMAGE); - Register("Primitive", ICON_FA_SHAPES); - Register("Shader", ICON_FA_CODE); - Register("Animation", ICON_FA_FILM); - Register("Audio", ICON_FA_VOLUME_HIGH); - Register("SpawnZone", ICON_FA_LOCATION_DOT); - Register("Player", ICON_FA_USER); - Register("SceneTransition", ICON_FA_DOOR_OPEN); - - // --- Scripting --- - Register("Scripts", ICON_FA_FILE_CODE); - - // --- UI Components --- - - Register("RectTransform", ICON_FA_VECTOR_SQUARE); - Register("Navigation", ICON_FA_ARROWS_TO_DOT); - Register("UIAction", ICON_FA_BOLT); - - // --- UI Widgets --- - Register("Button", ICON_FA_ARROW_POINTER); - Register("Panel", ICON_FA_WINDOW_MAXIMIZE); - Register("Label", ICON_FA_FONT); - Register("Slider", ICON_FA_SLIDERS); - Register("Checkbox", ICON_FA_SQUARE_CHECK); - Register("InputText", ICON_FA_PEN_TO_SQUARE); - Register("ComboBox", ICON_FA_LIST_UL); - Register("ProgressBar", ICON_FA_BARS_PROGRESS); - Register("Image", ICON_FA_IMAGE); - Register("ImageButton", ICON_FA_IMAGE); - Register("Separator", ICON_FA_MINUS); - Register("RadioButton", ICON_FA_CIRCLE_DOT); - Register("ColorPicker", ICON_FA_PALETTE); - Register("DragFloat", ICON_FA_ARROWS_LEFT_RIGHT); - Register("DragInt", ICON_FA_ARROWS_LEFT_RIGHT); - Register("TabBar", ICON_FA_TABLE_COLUMNS); - Register("TabItem", ICON_FA_FILE); - Register("CollapsingHeader", ICON_FA_ANGLE_DOWN); - Register("VerticalLayoutGroup", ICON_FA_LAYER_GROUP); - - // Mark only real UI widget types as IsWidget (these will be hidden in 3D scenes) - auto markWidget = [&](entt::id_type id) { - if (s_ComponentRegistry.contains(id)) - { - s_ComponentRegistry[id].IsWidget = true; - } - }; - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); - markWidget(entt::type_hash::value()); -} - -void PropertyEditor::DrawComponentInternal(entt::id_type typeId, const std::string& name, const char* icon, - Entity entity, std::function contentDrawer, - std::function remover) -{ - const ImGuiTreeNodeFlags treeNodeFlags = ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_Framed | - ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_AllowOverlap | - ImGuiTreeNodeFlags_FramePadding; - - ImVec2 contentRegionAvailable = ImGui::GetContentRegionAvail(); - - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2{4, 4}); - float lineHeight = ImGui::GetFontSize() + ImGui::GetStyle().FramePadding.y * 2.0f; - - // Header Background Color - ImGui::PushStyleColor(ImGuiCol_Header, {0.2f, 0.25f, 0.35f, 0.8f}); - ImGui::PushStyleColor(ImGuiCol_HeaderActive, {0.3f, 0.4f, 0.6f, 1.0f}); - ImGui::PushStyleColor(ImGuiCol_HeaderHovered, {0.25f, 0.35f, 0.5f, 1.0f}); - - std::string headerName = (icon ? std::string(icon) + " " : "") + name; - bool open = ImGui::TreeNodeEx((void*)typeId, treeNodeFlags, headerName.c_str()); - - ImGui::PopStyleColor(3); - ImGui::PopStyleVar(); - - // Right-aligned settings button - ImGui::SameLine(contentRegionAvailable.x - lineHeight * 0.7f); - ImGui::PushStyleColor(ImGuiCol_Button, {0, 0, 0, 0}); - if (ImGui::Button(ICON_FA_GEAR, ImVec2{lineHeight, lineHeight})) - { - ImGui::OpenPopup("ComponentSettings"); - } - ImGui::PopStyleColor(); - - bool removed = false; - if (ImGui::BeginPopup("ComponentSettings")) - { - if (ImGui::MenuItem("Remove Component")) - { - remover(); - removed = true; - } - - ImGui::EndPopup(); - } - - if (open) - { - if (!removed) - { - EditorGUI::BeginPropertyGrid(); - contentDrawer(); - EditorGUI::EndPropertyGrid(); - } - ImGui::TreePop(); - ImGui::Spacing(); - } -} - -void PropertyEditor::DrawEntityProperties(CHEngine::Entity entity) -{ - auto& registry = entity.GetRegistry(); - bool isUI = entity.HasComponent(); - - // 1. Check for widgets more efficiently - bool hasWidget = false; - for (auto [id, storage] : registry.storage()) - { - if (storage.contains(entity) && s_ComponentRegistry.contains(id) && s_ComponentRegistry[id].IsWidget) - { - hasWidget = true; - break; - } - } - - // 2. Draw components efficiently - for (auto [id, storage] : registry.storage()) - { - if (storage.contains(entity) && s_ComponentRegistry.contains(id)) - { - auto& metadata = s_ComponentRegistry[id]; - if (!metadata.Visible) - { - continue; - } - - // Logic to reduce clutter - if (isUI && id == entt::type_hash::value()) - { - continue; - } - - ImGui::PushID((int)id); - metadata.Draw(entity); - ImGui::PopID(); - } - } -} - -void PropertyEditor::DrawEntityHeader(CHEngine::Entity entity) -{ - if (entity.HasComponent()) - { - auto& tag = entity.GetComponent().Tag; - - // Entity Icon and Label - ImGui::BeginGroup(); - ImGui::PushFont(ImGui::GetIO().Fonts->Fonts[0]); - ImGui::TextColored({0.4f, 0.6f, 0.9f, 1.0f}, ICON_FA_CUBE " Entity"); - ImGui::PopFont(); - - char buffer[256]; - memset(buffer, 0, sizeof(buffer)); - strncpy(buffer, tag.c_str(), sizeof(buffer) - 1); - - ImGui::PushItemWidth(ImGui::GetContentRegionAvail().x - 120.0f); - if (ImGui::InputText("##Tag", buffer, sizeof(buffer))) - { - tag = std::string(buffer); - } - ImGui::PopItemWidth(); - - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_PLUS " Add Component", ImVec2(110, 0))) - { - ImGui::OpenPopup("AddComponent"); - } - - DrawAddComponentPopup(entity); - ImGui::EndGroup(); - - ImGui::Spacing(); - } -} - -void PropertyEditor::DrawAddComponentPopup(CHEngine::Entity entity) -{ - if (ImGui::BeginPopup("AddComponent")) - { - bool isUIEntity = entity.HasComponent(); - auto* scene = entity.GetRegistry().ctx().find(); - bool is3DScene = scene && (*scene)->GetSettings().Mode == BackgroundMode::Environment3D; - - for (auto& [id, metadata] : s_ComponentRegistry) - { - if (!metadata.AllowAdd) - { - continue; - } - if (metadata.IsWidget && !isUIEntity) - { - continue; - } - if (is3DScene && (metadata.IsWidget || id == entt::type_hash::value())) - { - continue; - } - - auto& registry = entity.GetRegistry(); - auto* storage = registry.storage(id); - if (storage && storage->contains(entity)) - { - continue; - } - - std::string label = (metadata.Icon ? std::string(metadata.Icon) + " " : "") + metadata.Name; - if (ImGui::MenuItem(label.c_str())) - { - metadata.Add(entity); - ImGui::CloseCurrentPopup(); - } - } - ImGui::EndPopup(); - } -} -} // namespace CHEngine + static bool DrawCheckboxData(CheckboxData& data, UIProperties& ui) + { + bool changed = false; + if (ui.Property("Label", data.Label)) + { + changed = true; + } + if (ui.Property("Checked", data.Checked)) + { + changed = true; + } + return changed; + } + + static bool DrawSliderData(SliderData& data, UIProperties& ui) + { + bool changed = false; + if (ui.Property("Label", data.Label)) + { + changed = true; + } + if (ui.Property("Value", data.Value, PropertyMeta(data.Min, data.Max, 0.01f))) + { + changed = true; + } + if (ui.Property("Min", data.Min)) + { + changed = true; + } + if (ui.Property("Max", data.Max)) + { + changed = true; + } + return changed; + } + + static bool DrawProgressBarData(ProgressBarData& data, UIProperties& ui) + { + bool changed = false; + if (ui.Property("Progress", data.Progress, PropertyMeta(0.0f, 1.0f, 0.01f))) + { + changed = true; + } + if (ui.Property("Overlay Text", data.OverlayText)) + { + changed = true; + } + if (ui.Property("Show %", data.ShowPercentage)) + { + changed = true; + } + return changed; + } + + static bool DrawImageData(ImageData& data, UIProperties& ui) + { + bool changed = false; + if (ui.File("Texture Path", data.TexturePath, ".png,.jpg,.jpeg,.bmp,.tga")) + { + changed = true; + } + if (ui.Property("Tint Color", data.TintColor)) + { + changed = true; + } + if (ui.Property("Border Color", data.BorderColor)) + { + changed = true; + } + return changed; + } + + static bool DrawPanelData(PanelData& data, UIProperties& ui) + { + bool changed = false; + if (ui.File("Texture Path", data.TexturePath, ".png,.jpg,.jpeg")) + { + changed = true; + } + if (ui.Property("Full Screen", data.FullScreen)) + { + changed = true; + } + return changed; + } + + static bool DrawComboBoxData(ComboBoxData& data, UIProperties& ui) + { + bool changed = false; + if (ui.Property("Label", data.Label)) + { + changed = true; + } + if (!data.Items.empty()) + { + if (ui.Property("Selected", data.SelectedIndex, PropertyMeta(0, (int)data.Items.size() - 1, 1))) + { + changed = true; + } + } + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted("Items"); + ImGui::TableSetColumnIndex(1); + int removeIdx = -1; + for (int i = 0; i < (int)data.Items.size(); i++) + { + ImGui::PushID(i); + if (ImGui::InputText("##item", &data.Items[i])) + { + changed = true; + } + ImGui::SameLine(); + if (ImGui::SmallButton(ICON_FA_TRASH)) + { + removeIdx = i; + changed = true; + } + ImGui::PopID(); + } + if (removeIdx >= 0) + { + data.Items.erase(data.Items.begin() + removeIdx); + } + if (ImGui::SmallButton(ICON_FA_PLUS " Add Item")) + { + data.Items.push_back(""); + changed = true; + } + return changed; + } + + static bool DrawInputTextData(InputTextData& data, UIProperties& ui) + { + bool changed = false; + if (ui.Property("Text", data.Text)) + { + data.InputBuffer.clear(); + changed = true; + } + if (ui.Property("Placeholder", data.Placeholder)) + { + changed = true; + } + if (ui.Property("Max Length", data.MaxLength, PropertyMeta(1, 1024, 1))) + { + changed = true; + } + if (ui.Property("Multiline", data.Multiline)) + { + changed = true; + } + if (ui.Property("Read Only", data.ReadOnly)) + { + changed = true; + } + if (ui.Property("Password", data.Password)) + { + changed = true; + } + return changed; + } + + static bool DrawImageButtonData(ImageButtonData& data, UIProperties& ui) + { + bool changed = false; + if (ui.File("Texture Path", data.TexturePath, ".png,.jpg,.jpeg")) + { + changed = true; + } + if (ui.Property("Label", data.Label)) + { + changed = true; + } + return changed; + } + + static bool DrawRadioButtonData(RadioButtonData& data, UIProperties& ui) + { + bool changed = false; + if (ui.Property("Label", data.Label)) + { + changed = true; + } + if (!data.Options.empty()) + { + if (ui.Property("Selected", data.SelectedIndex, PropertyMeta(0, (int)data.Options.size() - 1, 1))) + { + changed = true; + } + } + if (ui.Property("Horizontal", data.Horizontal)) + { + changed = true; + } + return changed; + } + + static bool DrawDragFloatData(DragFloatData& data, UIProperties& ui) + { + bool changed = false; + if (ui.Property("Label", data.Label)) + { + changed = true; + } + if (ui.Property("Value", data.Value, PropertyMeta(data.Min, data.Max, data.Speed))) + { + changed = true; + } + if (ui.Property("Min", data.Min)) + { + changed = true; + } + if (ui.Property("Max", data.Max)) + { + changed = true; + } + return changed; + } + + static bool DrawDragIntData(DragIntData& data, UIProperties& ui) + { + bool changed = false; + if (ui.Property("Label", data.Label)) + { + changed = true; + } + if (ui.Property("Value", data.Value, PropertyMeta(data.Min, data.Max, 1))) + { + changed = true; + } + if (ui.Property("Min", data.Min)) + { + changed = true; + } + if (ui.Property("Max", data.Max)) + { + changed = true; + } + return changed; + } + + // --- Template Implementations (Moved from Header) --- + + template + void PropertyEditor::DrawComponentReflection(const std::string& name, const char* icon, Entity entity) + { + static std::unordered_map s_InitialStates; + entt::entity e = (entt::entity)entity; + + // Clear stale states when entity is not in the current context + // (handles scene changes where entity IDs may be reused) + static entt::registry* s_LastRegistry = nullptr; + entt::registry* currentRegistry = &entity.GetRegistry(); + if (s_LastRegistry != currentRegistry) + { + s_InitialStates.clear(); + s_LastRegistry = currentRegistry; + } + + DrawComponentContainer(name, icon, entity, [&](T& comp, Entity ent) { + UIProperties ui; + Properties props(ui); + + if constexpr (is_rfl_component::value) + { + ReflectFromRfl(comp, props); + } + else + { + comp.Reflect(props); + } + + if (ui.HasStarted()) + { + s_InitialStates[e] = entity.GetComponent(); + } + + if (ui.HasFinished()) + { + if (s_InitialStates.contains(e)) + { + auto oldState = s_InitialStates[e]; + auto newState = comp; + + EditorLayer::Get().GetCommandHistory().PushCommand( + std::make_unique>(entity, oldState, newState, "Modify " + name)); + + s_InitialStates.erase(e); + } + } + + return props.HasChanged(); + }); + } + + template + void PropertyEditor::DrawComponentContainer(const std::string& name, const char* icon, Entity entity, F&& drawer) + { + if (entity.HasComponent()) + { + if constexpr (std::is_same_v) + { + if (entity.HasComponent() && + entity.GetComponent().Type != PrimitiveType::None) + { + return; + } + } + + DrawComponentInternal( + entt::type_hash::value(), name, icon, entity, + [&]() { + auto& component = entity.GetComponent(); + T componentCopy = component; + if (drawer(componentCopy, entity)) + { + // Live preview / immediate update + entity.GetRegistry().template patch(entity, + [&componentCopy](T& comp) { comp = componentCopy; }); + return true; + } + return false; + }, + [&]() { + EditorLayer::Get().GetCommandHistory().PushCommand( + std::make_unique>(entity)); + }); + } + } + + void PropertyEditor::DrawGenericReflection(const ComponentMetadata& metadata, Entity entity) + { + // Use a stable hash of the component name as the tree node ID + // to avoid ImGui ID collisions when multiple generic components are rendered + entt::id_type stableId = static_cast(std::hash{}(metadata.Name)); + + DrawComponentInternal( + stableId, metadata.Name, metadata.Icon, entity, + [&]() { + UIProperties ui; + metadata.ReflectInternal(entity, ui, ReflectionMode::UI); + bool changed = ui.HasChanged(); + if (changed && metadata.NotifyUpdate) + { + // Fire registry.patch() so on_update observers (e.g. MarkPrimitiveDirty) run. + metadata.NotifyUpdate(entity); + } + return changed; + }, + [&]() { + if (metadata.Remove) + { + metadata.Remove(entity); + } + }); + } + + template + void PropertyEditor::RegisterComponentImpl(const std::string& name, const char* icon, + std::function drawUI) + { + auto typeId = entt::type_hash::value(); + + // Register fresh metadata if the component type doesn't exist yet + if (!ComponentRegistry::Exists(typeId)) + { + ComponentMetadata fresh; + fresh.Name = name; + fresh.Icon = icon; + fresh.Category = "Engine"; + fresh.SerializationKey = name + "Component"; + ComponentRegistry::Register(typeId, fresh); + } + + // Apply editor-specific overrides (undo/redo, custom DrawUI) + ComponentMetadata override; + override.Name = name; + override.Icon = icon; + override.DrawUI = drawUI; + override.Add = [](Entity e) { + if (!e.HasComponent()) + { + EditorLayer::Get().GetCommandHistory().PushCommand(std::make_unique>(e)); + } + }; + override.Remove = [](Entity e) { + EditorLayer::Get().GetCommandHistory().PushCommand(std::make_unique>(e)); + }; + ComponentRegistry::OverrideMetadata(typeId, override); + } + + template void PropertyEditor::Register(const std::string& name, const char* icon) + { + RegisterComponentImpl(name, icon, [name, icon](Entity e) { DrawComponentReflection(name, icon, e); }); + } + + template + void PropertyEditor::RegisterCustom(const std::string& name, F&& drawer, const char* icon) + { + RegisterComponentImpl(name, icon, [name, icon, drawer = std::forward(drawer)](Entity e) { + DrawComponentContainer(name, icon, e, drawer); + }); + } + + // --- Implementation --- + + void PropertyEditor::Init() + { + // --- Core Components --- + ComponentRegistry::SetAllowAdd(entt::type_hash::value(), false); + + // Custom drawer for PrimitiveComponent (shown in Inspector) + RegisterCustom( + "Primitive", + [](PrimitiveComponent& comp, Entity entity) { + bool changed = false; + UIProperties ui; + + static const char* primitiveTypes[] = {"None", "Cube", "Sphere", "Plane", "Cylinder", + "Cone", "Torus", "Knot", "Hemisphere"}; + int typeIdx = static_cast(comp.Type); + if (ui.Enum("Shape Type", typeIdx, primitiveTypes, 9)) + { + comp.Type = static_cast(typeIdx); + changed = true; + } + + switch (comp.Type) + { + case PrimitiveType::Cube: { + if (ui.Property("Dimensions", comp.Dimensions, PropertyMeta(0.01f, 100.0f, 0.05f))) + { + changed = true; + } + break; + } + case PrimitiveType::Sphere: { + if (ui.Property("Radius", comp.Radius, PropertyMeta(0.01f, 100.0f, 0.05f))) + { + changed = true; + } + if (ui.Property("Slices", comp.Slices, PropertyMeta(3, 128, 1))) + { + changed = true; + } + if (ui.Property("Stacks", comp.Stacks, PropertyMeta(3, 128, 1))) + { + changed = true; + } + break; + } + case PrimitiveType::Plane: { + if (ui.Property("Dimensions", comp.Dimensions, PropertyMeta(0.01f, 100.0f, 0.05f))) + { + changed = true; + } + break; + } + case PrimitiveType::Cylinder: { + if (ui.Property("Radius", comp.Radius, PropertyMeta(0.01f, 100.0f, 0.05f))) + { + changed = true; + } + if (ui.Property("Height", comp.Height, PropertyMeta(0.01f, 100.0f, 0.05f))) + { + changed = true; + } + if (ui.Property("Slices", comp.Slices, PropertyMeta(3, 128, 1))) + { + changed = true; + } + break; + } + case PrimitiveType::Cone: { + if (ui.Property("Radius", comp.Radius, PropertyMeta(0.01f, 100.0f, 0.05f))) + { + changed = true; + } + if (ui.Property("Height", comp.Height, PropertyMeta(0.01f, 100.0f, 0.05f))) + { + changed = true; + } + if (ui.Property("Slices", comp.Slices, PropertyMeta(3, 128, 1))) + { + changed = true; + } + break; + } + case PrimitiveType::Torus: { + if (ui.Property("Radius", comp.Radius, PropertyMeta(0.01f, 100.0f, 0.05f))) + { + changed = true; + } + if (ui.Property("Inner Radius", comp.InnerRadius, PropertyMeta(0.01f, 50.0f, 0.01f))) + { + changed = true; + } + if (ui.Property("Slices", comp.Slices, PropertyMeta(3, 128, 1))) + { + changed = true; + } + if (ui.Property("Stacks", comp.Stacks, PropertyMeta(3, 128, 1))) + { + changed = true; + } + break; + } + case PrimitiveType::Knot: { + if (ui.Property("Radius", comp.Radius, PropertyMeta(0.01f, 100.0f, 0.05f))) + { + changed = true; + } + if (ui.Property("Inner Radius", comp.InnerRadius, PropertyMeta(0.01f, 50.0f, 0.01f))) + { + changed = true; + } + if (ui.Property("Slices", comp.Slices, PropertyMeta(3, 128, 1))) + { + changed = true; + } + if (ui.Property("Stacks", comp.Stacks, PropertyMeta(3, 128, 1))) + { + changed = true; + } + break; + } + case PrimitiveType::Hemisphere: { + if (ui.Property("Radius", comp.Radius, PropertyMeta(0.01f, 100.0f, 0.05f))) + { + changed = true; + } + if (ui.Property("Slices", comp.Slices, PropertyMeta(3, 128, 1))) + { + changed = true; + } + if (ui.Property("Stacks", comp.Stacks, PropertyMeta(3, 128, 1))) + { + changed = true; + } + break; + } + default: + break; + } + + if (entity.HasComponent()) + { + auto& mc = entity.GetComponent(); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextDisabled("Material Override"); + + if (mc.MaterialPaths.empty()) + { + mc.MaterialPaths.resize(1); + } + + std::filesystem::path modelPath(mc.ModelPath); + std::string modelName = modelPath.stem().string(); + std::filesystem::path modelDir = modelPath.parent_path(); + + for (size_t matIdx = 0; matIdx < mc.MaterialPaths.size(); ++matIdx) + { + if (mc.MaterialPaths[matIdx].empty() && !mc.ModelPath.empty()) + { + auto* am = ServiceLocator::TryGet(); + std::string autoName = modelName + "_material_" + std::to_string(matIdx) + ".chmat"; + std::string autoRel = (modelDir / autoName).generic_string(); + if (am && am->FileExists(autoRel)) + { + mc.MaterialPaths[matIdx] = autoRel; + } + } + + std::string matLabel = "Material " + std::to_string(matIdx); + if (ui.File(matLabel.c_str(), mc.MaterialPaths[matIdx], ".chmat")) + { + entity.GetRegistry().patch(entity, [](ModelComponent&) {}); + EditorLayer::Get().GetSceneManager().MarkSceneDirty(); + changed = true; + } + } + } + + return changed; + }, + ICON_FA_SHAPES); + RegisterCustom( + "Light", + [&](LightComponent& comp, Entity entity) { + bool changed = false; + UIProperties ui; + Properties props(ui); + + int typeIdx = static_cast(comp.Type); + static const char* lightTypes[] = {"Point", "Spot", "Directional"}; + if (ui.Enum("Type", typeIdx, lightTypes, 3)) + { + comp.Type = static_cast(typeIdx); + changed = true; + } + if (ui.Property("Color", comp.LightColor)) + { + changed = true; + } + if (ui.Property("Intensity", comp.Intensity, PropertyMeta(0.0f, 10000.0f, 5.0f))) + { + changed = true; + } + if (ui.Property("Range", comp.Radius, PropertyMeta(0.0f, 1000.0f, 1.0f))) + { + changed = true; + } + + if (comp.Type == LightType::Spot) + { + if (ui.Property("Inner Cutoff", comp.InnerCutoff, PropertyMeta(0.0f, 90.0f, 0.5f))) + { + changed = true; + } + if (ui.Property("Outer Cutoff", comp.OuterCutoff, PropertyMeta(0.0f, 90.0f, 0.5f))) + { + changed = true; + } + } + + if (ui.Property("Cast Shadows", comp.Shadows)) + { + changed = true; + } + + return changed; + }, + ICON_FA_LIGHTBULB); + + RegisterCustom( + "Collider", + [&](ColliderComponent& comp, Entity entity) { + bool changed = false; + UIProperties ui; + Properties props(ui); + + int typeIdx = static_cast(comp.Type); + static const char* colliderTypes[] = {"Box", "Sphere", "Capsule", "Mesh"}; + if (ui.Enum("Type", typeIdx, colliderTypes, 4)) + { + comp.Type = static_cast(typeIdx); + changed = true; + } + + if (comp.Type == ColliderType::Box) + { + if (ui.Property("Size", comp.Size, PropertyMeta(0.01f, 100.0f, 0.05f))) + { + changed = true; + } + } + else if (comp.Type == ColliderType::Sphere || comp.Type == ColliderType::Capsule) + { + if (ui.Property("Radius", comp.Radius, PropertyMeta(0.0f, 500.0f, 0.05f))) + { + changed = true; + } + } + if (comp.Type == ColliderType::Capsule) + { + if (ui.Property("Height", comp.Height, PropertyMeta(0.0f, 500.0f, 0.05f))) + { + changed = true; + } + } + + if (ui.Property("Offset", comp.Offset, PropertyMeta(-10.0f, 10.0f, 0.05f))) + { + changed = true; + } + + if (comp.Type == ColliderType::Mesh) + { + if (ui.Property("Auto Calculate", comp.AutoCalculate)) + { + changed = true; + } + if (!comp.AutoCalculate) + { + if (ui.File("Model Path", comp.ModelPath, ".glb,.gltf,.obj")) + { + changed = true; + } + } + } + + if (ui.Property("Friction", comp.Friction, PropertyMeta(0.0f, 1.0f, 0.01f))) + { + changed = true; + } + if (ui.Property("Restitution", comp.Restitution, PropertyMeta(0.0f, 1.0f, 0.01f))) + { + changed = true; + } + if (ui.Property("Is Trigger", comp.IsTrigger)) + { + changed = true; + } + if (ui.Property("Enabled", comp.Enabled)) + { + changed = true; + } + + return changed; + }, + ICON_FA_SHIELD); + + // --- Scripting --- + RegisterCustom( + "Scripts", + [](ManagedScriptComponent& comp, Entity entity) { + bool changed = false; + + for (int i = 0; i < (int)comp.Scripts.size(); i++) + { + auto& script = comp.Scripts[i]; + ImGui::PushID(i); + + // We are already inside a PropertyGrid table (2 columns). + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_Framed | + ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_AllowOverlap | + ImGuiTreeNodeFlags_SpanAllColumns; + + // Extract short class name (after last dot) + std::string fullClassName = script.ClassName; + size_t lastDot = fullClassName.find_last_of('.'); + std::string shortName = + (lastDot == std::string::npos) ? fullClassName : fullClassName.substr(lastDot + 1); + std::string label = shortName.empty() ? "-- Empty Script --" : shortName; + + float lineHeight = ImGui::GetFontSize() + ImGui::GetStyle().FramePadding.y * 2.0f; + bool open = + ImGui::TreeNodeEx((void*)(uintptr_t)i, flags, "%s %s", ICON_FA_FILE_CODE, label.c_str()); + + // Tooltip with full name + if (ImGui::IsItemHovered() && !fullClassName.empty()) + { + ImGui::SetTooltip("%s", fullClassName.c_str()); + } + + // Delete button in the header row (right aligned in column 1) + ImGui::TableSetColumnIndex(1); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - lineHeight - 5.0f); + if (ImGui::Button(ICON_FA_TRASH, ImVec2{lineHeight, lineHeight})) + { + comp.Scripts.erase(comp.Scripts.begin() + i); + changed = true; + if (open) + { + ImGui::TreePop(); + } + ImGui::PopID(); + break; + } + + if (open) + { + UIProperties ui; + // Manually draw fields from the map, skipping redundancy + for (auto& [fieldName, field] : script.Fields) + { + std::visit( + [&](auto&& val) { + if (ui.Property(fieldName.c_str(), val)) + { + changed = true; + } + }, + field.Value); + } + + ImGui::TreePop(); + } + + ImGui::PopID(); + ImGui::Spacing(); + } + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(1); + if (EditorGUI::ActionButton(ICON_FA_PLUS, "Add Script")) + { + ImGui::OpenPopup("AddScriptPopup"); + } + + if (ImGui::BeginPopup("AddScriptPopup")) + { + if (auto* se = ServiceLocator::TryGet()) + { + for (const auto& [className, type] : se->GetRegistry().GetScriptClasses()) + { + // Extract short name for menu + size_t lastDot = className.find_last_of('.'); + std::string shortName = + (lastDot == std::string::npos) ? className : className.substr(lastDot + 1); + + if (ImGui::MenuItem(shortName.c_str())) + { + comp.Scripts.emplace_back(className); + changed = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", className.c_str()); + } + } + } + ImGui::EndPopup(); + } + + return changed; + }, + ICON_FA_FILE_CODE); + + RegisterCustom( + "Model", + [&](ModelComponent& comp, Entity entity) { + bool changed = false; + UIProperties ui; + Properties props(ui); + + if (ui.File("Model Path", comp.ModelPath, ".glb,.gltf,.obj")) + { + comp.ModelHandle = AssetHandle(0); + comp.MaterialPaths.clear(); + changed = true; + } + + auto* am = ServiceLocator::TryGet(); + if (am && !comp.ModelPath.empty()) + { + auto handle = am->ResolveToHandle(comp.ModelPath); + if (handle != AssetHandle(0)) + { + auto asset = am->Get(handle); + if (asset) + { + const char* stateStr = "Unknown"; + ImVec4 stateColor(0.7f, 0.7f, 0.7f, 1.0f); + switch (asset->GetState()) + { + case AssetState::Ready: + stateStr = "Ready"; + stateColor = ImVec4(0.3f, 0.8f, 0.3f, 1.0f); + break; + case AssetState::Loading: + stateStr = "Loading"; + stateColor = ImVec4(0.9f, 0.7f, 0.2f, 1.0f); + break; + case AssetState::Failed: + stateStr = "Failed"; + stateColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); + break; + default: + break; + } + ImGui::SameLine(); + ImGui::TextColored(stateColor, "%s", stateStr); + + if (asset->GetState() == AssetState::Ready) + { + ImGui::SameLine(); + if (ImGui::SmallButton("Reload")) + { + am->Invalidate(comp.ModelPath); + comp.ModelHandle = AssetHandle(0); + comp.MaterialPaths.clear(); + CH_CORE_INFO("ModelComponent: Invalidated '{}', will reload next frame", + comp.ModelPath); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Delete .chasset")) + { + am->DeleteChasset(comp.ModelPath); + am->Invalidate(comp.ModelPath); + comp.ModelHandle = AssetHandle(0); + comp.MaterialPaths.clear(); + CH_CORE_INFO("ModelComponent: Deleted .chasset for '{}', will re-import next frame", + comp.ModelPath); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Delete .chmat")) + { + std::filesystem::path modelPath(comp.ModelPath); + std::string modelName = modelPath.stem().string(); + std::filesystem::path modelDir = modelPath.parent_path(); + for (const auto& mp : comp.MaterialPaths) + { + if (!mp.empty()) + { + std::string resolved = am->ResolvePath(mp); + std::error_code ec; + std::filesystem::remove(resolved, ec); + std::filesystem::remove(resolved + ".meta", ec); + am->Invalidate(mp); + } + } + for (int i = 0; i < 64; ++i) + { + std::string matFileName = + modelName + "_material_" + std::to_string(i) + ".chmat"; + std::string matRel = (modelDir / matFileName).generic_string(); + std::string resolved = am->ResolvePath(matRel); + if (std::filesystem::exists(resolved)) + { + std::error_code ec; + std::filesystem::remove(resolved, ec); + std::filesystem::remove(resolved + ".meta", ec); + am->Invalidate(matRel); + } + } + comp.MaterialPaths.clear(); + am->Invalidate(comp.ModelPath); + comp.ModelHandle = AssetHandle(0); + CH_CORE_INFO( + "ModelComponent: Deleted .chmat files for '{}', restored default materials", + comp.ModelPath); + } + } + } + } + } + + return changed; + }, + ICON_FA_SHAPES); + + RegisterCustom( + "Animation", + [&](AnimationComponent& comp, Entity entity) { + bool changed = false; + UIProperties ui; + Properties props(ui); + + if (ui.File("Graph Path", comp.GraphPath, ".chag")) + { + comp.GraphAssetHandle = AssetHandle(0); + changed = true; + } + + auto* am = ServiceLocator::TryGet(); + if (ImGui::Button("New Graph")) + { + std::string baseName = "anim_graph"; + if (entity.HasComponent()) + { + std::string tag = entity.GetComponent().Tag; + if (!tag.empty()) + { + std::string cleanTag = tag; + std::replace(cleanTag.begin(), cleanTag.end(), ' ', '_'); + std::replace(cleanTag.begin(), cleanTag.end(), '/', '_'); + std::replace(cleanTag.begin(), cleanTag.end(), '\\', '_'); + std::replace(cleanTag.begin(), cleanTag.end(), '#', '_'); + std::transform(cleanTag.begin(), cleanTag.end(), cleanTag.begin(), ::tolower); + baseName = cleanTag + "_graph"; + } + } + else if (entity.HasComponent()) + { + std::string mPath = entity.GetComponent().ModelPath; + if (!mPath.empty()) + { + baseName = std::filesystem::path(mPath).stem().string() + "_graph"; + } + } + + std::string cand = "animations/" + baseName + ".chag"; + if (am) + { + int counter = 1; + while (std::filesystem::exists(am->ResolvePath(cand))) + { + cand = "animations/" + baseName + "_" + std::to_string(counter++) + ".chag"; + } + } + comp.GraphPath = cand; + comp.GraphAssetHandle = AssetHandle(0); + + AnimationGraphAsset newGraph; + AnimGraphLoader loader; + if (am) + { + loader.Save(newGraph, am->ResolvePath(cand)); + am->Invalidate(cand); + } + changed = true; + } + + ImGui::SameLine(); + if (ImGui::Button("Duplicate Graph") && !comp.GraphPath.empty() && am) + { + std::string srcResolved = am->ResolvePath(comp.GraphPath); + if (std::filesystem::exists(srcResolved)) + { + std::filesystem::path p(comp.GraphPath); + std::string newPath = (p.parent_path() / (p.stem().string() + "_copy.chag")).generic_string(); + int counter = 1; + while (std::filesystem::exists(am->ResolvePath(newPath))) + { + newPath = + (p.parent_path() / (p.stem().string() + "_copy" + std::to_string(counter++) + ".chag")) + .generic_string(); + } + std::error_code ec; + std::filesystem::copy_file(srcResolved, am->ResolvePath(newPath), ec); + comp.GraphPath = newPath; + comp.GraphAssetHandle = AssetHandle(0); + am->Invalidate(newPath); + changed = true; + } + } + + if (ui.Property("Blend Duration", comp.BlendDuration, PropertyMeta(0.0f, 10.0f, 0.01f))) + { + changed = true; + } + if (ui.Property("Default Loop", comp.DefaultIsLooping)) + { + changed = true; + } + if (ui.Property("Play On Start", comp.PlayOnStart)) + { + changed = true; + } + + return changed; + }, + ICON_FA_FILM); + + // --- UI Components --- + + // --- UI Widgets --- + RegisterCustom( + "Widget", + [](UIControlComponent& comp, Entity entity) { + bool changed = false; + UIProperties ui; + + // Box Style + ui.Header("Box Style"); + if (ui.Property("BG Color", comp.BoxStyle.BackgroundColor)) + { + changed = true; + } + if (ui.Property("Hover Color", comp.BoxStyle.HoverColor)) + { + changed = true; + } + if (ui.Property("Pressed Color", comp.BoxStyle.PressedColor)) + { + changed = true; + } + if (ui.Property("Border Color", comp.BoxStyle.BorderColor)) + { + changed = true; + } + if (ui.Property("Rounding", comp.BoxStyle.Rounding, PropertyMeta(0.0f, 32.0f, 0.5f))) + { + changed = true; + } + if (ui.Property("Border Size", comp.BoxStyle.BorderSize, PropertyMeta(0.0f, 10.0f, 0.1f))) + { + changed = true; + } + if (ui.Property("Padding", comp.BoxStyle.Padding, PropertyMeta(0.0f, 64.0f, 0.5f))) + { + changed = true; + } + if (ui.Property("Hover Scale", comp.BoxStyle.HoverScale, PropertyMeta(0.5f, 3.0f, 0.01f))) + { + changed = true; + } + if (ui.Property("Pressed Scale", comp.BoxStyle.PressedScale, PropertyMeta(0.5f, 3.0f, 0.01f))) + { + changed = true; + } + if (ui.Property("Transition Speed", comp.BoxStyle.TransitionSpeed, PropertyMeta(0.0f, 2.0f, 0.01f))) + { + changed = true; + } + if (ui.Property("Gradient", comp.BoxStyle.UseGradient)) + { + changed = true; + } + if (ui.Property("Gradient Color", comp.BoxStyle.GradientColor)) + { + changed = true; + } + + const bool needsTextStyle = + std::holds_alternative(comp.Data) || std::holds_alternative(comp.Data) || + std::holds_alternative(comp.Data) || + std::holds_alternative(comp.Data) || + std::holds_alternative(comp.Data) || + std::holds_alternative(comp.Data) || + std::holds_alternative(comp.Data) || + std::holds_alternative(comp.Data) || + std::holds_alternative(comp.Data) || std::holds_alternative(comp.Data) || + std::holds_alternative(comp.Data) || + std::holds_alternative(comp.Data) || + std::holds_alternative(comp.Data) || std::holds_alternative(comp.Data) || + std::holds_alternative(comp.Data); + + if (needsTextStyle) + { + ui.Separator(); + // Text Style + ui.Header("Text Style"); + { + auto* fontRegistry = ServiceLocator::TryGet(); + auto fontNames = fontRegistry ? fontRegistry->GetKnownFontNames() : std::vector{}; + fontNames.insert(fontNames.begin(), "Default"); + if (ui.StringEnum("Font Name", comp.TextStyle.FontName, fontNames)) + { + changed = true; + } + } + if (ui.Property("Font Size", comp.TextStyle.FontSize, PropertyMeta(4.0f, 256.0f, 0.5f))) + { + changed = true; + } + if (ui.Property("Text Color", comp.TextStyle.TextColor)) + { + changed = true; + } + if (ui.Property("Shadow", comp.TextStyle.Shadow)) + { + changed = true; + } + if (comp.TextStyle.Shadow) + { + if (ui.Property("Shadow Offset", comp.TextStyle.ShadowOffset, PropertyMeta(0.0f, 20.0f, 0.5f))) + { + changed = true; + } + if (ui.Property("Shadow Color", comp.TextStyle.ShadowColor)) + { + changed = true; + } + } + if (ui.Property("Letter Spacing", comp.TextStyle.LetterSpacing, PropertyMeta(0.0f, 10.0f, 0.05f))) + { + changed = true; + } + if (ui.Property("Line Height", comp.TextStyle.LineHeight, PropertyMeta(0.0f, 5.0f, 0.05f))) + { + changed = true; + } + if (ui.Property("H Align", comp.TextStyle.Horizontal)) + { + changed = true; + } + if (ui.Property("V Align", comp.TextStyle.Vertical)) + { + changed = true; + } + } + + ui.Separator(); + // Widget-type specific + std::visit( + [&](auto&& data) { + using T = std::decay_t; + if constexpr (std::is_same_v) + { + changed = DrawButtonData(data, ui) || changed; + } + else if constexpr (std::is_same_v) + { + changed = DrawLabelData(data, ui) || changed; + } + else if constexpr (std::is_same_v) + { + changed = DrawCheckboxData(data, ui) || changed; + } + else if constexpr (std::is_same_v) + { + changed = DrawSliderData(data, ui) || changed; + } + else if constexpr (std::is_same_v) + { + changed = DrawProgressBarData(data, ui) || changed; + } + else if constexpr (std::is_same_v) + { + changed = DrawImageData(data, ui) || changed; + } + else if constexpr (std::is_same_v) + { + changed = DrawPanelData(data, ui) || changed; + } + else if constexpr (std::is_same_v) + { + changed = DrawComboBoxData(data, ui) || changed; + } + else if constexpr (std::is_same_v) + { + changed = DrawInputTextData(data, ui) || changed; + } + else if constexpr (std::is_same_v) + { + changed = DrawImageButtonData(data, ui) || changed; + } + else if constexpr (std::is_same_v) + { + changed = DrawRadioButtonData(data, ui) || changed; + } + else if constexpr (std::is_same_v) + { + changed = DrawDragFloatData(data, ui) || changed; + } + else if constexpr (std::is_same_v) + { + changed = DrawDragIntData(data, ui) || changed; + } + }, + comp.Data); + + return changed; + }, + ICON_FA_SHAPES); + + // Mark only real UI widget types as IsWidget (these will be hidden in 3D scenes) + auto markWidget = [&](entt::id_type id) { ComponentRegistry::SetIsWidget(id, true); }; + markWidget(entt::type_hash::value()); + markWidget(entt::type_hash::value()); + markWidget(entt::type_hash::value()); + markWidget(entt::type_hash::value()); + } + + void PropertyEditor::DrawComponentInternal(entt::id_type typeId, const std::string& name, const char* icon, + Entity entity, std::function contentDrawer, + std::function remover) + { + const ImGuiTreeNodeFlags treeNodeFlags = ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_Framed | + ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_AllowOverlap | + ImGuiTreeNodeFlags_FramePadding; + + ImVec2 contentRegionAvailable = ImGui::GetContentRegionAvail(); + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2{4, 4}); + float lineHeight = ImGui::GetFontSize() + ImGui::GetStyle().FramePadding.y * 2.0f; + + // Header Background Color + ImGui::PushStyleColor(ImGuiCol_Header, {0.2f, 0.25f, 0.35f, 0.8f}); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, {0.3f, 0.4f, 0.6f, 1.0f}); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, {0.25f, 0.35f, 0.5f, 1.0f}); + + std::string headerName = (icon ? std::string(icon) + " " : "") + name; + bool open = ImGui::TreeNodeEx((void*)typeId, treeNodeFlags, headerName.c_str()); + + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(); + + // Right-aligned settings button + ImGui::SameLine(contentRegionAvailable.x - lineHeight * 0.7f); + ImGui::PushStyleColor(ImGuiCol_Button, {0, 0, 0, 0}); + if (ImGui::Button(ICON_FA_GEAR, ImVec2{lineHeight, lineHeight})) + { + ImGui::OpenPopup("ComponentSettings"); + } + ImGui::PopStyleColor(); + + bool removed = false; + if (ImGui::BeginPopup("ComponentSettings")) + { + if (ImGui::MenuItem("Remove Component")) + { + remover(); + removed = true; + } + + ImGui::EndPopup(); + } + + if (open) + { + if (!removed) + { + EditorGUI::BeginPropertyGrid(); + contentDrawer(); + EditorGUI::EndPropertyGrid(); + } + ImGui::TreePop(); + ImGui::Spacing(); + } + } + + void PropertyEditor::DrawEntityProperties(Chained::Entity entity) + { + auto& registry = entity.GetRegistry(); + bool isUI = entity.HasComponent(); + + auto& compRegistry = ComponentRegistry::GetRegistry(); + + // 2. Draw components efficiently + for (auto [id, storage] : registry.storage()) + { + if (storage.contains(entity) && compRegistry.contains(id)) + { + auto& metadata = compRegistry.at(id); + if (!metadata.Visible) + { + continue; + } + + // Logic to reduce clutter + if (isUI && id == entt::type_hash::value()) + { + continue; + } + + ImGui::PushID((int)id); + if (metadata.DrawUI) + { + metadata.DrawUI(entity); + } + else if (metadata.IsReflective && metadata.ReflectInternal) + { + DrawGenericReflection(metadata, entity); + } + ImGui::PopID(); + } + } + } + + void PropertyEditor::DrawEntityHeader(Chained::Entity entity) + { + if (entity.HasComponent()) + { + auto& tag = entity.GetComponent().Tag; + + // Entity Icon and Label + ImGui::BeginGroup(); + ImGui::PushFont(ImGui::GetIO().Fonts->Fonts[0]); + ImGui::TextColored({0.4f, 0.6f, 0.9f, 1.0f}, ICON_FA_CUBE " Entity"); + ImGui::PopFont(); + + char buffer[256]; + memset(buffer, 0, sizeof(buffer)); + strncpy(buffer, tag.c_str(), sizeof(buffer) - 1); + + ImGui::PushItemWidth(ImGui::GetContentRegionAvail().x - 120.0f); + if (ImGui::InputText("##Tag", buffer, sizeof(buffer))) + { + tag = std::string(buffer); + } + ImGui::PopItemWidth(); + + ImGui::SameLine(); + if (ImGui::Button(ICON_FA_PLUS " Add Component", ImVec2(110, 0))) + { + ImGui::OpenPopup("AddComponent"); + } + + DrawAddComponentPopup(entity); + ImGui::EndGroup(); + + ImGui::Spacing(); + } + } + + void PropertyEditor::DrawAddComponentPopup(Entity entity) + { + if (ImGui::BeginPopup("AddComponent")) + { + bool isUIEntity = entity.HasComponent(); + auto* scene = entity.GetRegistry().ctx().find(); + bool is3DScene = scene && (*scene)->GetSettings().Mode == BackgroundMode::Environment3D; + + // Group components by category + std::map> categorized; + + for (auto& [id, metadata] : ComponentRegistry::GetRegistry()) + { + if (!metadata.AllowAdd) + { + continue; + } + if (metadata.IsWidget && !isUIEntity) + { + continue; + } + if (is3DScene && (metadata.IsWidget || id == entt::type_hash::value())) + { + continue; + } + + auto& registry = entity.GetRegistry(); + auto* storage = registry.storage(id); + if (storage && storage->contains(entity)) + { + continue; + } + + categorized[metadata.Category].push_back(&metadata); + } + + // Render categorized menus + for (auto& [category, components] : categorized) + { + if (ImGui::BeginMenu(category.c_str())) + { + for (const auto* metadata : components) + { + std::string label = (metadata->Icon ? std::string(metadata->Icon) + " " : "") + metadata->Name; + if (ImGui::MenuItem(label.c_str())) + { + metadata->Add(entity); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndMenu(); + } + } + + ImGui::EndPopup(); + } + } +} // namespace Chained diff --git a/editor/panels/property_editor.h b/editor/panels/property_editor.h index 3cf4e86e8..41682eb4b 100644 --- a/editor/panels/property_editor.h +++ b/editor/panels/property_editor.h @@ -3,59 +3,50 @@ #include #include -#include +#include "engine/scene/component_registry.h" #include "engine/scene/entity.h" -namespace CHEngine +namespace Chained { -class PropertyEditor -{ -public: - struct ComponentMetadata - { - std::string Name; - const char* Icon = nullptr; - std::function Draw; - std::function Add; - bool Visible = true; - bool AllowAdd = true; - bool IsWidget = false; - }; - - static void Init(); - - // Registry API - static void RegisterComponent(entt::id_type typeId, const ComponentMetadata& metadata); - static void DrawEntityProperties(Entity entity); - static void DrawAddComponentPopup(Entity entity); - - // Automation: Register using Reflection - template static void Register(const std::string& name, const char* icon = nullptr); - - // Custom Drawer Registration - template - static void RegisterCustom(const std::string& name, std::function drawer, - const char* icon = nullptr); - - static void DrawEntityHeader(CHEngine::Entity entity); - -private: - // Internal template helpers (Implementations moved to .cpp or a separate _impl.h if needed elsewhere) - template static void DrawComponentReflection(const std::string& name, const char* icon, Entity entity); - - template - static void DrawComponentContainer(const std::string& name, const char* icon, Entity entity, - std::function drawer); - - // Final non-template drawing core - static void DrawComponentInternal(entt::id_type typeId, const std::string& name, const char* icon, Entity entity, - std::function contentDrawer, std::function remover); - -private: - static std::unordered_map s_ComponentRegistry; -}; - -} // namespace CHEngine + class PropertyEditor + { + public: + static void Init(); + + // Registry API + static void DrawEntityProperties(Entity entity); + static void DrawAddComponentPopup(Entity entity); + + // Automation: Register using Reflection + template static void Register(const std::string& name, const char* icon = nullptr); + + // Custom Drawer Registration + template + static void RegisterCustom(const std::string& name, F&& drawer, const char* icon = nullptr); + + static void DrawEntityHeader(Entity entity); + + private: + // Shared registration logic + template + static void RegisterComponentImpl(const std::string& name, const char* icon, + std::function drawUI); + + // Internal template helpers (Implementations moved to .cpp or a separate _impl.h if needed elsewhere) + template + static void DrawComponentReflection(const std::string& name, const char* icon, Entity entity); + static void DrawGenericReflection(const ComponentMetadata& metadata, Entity entity); + + template + static void DrawComponentContainer(const std::string& name, const char* icon, Entity entity, F&& drawer); + + // Final non-template drawing core + static void DrawComponentInternal(::entt::id_type typeId, const std::string& name, const char* icon, + Entity entity, std::function contentDrawer, + std::function remover); + }; + +} // namespace Chained #endif // CH_PROPERTY_EDITOR_H diff --git a/editor/panels/scene_hierarchy_panel.cpp b/editor/panels/scene_hierarchy_panel.cpp index b08039434..b4c36686a 100644 --- a/editor/panels/scene_hierarchy_panel.cpp +++ b/editor/panels/scene_hierarchy_panel.cpp @@ -1,542 +1,550 @@ #include "scene_hierarchy_panel.h" -#include "editor_layer.h" -#include "engine/core/application.h" +#include "editor/events.h" +#include "editor/layer.h" +#include "editor/types.h" +#include "editor/undo/command_history.h" +#include "engine/app/application.h" +#include "engine/platform/dialogs/dialogs.h" #include "engine/scene/components.h" #include "engine/scene/scene_events.h" #include "engine/scene/scene_settings.h" -#include "IconsFontAwesome6.h" +#include "thirdparty/IconsFontAwesome6.h" +#include "engine/core/input.h" +#include "engine/core/platform.h" +#include "engine/scene/prefab_serializer.h" +#include "engine/scene/scene_serializer.h" +#include "events.h" #include "imgui.h" #include "undo/entity_commands.h" -#include "editor_events.h" -#include "engine/core/input.h" -#include #include +#include #include namespace { - bool IsDescendant(CHEngine::Entity child, CHEngine::Entity possibleParent) - { - if (child == possibleParent) return true; - - if (!possibleParent.HasComponent()) return false; - - for (entt::entity c : possibleParent.GetComponent().Children) - { - if (IsDescendant(child, CHEngine::Entity(c, child.GetRegistryPtr()))) return true; - } - return false; - } -} - -namespace CHEngine -{ -SceneHierarchyPanel::SceneHierarchyPanel() -{ - m_Name = "Scene Hierarchy"; -} - -SceneHierarchyPanel::SceneHierarchyPanel(const std::shared_ptr& context) -{ - m_Name = "Scene Hierarchy"; - SetContext(context); -} - -void SceneHierarchyPanel::OnImGuiRender(bool readOnly) -{ - ImGui::Begin("Scene Hierarchy"); - - // Search Bar - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2{4, 4}); - ImGui::InputTextWithHint("##Search", ICON_FA_MAGNIFYING_GLASS " Search...", m_SearchBuffer, sizeof(m_SearchBuffer)); - ImGui::PopStyleVar(); - ImGui::Separator(); - - if (m_Context) - { - m_DrawnEntities.clear(); - m_EntitiesToDestroyPending.clear(); - - ImGui::BeginDisabled(readOnly); - - std::string filter = m_SearchBuffer; - std::transform(filter.begin(), filter.end(), filter.begin(), ::tolower); - - // Draw entities - auto view = m_Context->GetRegistry().view(); - for (auto entityID : view) - { - Entity entity(entityID, &m_Context->GetRegistry()); - - // Skip child entities, they will be drawn recursively - if (entity.HasComponent() && - entity.GetComponent().Parent != entt::null) - { - continue; - } - - // Skip hidden UI components - if (entity.HasComponent() && entity.GetComponent().HiddenInHierarchy) - { - continue; - } - - // Apply search filter (if not empty) - if (!filter.empty()) - { - std::string tag = entity.GetComponent().Tag; - std::transform(tag.begin(), tag.end(), tag.begin(), ::tolower); - if (tag.find(filter) == std::string::npos) - { - continue; - } - } - - DrawEntityNodeRecursive(entity, readOnly); - } - - if (ImGui::IsMouseDown(0) && ImGui::IsWindowHovered() && !ImGui::IsAnyItemHovered()) - { - EntitySelectedEvent e(entt::null, m_Context.get()); - Application::Get().OnEvent(e); - } - - // Focus Shortcut - if (ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows) && Input::IsKeyPressed(Key::F)) - - - { - Entity selected = EditorContext::GetSelectedEntity(); - if (selected) - { - ViewportFocusEntityEvent e(selected); - Application::Get().OnEvent(e); - } - } - - // Duplicate Shortcut - if (ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows) && Input::IsKeyDown(Key::LeftControl) && - Input::IsKeyPressed(Key::D)) - - - { - Entity selected = EditorContext::GetSelectedEntity(); - if (selected) - { - EditorLayer::GetCommandHistory().PushCommand(std::make_unique(selected)); - } - } - - // Blank space context menu - if (!readOnly && ImGui::BeginPopupContextWindow(0, ImGuiPopupFlags_MouseButtonRight | ImGuiPopupFlags_NoOpenOverItems)) - { - DrawContextMenu(); - ImGui::EndPopup(); - } - - // Blank space drop target to unparent - ImGui::Dummy(ImGui::GetContentRegionAvail()); - if (!readOnly && ImGui::BeginDragDropTarget()) - { - if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("ENTITY")) - { - uint64_t droppedUUID = *(const uint64_t*)payload->Data; - Entity sourceEntity = m_Context->GetEntityByUUID(droppedUUID); - if (sourceEntity) - { - EditorLayer::GetCommandHistory().PushCommand(std::make_unique(sourceEntity, Entity{}, m_Context.get())); - } - } - ImGui::EndDragDropTarget(); - } - - ImGui::EndDisabled(); - } - - ImGui::End(); - - if (!m_EntitiesToDestroyPending.empty()) - { - for (auto ent : m_EntitiesToDestroyPending) - { - Entity entity(ent, &m_Context->GetRegistry()); - if (entity.IsValid()) - { - EditorLayer::GetCommandHistory().PushCommand(std::make_unique(entity)); - } - } - m_EntitiesToDestroyPending.clear(); - } -} - -const char* SceneHierarchyPanel::GetEntityIcon(Entity entity) -{ - if (entity.HasComponent()) - { - return ICON_FA_ARROW_POINTER; - } - if (entity.HasComponent()) - { - return ICON_FA_FONT; - } - if (entity.HasComponent()) - { - return ICON_FA_SLIDERS; - } - if (entity.HasComponent()) - { - return ICON_FA_SQUARE_CHECK; - } - if (entity.HasComponent() || entity.HasComponent()) - { - return ICON_FA_IMAGE; - } - if (entity.HasComponent()) - { - return ICON_FA_SHAPES; - } - if (entity.HasComponent()) - { - return ICON_FA_LIGHTBULB; - } - if (entity.HasComponent()) - { - return ICON_FA_VIDEO; - } - if (entity.HasComponent()) - { - return ICON_FA_VOLUME_HIGH; - } - if (entity.HasComponent()) - { - return ICON_FA_CODE; - } - - return ICON_FA_CUBE; -} - -void SceneHierarchyPanel::DrawEntityNodeRecursive(Entity entity, bool readOnly) -{ - if (!entity || !entity.IsValid() || m_DrawnEntities.contains(entity)) - { - return; - } - - m_DrawnEntities.insert(entity); - - auto& tag = entity.GetComponent().Tag; - std::string label = std::string(GetEntityIcon(entity)) + " " + tag; - - auto selectedEntity = EditorLayer::Get().GetSelectedEntity(); - ImGuiTreeNodeFlags flags = ((selectedEntity == entity) ? ImGuiTreeNodeFlags_Selected : 0); - flags |= ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanAvailWidth; - - if (!entity.HasComponent() || entity.GetComponent().Children.empty()) - { - flags |= ImGuiTreeNodeFlags_Leaf; - } - - ImGui::PushID((int)(uint32_t)entity); - - bool opened = false; - bool renamed = false; - - if (m_Renaming && m_RenamingEntity == entity) - { - ImGui::SetKeyboardFocusHere(); - if (ImGui::InputText("##Rename", m_RenameBuffer, sizeof(m_RenameBuffer), ImGuiInputTextFlags_EnterReturnsTrue) || - (ImGui::IsMouseClicked(0) && !ImGui::IsItemHovered())) - { - tag = m_RenameBuffer; - m_Renaming = false; - renamed = true; - } - } - else - { - opened = ImGui::TreeNodeEx(label.c_str(), flags); - } - - // Drag & Drop Source - if (!readOnly && ImGui::BeginDragDropSource()) - { - uint64_t uuid = entity.GetUUID(); - ImGui::SetDragDropPayload("ENTITY", &uuid, sizeof(uint64_t)); - ImGui::Text("%s", label.c_str()); - ImGui::EndDragDropSource(); - } - - // Drag & Drop Target - if (!readOnly && ImGui::BeginDragDropTarget()) - { - if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("ENTITY")) - { - uint64_t droppedUUID = *(const uint64_t*)payload->Data; - Entity sourceEntity = m_Context->GetEntityByUUID(droppedUUID); - if (sourceEntity && sourceEntity != entity && !IsDescendant(entity, sourceEntity)) - { - EditorLayer::GetCommandHistory().PushCommand(std::make_unique(sourceEntity, entity, m_Context.get())); - } - } - ImGui::EndDragDropTarget(); - } - - if (ImGui::IsItemClicked()) - { - EntitySelectedEvent e(entity, m_Context.get()); - Application::Get().OnEvent(e); - } - - // Rename on F2 - if (selectedEntity == entity && ImGui::IsKeyPressed(ImGuiKey_F2) && !m_Renaming) - { - m_Renaming = true; - m_RenamingEntity = entity; - strncpy(m_RenameBuffer, tag.c_str(), sizeof(m_RenameBuffer)); - } - - if (!readOnly && ImGui::BeginPopupContextItem()) - { - if (ImGui::MenuItem(ICON_FA_PEN " Rename", "F2")) - { - m_Renaming = true; - m_RenamingEntity = entity; - strncpy(m_RenameBuffer, tag.c_str(), sizeof(m_RenameBuffer)); - } - if (ImGui::MenuItem(ICON_FA_COPY " Duplicate", "Ctrl+D")) - { - EditorLayer::GetCommandHistory().PushCommand(std::make_unique(entity)); - } - ImGui::Separator(); - if (ImGui::MenuItem(ICON_FA_TRASH " Delete Entity", "Del")) - { - m_EntitiesToDestroyPending.push_back((entt::entity)entity); - } - - ImGui::EndPopup(); - } - - if (opened) - { - if (entity.HasComponent()) - { - auto children = entity.GetComponent().Children; // Copy to avoid iteration issues - for (auto childID : children) - { - DrawEntityNodeRecursive(Entity(childID, &m_Context->GetRegistry()), readOnly); - } - } - ImGui::TreePop(); - } - ImGui::PopID(); -} - -void SceneHierarchyPanel::DrawContextMenu() + bool IsDescendant(Chained::Entity child, Chained::Entity possibleParent) + { + if (child == possibleParent) + { + return true; + } + + if (!possibleParent.HasComponent()) + { + return false; + } + + std::queue queue; + for (entt::entity c : possibleParent.GetComponent().Children) + { + queue.push(c); + } + + while (!queue.empty()) + { + entt::entity current = queue.front(); + queue.pop(); + + Chained::Entity currentEnt(current, child.GetRegistryPtr()); + if (currentEnt == child) + { + return true; + } + + if (currentEnt.HasComponent()) + { + for (entt::entity c : currentEnt.GetComponent().Children) + { + queue.push(c); + } + } + } + return false; + } +} // namespace + +namespace Chained { - if (ImGui::MenuItem("Create Empty Entity")) - { - m_Context->CreateEntity("Empty Entity"); - } - - if (ImGui::BeginMenu("Create")) - { - if (ImGui::MenuItem("Static Box Collider")) - { - auto entity = m_Context->CreateEntity("Static Collider"); - auto& collider = entity.AddComponent(); - collider.Type = ColliderType::Box; - collider.AutoCalculate = false; - collider.Size = {1.0f, 1.0f, 1.0f}; - collider.Offset = {0.0f, 0.0f, 0.0f}; - } - ImGui::EndMenu(); - } - - if (ImGui::MenuItem("Camera")) - { - auto entity = m_Context->CreateEntity("Camera"); - entity.AddComponent(); - } - - if (ImGui::MenuItem("Point Light")) - { - auto entity = m_Context->CreateEntity("Point Light"); - auto& light = entity.AddComponent(); - light.Type = LightType::Point; - } - - if (ImGui::MenuItem("Spot Light")) - { - auto entity = m_Context->CreateEntity("Spot Light"); - auto& light = entity.AddComponent(); - light.Type = LightType::Spot; - } - - if (ImGui::MenuItem("Directional Light")) - { - auto entity = m_Context->CreateEntity("Directional Light"); - auto& light = entity.AddComponent(); - light.Type = LightType::Directional; - } - - if (ImGui::MenuItem("Spawn Zone")) - { - m_Context->CreateEntity("Spawn Zone").AddComponent(); - } - - if (ImGui::BeginMenu("3D Object")) - { - auto create = [this](const char* name, const char* mesh) { - EditorLayer::GetCommandHistory().PushCommand( - std::make_unique(m_Context.get(), name, mesh)); - }; - if (ImGui::MenuItem("Cube")) - { - create("Cube", ":cube:"); - } - if (ImGui::MenuItem("Sphere")) - { - create("Sphere", ":sphere:"); - } - if (ImGui::MenuItem("Cylinder")) - { - create("Cylinder", ":cylinder:"); - } - if (ImGui::MenuItem("Cone")) - { - create("Cone", ":cone:"); - } - if (ImGui::MenuItem("Torus")) - { - create("Torus", ":torus:"); - } - if (ImGui::MenuItem("Knot")) - { - create("Knot", ":knot:"); - } - if (ImGui::MenuItem("Plane")) - { - create("Plane", ":plane:"); - } - ImGui::EndMenu(); - } - - if (m_Context->GetSettings().Mode != BackgroundMode::Environment3D) - { - if (ImGui::BeginMenu("Control")) - { - if (ImGui::BeginMenu("Basic")) - { - if (ImGui::MenuItem("Panel")) - { - m_Context->CreateUIEntity("Panel"); - } - if (ImGui::MenuItem("Button")) - { - m_Context->CreateUIEntity("Button"); - } - if (ImGui::MenuItem("Label")) - { - m_Context->CreateUIEntity("Label"); - } - if (ImGui::MenuItem("Slider")) - { - m_Context->CreateUIEntity("Slider"); - } - if (ImGui::MenuItem("Checkbox")) - { - m_Context->CreateUIEntity("CheckBox"); - } - if (ImGui::MenuItem("InputText")) - { - m_Context->CreateUIEntity("InputText"); - } - if (ImGui::MenuItem("ComboBox")) - { - m_Context->CreateUIEntity("ComboBox"); - } - if (ImGui::MenuItem("ProgressBar")) - { - m_Context->CreateUIEntity("ProgressBar"); - } - ImGui::EndMenu(); - } - - if (ImGui::BeginMenu("Visual")) - { - if (ImGui::MenuItem("Image")) - { - m_Context->CreateUIEntity("Image"); - } - if (ImGui::MenuItem("Image Button")) - { - m_Context->CreateUIEntity("ImageButton"); - } - if (ImGui::MenuItem("Separator")) - { - m_Context->CreateUIEntity("Separator"); - } - ImGui::EndMenu(); - } - - if (ImGui::BeginMenu("Input")) - { - if (ImGui::MenuItem("RadioButton")) - { - m_Context->CreateUIEntity("RadioButton"); - } - if (ImGui::MenuItem("ColorPicker")) - { - m_Context->CreateUIEntity("ColorPicker"); - } - if (ImGui::MenuItem("Drag Float")) - { - m_Context->CreateUIEntity("DragFloat"); - } - if (ImGui::MenuItem("Drag Int")) - { - m_Context->CreateUIEntity("DragInt"); - } - ImGui::EndMenu(); - } - - if (ImGui::BeginMenu("Structural")) - { - if (ImGui::MenuItem("Tree Node")) - { - m_Context->CreateUIEntity("TreeNode"); - } - if (ImGui::MenuItem("Tab Bar")) - { - m_Context->CreateUIEntity("TabBar"); - } - if (ImGui::MenuItem("Tab Item")) - { - m_Context->CreateUIEntity("TabItem"); - } - if (ImGui::MenuItem("Collapsing Header")) - { - m_Context->CreateUIEntity("CollapsingHeader"); - } - ImGui::EndMenu(); - } - - if (ImGui::BeginMenu("Charts")) - { - if (ImGui::MenuItem("Plot Lines")) - { - m_Context->CreateUIEntity("PlotLines"); - } - if (ImGui::MenuItem("Plot Histogram")) - { - m_Context->CreateUIEntity("PlotHistogram"); - } - ImGui::EndMenu(); - } - - ImGui::EndMenu(); - } - } -} -} // namespace CHEngine + SceneHierarchyPanel::SceneHierarchyPanel() + { + m_Name = "Scene Hierarchy"; + } + + void SceneHierarchyPanel::OnImGuiRender(bool readOnly) + { + ImGui::Begin("Scene Hierarchy###SceneHierarchyPanel"); + + // Search Bar + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2{4, 4}); + ImGui::InputTextWithHint("##Search", ICON_FA_MAGNIFYING_GLASS " Search...", m_SearchBuffer, + sizeof(m_SearchBuffer)); + ImGui::PopStyleVar(); + ImGui::Separator(); + + if (m_Context) + { + m_DrawnEntities.clear(); + m_EntitiesToDestroyPending.clear(); + + bool isTransitioning = EditorLayer::Get().GetSceneManager().IsTransitioning(); + ImGui::BeginDisabled(readOnly || isTransitioning); + + std::string filter = m_SearchBuffer; + std::transform(filter.begin(), filter.end(), filter.begin(), ::tolower); + + // Draw entities + auto view = m_Context->GetRegistry().view(); + for (auto entityID : view) + { + Entity entity(entityID, &m_Context->GetRegistry()); + + // Skip child entities, they will be drawn recursively + if (entity.HasComponent() && + entity.GetComponent().Parent != entt::null) + { + continue; + } + + // Skip hidden UI components + if (entity.HasComponent() && + entity.GetComponent().HiddenInHierarchy) + { + continue; + } + + // Apply search filter (if not empty) + if (!filter.empty()) + { + std::string tag = entity.GetComponent().Tag; + std::transform(tag.begin(), tag.end(), tag.begin(), ::tolower); + if (tag.find(filter) == std::string::npos) + { + continue; + } + } + + DrawEntityNodeRecursive(entity, readOnly); + } + + if (ImGui::IsMouseDown(0) && ImGui::IsWindowHovered() && !ImGui::IsAnyItemHovered()) + { + DeselectEntity(m_Context.get()); + } + + // Focus Shortcut + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows) && Core::Input::IsKeyPressed(KeyCode::F)) + { + Entity selected = EditorLayer::Get().GetEditorState().SelectedEntity; + if (selected) + { + ViewportFocusEntityEvent e(selected); + Application::Get().OnEvent(e); + } + } + + // Duplicate Shortcut + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows) && + Core::Input::IsKeyDown(KeyCode::LeftControl) && Core::Input::IsKeyPressed(KeyCode::D)) + { + Entity selected = EditorLayer::Get().GetEditorState().SelectedEntity; + if (selected) + { + EditorLayer::Get().GetCommandHistory().PushCommand( + std::make_unique(selected)); + } + } + + // Blank space context menu + if (!readOnly && + ImGui::BeginPopupContextWindow(0, ImGuiPopupFlags_MouseButtonRight | ImGuiPopupFlags_NoOpenOverItems)) + { + DrawContextMenu(); + ImGui::EndPopup(); + } + + // Blank space drop target to unparent + ImGui::Dummy(ImGui::GetContentRegionAvail()); + if (!readOnly && ImGui::BeginDragDropTarget()) + { + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("ENTITY")) + { + uint64_t droppedUUID = *(const uint64_t*)payload->Data; + Entity sourceEntity = m_Context->GetEntityByUUID(droppedUUID); + if (sourceEntity) + { + EditorLayer::Get().GetCommandHistory().PushCommand( + std::make_unique(sourceEntity, Entity{}, m_Context.get())); + } + } + ImGui::EndDragDropTarget(); + } + + ImGui::EndDisabled(); + } + + ImGui::End(); + + if (!m_EntitiesToDestroyPending.empty()) + { + for (auto ent : m_EntitiesToDestroyPending) + { + Entity entity(ent, &m_Context->GetRegistry()); + if (entity.IsValid()) + { + EditorLayer::Get().GetCommandHistory().PushCommand(std::make_unique(entity)); + } + } + m_EntitiesToDestroyPending.clear(); + } + } + + const char* SceneHierarchyPanel::GetEntityIcon(Entity entity) + { + // Priority 1: UIControlComponent — detect widget subtype + if (entity.HasComponent()) + { + auto& widget = entity.GetComponent(); + if (std::holds_alternative(widget.Data)) + { + return ICON_FA_ARROW_POINTER; + } + if (std::holds_alternative(widget.Data)) + { + return ICON_FA_FONT; + } + if (std::holds_alternative(widget.Data)) + { + return ICON_FA_SLIDERS; + } + if (std::holds_alternative(widget.Data)) + { + return ICON_FA_SQUARE_CHECK; + } + if (std::holds_alternative(widget.Data) || std::holds_alternative(widget.Data)) + { + return ICON_FA_IMAGE; + } + return ICON_FA_WINDOW_MAXIMIZE; + } + + // Priority 2+: Use ComponentRegistry icon lookup + auto& registry = entity.GetRegistry(); + auto& compRegistry = ComponentRegistry::GetRegistry(); + + // Check components in a fixed priority order for consistent icon display + static const entt::id_type priorityOrder[] = { + entt::type_hash::value(), entt::type_hash::value(), + entt::type_hash::value(), entt::type_hash::value(), + entt::type_hash::value(), entt::type_hash::value(), + entt::type_hash::value(), entt::type_hash::value(), + entt::type_hash::value(), entt::type_hash::value(), + }; + + for (auto typeId : priorityOrder) + { + if (compRegistry.contains(typeId)) + { + auto& meta = compRegistry.at(typeId); + if (meta.Icon && meta.Has && meta.Has(entity)) + { + return meta.Icon; + } + } + } + + return ICON_FA_CUBE; + } + + void SceneHierarchyPanel::DrawEntityNodeRecursive(Entity entity, bool readOnly) + { + if (!entity || !entity.IsValid() || m_DrawnEntities.contains(entity)) + { + return; + } + + m_DrawnEntities.insert(entity); + + auto& tag = entity.GetComponent().Tag; + std::string label = std::string(GetEntityIcon(entity)) + " " + tag; + + auto selectedEntity = EditorLayer::Get().GetEditorState().SelectedEntity; + ImGuiTreeNodeFlags flags = ((selectedEntity == entity) ? ImGuiTreeNodeFlags_Selected : 0); + flags |= ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanAvailWidth; + + if (!entity.HasComponent() || entity.GetComponent().Children.empty()) + { + flags |= ImGuiTreeNodeFlags_Leaf; + } + + ImGui::PushID((int)(uint32_t)entity); + + bool opened = false; + bool renamed = false; + + if (m_Renaming && m_RenamingEntity == entity) + { + ImGui::SetKeyboardFocusHere(); + if (ImGui::InputText("##Rename", m_RenameBuffer, sizeof(m_RenameBuffer), + ImGuiInputTextFlags_EnterReturnsTrue) || + (ImGui::IsMouseClicked(0) && !ImGui::IsItemHovered())) + { + tag = m_RenameBuffer; + m_Renaming = false; + renamed = true; + } + } + else + { + opened = ImGui::TreeNodeEx(label.c_str(), flags); + } + + // Drag & Drop Source + if (!readOnly && ImGui::BeginDragDropSource()) + { + uint64_t uuid = entity.GetUUID(); + ImGui::SetDragDropPayload("ENTITY", &uuid, sizeof(uint64_t)); + ImGui::Text("%s", label.c_str()); + ImGui::EndDragDropSource(); + } + + // Drag & Drop Target + if (!readOnly && ImGui::BeginDragDropTarget()) + { + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("ENTITY")) + { + uint64_t droppedUUID = *(const uint64_t*)payload->Data; + Entity sourceEntity = m_Context->GetEntityByUUID(droppedUUID); + if (sourceEntity && sourceEntity != entity && !IsDescendant(entity, sourceEntity)) + { + EditorLayer::Get().GetCommandHistory().PushCommand( + std::make_unique(sourceEntity, entity, m_Context.get())); + } + } + ImGui::EndDragDropTarget(); + } + + if (ImGui::IsItemClicked()) + { + SelectEntity(entity, m_Context.get()); + } + + // Rename on F2 + if (selectedEntity == entity && ImGui::IsKeyPressed(ImGuiKey_F2) && !m_Renaming) + { + StartRename(entity); + } + + if (!readOnly && ImGui::BeginPopupContextItem()) + { + if (ImGui::MenuItem(ICON_FA_PEN " Rename", "F2")) + { + StartRename(entity); + } + if (ImGui::MenuItem(ICON_FA_COPY " Duplicate", "Ctrl+D")) + { + EditorLayer::Get().GetCommandHistory().PushCommand(std::make_unique(entity)); + } + ImGui::Separator(); + if (ImGui::MenuItem(ICON_FA_TRASH " Delete Entity", "Del")) + { + m_EntitiesToDestroyPending.push_back((entt::entity)entity); + } + ImGui::Separator(); + if (ImGui::MenuItem(ICON_FA_FILE_EXPORT " Save as Prefab...")) + { + std::vector filters = {{"Chained Prefab", "chprefab"}}; + auto path = Chained::Dialogs::SaveFile(filters); + if (path) + { + if (path->extension().empty()) + { + path->replace_extension(".chprefab"); + } + PrefabSerializer::Serialize(entity, path->string()); + } + } + + ImGui::EndPopup(); + } + + if (opened) + { + if (entity.HasComponent()) + { + auto children = entity.GetComponent().Children; // Copy to avoid iteration issues + for (auto childID : children) + { + DrawEntityNodeRecursive(Entity(childID, &m_Context->GetRegistry()), readOnly); + } + } + ImGui::TreePop(); + } + ImGui::PopID(); + } + + void SceneHierarchyPanel::DrawContextMenu() + { + if (ImGui::MenuItem("Create Empty Entity")) + { + m_Context->CreateEntity("Empty Entity"); + } + + if (ImGui::MenuItem(ICON_FA_FILE_IMPORT " Load Prefab...")) + { + std::vector filters = {{"Chained Prefab", "chprefab"}}; + auto path = Chained::Dialogs::OpenFile(filters); + if (path) + { + PrefabSerializer::Deserialize(m_Context.get(), path->string()); + } + } + + ImGui::Separator(); + + // --- Quick Create: Lights & Camera --- + if (m_Context->GetSettings().Type != SceneType::UI) + { + struct QuickCreateEntry + { + const char* label; + const char* icon; + std::function action; + }; + static const QuickCreateEntry quickCreates[] = { + {"Camera", ICON_FA_VIDEO, + [this]() { + auto e = m_Context->CreateEntity("Camera"); + e.AddComponent(); + }}, + {"Point Light", ICON_FA_LIGHTBULB, + [this]() { + auto e = m_Context->CreateEntity("Point Light"); + e.AddComponent().Type = LightType::Point; + }}, + {"Spot Light", ICON_FA_LIGHTBULB, + [this]() { + auto e = m_Context->CreateEntity("Spot Light"); + e.AddComponent().Type = LightType::Spot; + }}, + {"Directional Light", ICON_FA_LIGHTBULB, + [this]() { + auto e = m_Context->CreateEntity("Directional Light"); + e.AddComponent().Type = LightType::Directional; + }}, + }; + for (auto& entry : quickCreates) + { + std::string label = entry.icon ? std::string(entry.icon) + " " + entry.label : entry.label; + if (ImGui::MenuItem(label.c_str())) + { + entry.action(); + } + } + + ImGui::Separator(); + + // --- 3D Object Submenu --- + struct PrimitiveEntry + { + const char* label; + const char* mesh; + }; + static const PrimitiveEntry primitives[] = { + {"Cube", ":cube:"}, {"Sphere", ":sphere:"}, {"Cylinder", ":cylinder:"}, {"Cone", ":cone:"}, + {"Torus", ":torus:"}, {"Knot", ":knot:"}, {"Plane", ":plane:"}, + }; + if (ImGui::BeginMenu("3D Object")) + { + for (auto& p : primitives) + { + if (ImGui::MenuItem(p.label)) + { + EditorLayer::Get().GetCommandHistory().PushCommand( + std::make_unique(m_Context.get(), p.label, p.mesh)); + } + } + ImGui::EndMenu(); + } + } + + // --- UI Widget Submenus --- + if (m_Context->GetSettings().Type == SceneType::UI || + m_Context->GetSettings().Mode != BackgroundMode::Environment3D) + { + struct WidgetEntry + { + const char* label; + WidgetType type; + }; + struct WidgetCategory + { + const char* label; + const WidgetEntry* entries; + int count; + }; + + static const WidgetEntry basicWidgets[] = { + {"Panel", WidgetType_Panel}, {"Button", WidgetType_Button}, + {"Label", WidgetType_Label}, {"Slider", WidgetType_Slider}, + {"Checkbox", WidgetType_Checkbox}, {"InputText", WidgetType_InputText}, + {"ComboBox", WidgetType_ComboBox}, {"ProgressBar", WidgetType_ProgressBar}, + }; + static const WidgetEntry visualWidgets[] = { + {"Image", WidgetType_Image}, + {"Image Button", WidgetType_ImageButton}, + {"Separator", WidgetType_Separator}, + }; + static const WidgetEntry inputWidgets[] = { + {"RadioButton", WidgetType_RadioButton}, + {"ColorPicker", WidgetType_ColorPicker}, + {"Drag Float", WidgetType_DragFloat}, + {"Drag Int", WidgetType_DragInt}, + }; + static const WidgetEntry structuralWidgets[] = { + {"Tree Node", WidgetType_TreeNode}, + {"Tab Bar", WidgetType_TabBar}, + {"Tab Item", WidgetType_TabItem}, + {"Collapsing Header", WidgetType_CollapsingHeader}, + }; + static const WidgetEntry chartWidgets[] = { + {"Plot Lines", WidgetType_PlotLines}, + {"Plot Histogram", WidgetType_PlotHistogram}, + }; + + static const WidgetCategory widgetCategories[] = { + {"Basic", basicWidgets, (int)std::size(basicWidgets)}, + {"Visual", visualWidgets, (int)std::size(visualWidgets)}, + {"Input", inputWidgets, (int)std::size(inputWidgets)}, + {"Structural", structuralWidgets, (int)std::size(structuralWidgets)}, + {"Charts", chartWidgets, (int)std::size(chartWidgets)}, + }; + + if (ImGui::BeginMenu("Control")) + { + for (auto& cat : widgetCategories) + { + if (ImGui::BeginMenu(cat.label)) + { + for (int i = 0; i < cat.count; ++i) + { + if (ImGui::MenuItem(cat.entries[i].label)) + { + m_Context->CreateUIEntity(cat.entries[i].type); + } + } + ImGui::EndMenu(); + } + } + ImGui::EndMenu(); + } + } + } + void SceneHierarchyPanel::StartRename(Entity entity) + { + m_Renaming = true; + m_RenamingEntity = entity; + snprintf(m_RenameBuffer, sizeof(m_RenameBuffer), "%s", entity.GetComponent().Tag.c_str()); + } + +} // namespace Chained diff --git a/editor/panels/scene_hierarchy_panel.h b/editor/panels/scene_hierarchy_panel.h index 6b18f3f13..ecbc8fd1d 100644 --- a/editor/panels/scene_hierarchy_panel.h +++ b/editor/panels/scene_hierarchy_panel.h @@ -4,32 +4,35 @@ #include "panel.h" #include "unordered_set" -namespace CHEngine +namespace Chained { -class SceneHierarchyPanel : public Panel -{ -public: - SceneHierarchyPanel(); - SceneHierarchyPanel(const std::shared_ptr& context); - virtual void OnImGuiRender(bool readOnly = false) override; + class CommandHistory; + struct EditorState; + + class SceneHierarchyPanel : public Panel + { + public: + SceneHierarchyPanel(); + + virtual void OnImGuiRender(bool readOnly = false) override; -private: - void DrawEntityNodeRecursive(Entity entity, bool readOnly); - void DrawComponents(Entity entity); - void DrawContextMenu(); - const char* GetEntityIcon(Entity entity); + private: + void DrawEntityNodeRecursive(Entity entity, bool readOnly); + void DrawContextMenu(); + const char* GetEntityIcon(Entity entity); + void StartRename(Entity entity); -private: - std::unordered_set m_DrawnEntities; - std::vector m_EntitiesToDestroyPending; + private: + std::unordered_set m_DrawnEntities; + std::vector m_EntitiesToDestroyPending; - char m_SearchBuffer[128] = {0}; - bool m_Renaming = false; - char m_RenameBuffer[128] = {0}; - Entity m_RenamingEntity; -}; + char m_SearchBuffer[128] = {0}; + bool m_Renaming = false; + char m_RenameBuffer[128] = {0}; + Entity m_RenamingEntity; + }; -} // namespace CHEngine +} // namespace Chained #endif // CH_SCENE_HIERARCHY_PANEL_H diff --git a/editor/panels/viewport_panel.cpp b/editor/panels/viewport_panel.cpp index 242de8d5a..20a8347d9 100644 --- a/editor/panels/viewport_panel.cpp +++ b/editor/panels/viewport_panel.cpp @@ -1,648 +1,1153 @@ #include "viewport_panel.h" - -#include "IconsFontAwesome6.h" -#include "editor/editor_layer.h" +#include "editor/asset_types.h" +#include "editor/editor_colors.h" +#include "editor/layer.h" +#include "editor/scene_picking.h" #include "editor/viewport/ui_manipulator.h" -#include "editor_events.h" -#include "editor_gui.h" -#include "editor_layer.h" -#include "editor_layout.h" -#include "engine/core/application.h" -#include "engine/core/events.h" +#include "engine/app/application.h" +#include "engine/assets/asset_manager.h" +#include "engine/assets/types/texture_asset.h" +#include "engine/core/events/events.h" #include "engine/core/input.h" -#include "engine/graphics/pipeline/render_command.h" +#include "engine/core/key_codes.h" +#include "engine/core/service_locator.h" +#include "engine/graphics/api/framebuffer.h" +#include "engine/graphics/api/graphics_device.h" +#include "engine/graphics/pipeline/debug_renderer.h" #include "engine/graphics/pipeline/renderer.h" #include "engine/graphics/pipeline/scene_renderer.h" -#include "engine/graphics/pipeline/ui_renderer.h" -#include "engine/scene/components.h" +#include "engine/ui/widget_renderer.h" +#include "engine/project/project.h" #include "engine/scene/prefab_serializer.h" -#include "engine/scene/project.h" #include "engine/scene/scene.h" #include "engine/scene/scene_events.h" -#include "engine/scene/scene_picking.h" +#include "events.h" #include "imgui.h" #include "imgui_internal.h" -#include "scripting/scriptengine.h" +#include "engine/scripting/scriptengine.h" +#include "thirdparty/IconsFontAwesome6.h" #include "undo/entity_commands.h" +#include +#include +#include -namespace CHEngine -{ -void ViewportPanel::ClearSceneBackground(Scene* scene) -{ - auto mode = scene->GetSettings().Mode; - if (mode == BackgroundMode::Color) - { - RenderCommand::Clear(scene->GetSettings().BackgroundColor); - } - else if (mode == BackgroundMode::Texture) - { - auto& path = scene->GetSettings().BackgroundTexturePath; - if (!path.empty()) - { - // Fallback for now - RenderCommand::Clear(scene->GetSettings().BackgroundColor); - } - } - else if (mode == BackgroundMode::Environment3D) - { - RenderCommand::Clear({0, 0, 0, 255}); - } -} - -static const GizmoBtn s_GizmoBtns[] = { - {GizmoType::NONE, ICON_FA_ARROW_POINTER "##Select", "Select (Q)", Key::Q}, - {GizmoType::TRANSLATE, ICON_FA_UP_DOWN_LEFT_RIGHT "##Translate", "Translate (W)", Key::W}, - {GizmoType::ROTATE, ICON_FA_ARROWS_ROTATE "##Rotate", "Rotate (E)", Key::E}, - {GizmoType::SCALE, ICON_FA_UP_RIGHT_FROM_SQUARE "##Scale", "Scale (R)", Key::R}}; - -void ViewportPanel::DrawCameraSelector(Scene* scene) -{ - if (!scene) - { - return; - } - - ImGui::PushStyleColor(ImGuiCol_Button, {0.1f, 0.1f, 0.12f, 0.0f}); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f); - - auto view = scene->GetRegistry().view(); - Entity primaryCam = scene->GetPrimaryCameraEntity(); - std::string currentLabel = primaryCam ? primaryCam.GetComponent().Tag : "No Camera"; - - ImGui::SetNextItemWidth(150); - if (ImGui::BeginCombo("##CameraSelector", (ICON_FA_VIDEO " " + currentLabel).c_str(), ImGuiComboFlags_None)) - { - for (auto entityHandle : view) - { - Entity entity(entityHandle, &scene->GetRegistry()); - bool isSelected = (entity == primaryCam); - std::string tag = entity.GetComponent().Tag; - - if (ImGui::Selectable(tag.c_str(), isSelected)) - { - // Unset all and set this one as primary - for (auto otherHandle : view) - { - scene->GetRegistry().get(otherHandle).Primary = false; - } - entity.GetComponent().Primary = true; - } - - if (isSelected) - { - ImGui::SetItemDefaultFocus(); - } - } - ImGui::EndCombo(); - } - - ImGui::PopStyleVar(); - ImGui::PopStyleColor(); -} - -void ViewportPanel::DrawGizmoButtons() -{ - ImGui::PushStyleColor(ImGuiCol_Button, {0.1f, 0.1f, 0.1f, 0.0f}); // Transparent buttons in toolbar - - for (const auto& btn : s_GizmoBtns) - { - bool selected = (m_CurrentTool == btn.type); - if (selected) - { - ImGui::PushStyleColor(ImGuiCol_Button, {0.9f, 0.45f, 0.0f, 1.0f}); - } - - if (ImGui::Button(btn.icon, {28, 28})) - { - m_CurrentTool = btn.type; - } - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("%s", btn.tooltip); - } - - if (selected) - { - ImGui::PopStyleColor(); - } - ImGui::SameLine(0, 5); - } - - ImGui::PopStyleColor(); -} - -ViewportPanel::ViewportPanel() -{ - m_Name = "Viewport"; - - FramebufferSpecification spec; - spec.Width = 1280; - spec.Height = 720; - spec.ColorFormat = FramebufferColorFormat::RGBA8; - - if (Application::Get().GetWindow().GetNativeWindow()) - { - spec.Width = Application::Get().GetWindow().GetWidth() > 0 ? Application::Get().GetWindow().GetWidth() : 1280; - spec.Height = Application::Get().GetWindow().GetHeight() > 0 ? Application::Get().GetWindow().GetHeight() : 720; - } - - m_ViewportFramebuffer = Framebuffer::Create(spec); - - FramebufferSpecification hdrSpec = spec; - hdrSpec.ColorFormat = FramebufferColorFormat::RGBA16F; - m_HDRFramebuffer = Framebuffer::Create(hdrSpec); - - m_SceneRenderer = std::make_unique(); - m_CameraController = std::make_unique(); -} - -ViewportPanel::~ViewportPanel() +namespace Chained { -} -void ViewportPanel::OnImGuiRender(bool readOnly) -{ - if (!m_IsOpen) return; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2{0, 0}); - ImGui::Begin(m_Name.c_str(), &m_IsOpen); - - ImVec2 viewportSize = ImGui::GetContentRegionAvail(); - ImVec2 viewportScreenPos = ImGui::GetCursorScreenPos(); - - auto activeScene = EditorLayer::Get().GetActiveScene(); - auto activeScene_raw = activeScene.get(); - - // 1. Initial State & Resizing - HandleResize(viewportSize, activeScene_raw); - - if (!activeScene || viewportSize.x <= 0 || viewportSize.y <= 0) - { - ImGui::End(); - ImGui::PopStyleVar(); - return; - } - - m_Focused = ImGui::IsWindowFocused(); - m_Hovered = ImGui::IsWindowHovered(); - - // 2. Rendering - RenderViewportScene(activeScene_raw, viewportSize); - - // 3. UI Image & Interaction - uint32_t finalTextureID = m_ViewportFramebuffer->GetColorAttachmentRendererID(); - ImGui::Image((ImTextureID)(uintptr_t)finalTextureID, viewportSize, {0, 1}, {1, 0}); - - // 4. Drag & Drop - HandleDragDrop(activeScene_raw); - - // 5. Overlays (Gizmos, UI, Highlights) - RenderOverlays(activeScene_raw, viewportSize, viewportScreenPos); - - // 6. Picking - HandlePicking(activeScene_raw, viewportSize, viewportScreenPos); - - // 7. Toolbars - RenderToolbar(activeScene_raw, viewportSize, viewportScreenPos); - RenderLaunchHUD(viewportSize, viewportScreenPos); - - ImGui::End(); - ImGui::PopStyleVar(); - - // Shortcuts & Keyboard Input - if (ImGui::IsWindowFocused() || ImGui::IsWindowHovered()) - { - for (const auto& btn : s_GizmoBtns) - { - if (CHEngine::Input::IsKeyPressed(btn.key)) - { - m_CurrentTool = btn.type; - } - } - - if (Input::IsKeyDown(Key::LeftControl) && Input::IsKeyPressed(Key::D)) - { - Entity selected = EditorLayer::Get().GetSelectedEntity(); - if (selected){ - EditorLayer::GetCommandHistory().PushCommand(std::make_unique(selected)); - } - } - } -} - -void ViewportPanel::OnUpdate(Timestep ts) -{ - // Only update editor camera in Edit mode - if (EditorLayer::Get().GetSceneState() == SceneState::Edit) - { - auto activeScene = EditorLayer::Get().GetActiveScene(); - // Use m_Focused/m_Hovered that were set in the PREVIOUS frame's ImGuiRender. - // Also allow update if right mouse is held (user clicked into viewport from outside). - bool mouseInViewport = m_Hovered || m_Focused || Input::IsMouseButtonDown(Mouse::ButtonRight); - if (activeScene && mouseInViewport) - { - Entity primaryCamera = activeScene->GetPrimaryCameraEntity(); - m_CameraController->OnUpdate(primaryCamera, ts); - } - } -} - -void ViewportPanel::OnEvent(Event& e) -{ - EventDispatcher dispatcher(e); - dispatcher.Dispatch([this](ViewportFocusEntityEvent& ev) { - Entity entity = ev.GetEntity(); - if (entity && entity.HasComponent()) - { - auto& transform = entity.GetComponent(); - m_CameraController->GetCamera().SetFocalPoint(*reinterpret_cast(&transform.Translation)); - return true; - } - return false; - }); -} - -void ViewportPanel::HandleResize(const ImVec2& viewportSize, Scene* activeScene) -{ - if (viewportSize.x != (float)m_ViewportFramebuffer->GetSpecification().Width || - viewportSize.y != (float)m_ViewportFramebuffer->GetSpecification().Height) - { - if (viewportSize.x > 0 && viewportSize.y > 0) - { - m_ViewportFramebuffer->Resize((uint32_t)viewportSize.x, (uint32_t)viewportSize.y); - m_HDRFramebuffer->Resize((uint32_t)viewportSize.x, (uint32_t)viewportSize.y); - - EditorLayer::Get().SetViewportSize(viewportSize); - m_CameraController->GetCamera().SetViewportSize((uint32_t)viewportSize.x, (uint32_t)viewportSize.y); - - if (activeScene) - { - EditorLayer::Get().GetSceneManager().OnViewportResize((uint32_t)viewportSize.x, (uint32_t)viewportSize.y); - } - } - } -} - -void ViewportPanel::RenderViewportScene(Scene* activeScene, const ImVec2& viewportSize) -{ - m_HDRFramebuffer->Bind(); - ClearSceneBackground(activeScene); - - auto activeCameraOpt = activeScene->GetActiveCamera(); - bool cameraFound = activeCameraOpt.has_value(); - CHEngine::Camera3D camera; - float nearClip = 0.01f; - float farClip = 10000.0f; - - // Default to Editor Camera - auto& edCam = m_CameraController->GetCamera(); - glm::vec3 pos = edCam.CalculatePosition(); - camera.Position = {pos.x, pos.y, pos.z}; - - glm::vec3 fp = edCam.GetFocalPoint(); - camera.Target = {fp.x, fp.y, fp.z}; - - glm::vec3 up = edCam.GetUpDirection(); - camera.Up = {up.x, up.y, up.z}; - - camera.Fovy = glm::degrees(edCam.GetPerspectiveVerticalFOV()); // Fovy in degrees - camera.Projection = 0; // Perspective - - nearClip = edCam.GetPerspectiveNearClip(); - farClip = edCam.GetPerspectiveFarClip(); - - // If an entity camera is active during Play mode, override the viewport perspective - if (cameraFound && EditorLayer::Get().GetSceneState() == SceneState::Play) - { - camera = activeCameraOpt.value(); - Entity primaryCam = activeScene->GetPrimaryCameraEntity(); - if (primaryCam && primaryCam.HasComponent()) - { - auto& cameraComp = primaryCam.GetComponent().Camera; - nearClip = cameraComp.GetPerspectiveNearClip(); - farClip = cameraComp.GetPerspectiveFarClip(); - } - } - - SceneRenderOptions options; - auto& currentDebugFlags = activeScene->GetSettings().DebugFlags; - options.DrawGrid = currentDebugFlags.DrawGrid; - options.ShowDebugColliders = currentDebugFlags.DrawColliders; - options.ShowDebugCollisionModelBox = currentDebugFlags.DrawCollisionModelBox; - options.ShowDebugSpawnZones = currentDebugFlags.DrawSpawnZones; - options.ShowEditorIcons = true; - - m_SceneRenderer->RenderScene(activeScene, camera, nearClip, farClip, options); - m_HDRFramebuffer->Unbind(); - - // Application of Post-processing - m_ViewportFramebuffer->Bind(); - RenderCommand::Clear({0, 0, 0, 255}); // Clear viewport buffer - Renderer::Get().ApplyPostProcessing(m_HDRFramebuffer->GetColorAttachmentRendererID(), - m_HDRFramebuffer->GetDepthAttachmentRendererID(), camera); - m_ViewportFramebuffer->Unbind(); -} - -void ViewportPanel::HandleDragDrop(Scene* activeScene) -{ - if (ImGui::BeginDragDropTarget()) - { - if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("CONTENT_BROWSER_ITEM")) - { - const char* path = (const char*)payload->Data; - std::filesystem::path filepath = std::filesystem::path(path); // Ensure cross-platform path handling - std::string ext = filepath.extension().string(); - std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); - - if (ext == ".chscene") - { - EditorLayer::Get().GetSceneManager().OpenScene(filepath); - } - else if (ext == ".chprefab") - { - PrefabSerializer::Deserialize(activeScene, filepath.string()); - } - else if (ext == ".gltf" || ext == ".glb" || ext == ".obj") - { - std::string filename = filepath.stem().string(); - Entity entity = activeScene->CreateEntity(filename); - auto& modelcomp = entity.AddComponent(); - // Use relative path if possible to satisfy portability - modelcomp.ModelPath = Project::GetRelativePath(filepath); - - // Select the new entity - EntitySelectedEvent e((entt::entity)entity, activeScene); - EditorLayer::Get().OnEvent(e); - } - } - ImGui::EndDragDropTarget(); - } -} - -void ViewportPanel::RenderOverlays(Scene* activeScene, const ImVec2& viewportSize, const ImVec2& viewportScreenPos) -{ - auto selectedEntity = EditorLayer::Get().GetSelectedEntity(); - bool isUISelected = selectedEntity && selectedEntity.HasComponent(); - auto activeCameraOpt = activeScene->GetActiveCamera(); - CHEngine::Camera3D camera; - if (activeCameraOpt.has_value()) - { - camera = activeCameraOpt.value(); - } - else - { - // Fallback to editor camera for gizmos even if no scene camera - auto& edCam = m_CameraController->GetCamera(); - glm::vec3 pos = edCam.CalculatePosition(); - camera.Position = {pos.x, pos.y, pos.z}; - glm::vec3 fp = edCam.GetFocalPoint(); - camera.Target = {fp.x, fp.y, fp.z}; - glm::vec3 up = edCam.GetUpDirection(); - camera.Up = {up.x, up.y, up.z}; - camera.Fovy = glm::degrees(edCam.GetPerspectiveVerticalFOV()); - camera.Projection = 0; - } - - ImGui::SetCursorScreenPos(viewportScreenPos); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); - - if (ImGui::BeginChild("##SceneUI", viewportSize, false, - ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoScrollWithMouse)) - { - // 1. Gizmo handling (inside child window for input priority) - m_Gizmo.RenderAndHandle(!isUISelected ? m_CurrentTool : GizmoType::NONE, viewportScreenPos, - viewportSize, camera); - - // 2. Game UI Overlay - ImVec2 canvasOrigin = ImGui::GetCursorScreenPos(); - UIRenderer::Get().DrawCanvas(activeScene, canvasOrigin, viewportSize, - EditorLayer::Get().GetSceneState() == SceneState::Edit); - - // 3. Selection Highlight - if (isUISelected && selectedEntity && EditorLayer::Get().GetSceneState() == SceneState::Edit) - { - auto rect = UIRenderer::Get().GetEntityRect(selectedEntity, viewportSize, viewportScreenPos); - - ImVec2 p1 = ImVec2(rect.x, rect.y); - ImVec2 p2 = ImVec2(p1.x + rect.width, p1.y + rect.height); - - ImGui::GetWindowDrawList()->AddRect(p1, p2, IM_COL32(255, 255, 0, 255), 0, 0, 2.0f); - - // Use the new UI Manipulator - m_UIManipulator.OnImGuiRender(selectedEntity, viewportScreenPos, viewportSize); - - // Debug info - if (ImGui::IsMouseHoveringRect(p1, p2)) - { - ImGui::GetWindowDrawList()->AddRect(p1, p2, IM_COL32(0, 255, 0, 255), 0, 0, 1.0f); - } - } - } - ImGui::EndChild(); - ImGui::PopStyleVar(); -} - -void ViewportPanel::HandlePicking(Scene* activeScene, const ImVec2& viewportSize, const ImVec2& viewportScreenPos) -{ - // Object picking logic - ImGuiContext& g = *GImGui; - bool isUIChildHovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByPopup); - bool isClicked = ImGui::IsMouseClicked(ImGuiMouseButton_Left); - bool isDragging = m_UIManipulator.IsActive(); - bool isGizmoDragging = m_Gizmo.IsDragging(); - bool isGizmoHovered = m_Gizmo.IsHovered(); - SceneState sceneState = EditorLayer::Get().GetSceneState(); - - if (sceneState == SceneState::Edit && isUIChildHovered && isClicked && !isGizmoDragging && !isGizmoHovered && !isDragging) - { - ImVec2 mousePos = ImGui::GetMousePos(); - ImVec2 localMouseImGui = {mousePos.x - viewportScreenPos.x, mousePos.y - viewportScreenPos.y}; - - auto activeCameraOpt = activeScene->GetActiveCamera(); - CHEngine::Camera3D camera; - if (activeCameraOpt.has_value()) - { - camera = activeCameraOpt.value(); - } - else - { - auto& edCam = m_CameraController->GetCamera(); - camera.Position = {edCam.CalculatePosition().x, edCam.CalculatePosition().y, edCam.CalculatePosition().z}; - camera.Target = {edCam.GetFocalPoint().x, edCam.GetFocalPoint().y, edCam.GetFocalPoint().z}; - camera.Up = {edCam.GetUpDirection().x, edCam.GetUpDirection().y, edCam.GetUpDirection().z}; - camera.Fovy = glm::degrees(edCam.GetPerspectiveVerticalFOV()); - camera.Projection = 0; - } - - Ray ray = EditorGUI::GetMouseRay(camera, {localMouseImGui.x, localMouseImGui.y}, {viewportSize.x, viewportSize.y}); - - Entity bestHit = {}; - - // UI Picking - auto uiView = activeScene->GetRegistry().view(); - for (auto entityID : uiView) - { - Entity entity(entityID, &activeScene->GetRegistry()); - auto& cc = uiView.get(entityID); - if (!cc.IsActive) continue; - - auto rect = UIRenderer::Get().GetEntityRect(entity, viewportSize, viewportScreenPos); - if (mousePos.x >= rect.x && mousePos.x <= rect.x + rect.width && mousePos.y >= rect.y && mousePos.y <= rect.y + rect.height) - { - bestHit = entity; - } - } - - // 3D Picking - if (!bestHit && activeCameraOpt.has_value()) - { - SceneRaycastResult result = ScenePicker::Raycast(activeScene, ray); - if (result.Hit) - { - bestHit = result.HitEntity; - } - } - - if (bestHit) - { - EntitySelectedEvent e((entt::entity)bestHit, activeScene); - EditorLayer::Get().OnEvent(e); - } - else - { - // Only deselect if the mouse is genuinely inside the viewport area - ImVec2 mousePos = ImGui::GetMousePos(); - bool mouseInViewport = (mousePos.x >= viewportScreenPos.x && - mousePos.x <= viewportScreenPos.x + viewportSize.x && - mousePos.y >= viewportScreenPos.y && - mousePos.y <= viewportScreenPos.y + viewportSize.y); - if (mouseInViewport) - { - EntitySelectedEvent e(entt::null, activeScene); - EditorLayer::Get().OnEvent(e); - } - } - } -} - -void ViewportPanel::RenderToolbar(Scene* activeScene, const ImVec2& viewportSize, const ImVec2& viewportScreenPos) -{ - ImVec2 toolbarPos = {viewportScreenPos.x + 10.0f, viewportScreenPos.y + 10.0f}; - ImGui::SetNextWindowPos(toolbarPos); - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.1f, 0.1f, 0.12f, 0.8f)); - ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(5, 5)); - - if (ImGui::BeginChild("##FloatingToolbar", ImVec2(850, 40), true, - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) - { - ImGui::SetCursorPosY(6); // Center align vertically-ish - ImGui::Indent(5); - - DrawGizmoButtons(); - DrawCameraSelector(activeScene); - - ImGui::SameLine(0, 10); - ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); - ImGui::SameLine(0, 10); - - // Snapping toggle - bool snapping = m_Gizmo.IsSnappingEnabled(); - if (snapping) ImGui::PushStyleColor(ImGuiCol_Text, {0.3f, 0.8f, 1.0f, 1.0f}); - if (ImGui::Button(ICON_FA_MAGNET "##SnapToggle", {28, 28})) m_Gizmo.SetSnapping(!snapping); - if (snapping) ImGui::PopStyleColor(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Enable Grid Snapping"); - - ImGui::SameLine(0, 5); - float gridSize = m_Gizmo.GetGridSize(); - ImGui::SetNextItemWidth(45); - if (ImGui::DragFloat("##SnapValue", &gridSize, 0.1f, 0.1f, 10.0f, "%.1f")) m_Gizmo.SetGridSize(gridSize); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Grid Snap Size"); - - ImGui::SameLine(0, 10); - - // Local/World toggle - bool isLocal = m_Gizmo.IsLocalSpace(); - if (ImGui::Button(isLocal ? (ICON_FA_CUBE " Local") : (ICON_FA_EARTH_AMERICAS " World"), {70, 28})) m_Gizmo.SetLocalSpace(!isLocal); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Toggle Local/World Space"); - - ImGui::SameLine(0, 15); - ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); - ImGui::SameLine(0, 15); - - // Playback Tools - SceneState sceneState = EditorLayer::Get().GetSceneState(); - bool isPlaying = (sceneState == SceneState::Play); - ImGui::SameLine(0, 10); - - if (isPlaying) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.3f, 1.0f, 0.3f, 1.0f)); - if (ImGui::Button(isPlaying ? ICON_FA_STOP : ICON_FA_PLAY, ImVec2(28, 28))) - { - if (isPlaying) - { - SceneStopEvent e; - EditorLayer::Get().OnEvent(e); - } - else - { - ScenePlayEvent e; - EditorLayer::Get().OnEvent(e); - } - } - if (isPlaying) ImGui::PopStyleColor(); - - ImGui::SameLine(0, 5); - if (ImGui::Button(ICON_FA_FILE_CODE "##ReloadToolbar", ImVec2(28, 28))) - { - ScriptEngine::Get().RequestAssemblyReload("ViewportPanel"); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Reload Scripts (Ctrl+R)"); - - ImGui::SameLine(0, 15); - ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); - ImGui::SameLine(0, 15); - - // Run scene in new window (for play mode testing) - if (ImGui::Button(ICON_FA_WINDOW_MAXIMIZE "##RunSceneInNewWindow", ImVec2(28, 28))) - { - AppLaunchRuntimeEvent e; - Application::Get().OnEvent(e); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Run Scene in New Window (Shift+F5)"); - - // Camera Info - Entity primaryCam = activeScene->GetPrimaryCameraEntity(); - if (primaryCam) ImGui::TextDisabled(ICON_FA_CAMERA " %s", primaryCam.GetComponent().Tag.c_str()); - else ImGui::TextColored({1, 0, 0, 1}, ICON_FA_CIRCLE_EXCLAMATION " No Primary Camera"); - } - ImGui::EndChild(); - ImGui::PopStyleVar(2); - ImGui::PopStyleColor(); -} - -void ViewportPanel::RenderLaunchHUD(const ImVec2& viewportSize, const ImVec2& viewportScreenPos) -{ - ImGui::SetCursorScreenPos({viewportScreenPos.x + viewportSize.x - 110.0f, viewportScreenPos.y + 10.0f}); - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.1f, 0.1f, 0.12f, 0.8f)); - ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(5, 5)); - - if (ImGui::BeginChild("##LaunchHUD", ImVec2(100, 40), true, - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) - { - ImGui::SetCursorPosY(6); - ImGui::Indent(5); - if (ImGui::Button(ICON_FA_ROCKET " Launch", ImVec2(90, 28))) - { - AppLaunchRuntimeEvent e; - Application::Get().OnEvent(e); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Build & Run Standalone project (F5)"); - } - ImGui::EndChild(); - ImGui::PopStyleVar(2); - ImGui::PopStyleColor(); -} - -} // namespace CHEngine + constexpr float kMinIconClickRadius = 14.0f; + + Camera3D ViewportPanel::GetActiveOrEditorCamera(Scene* scene) const + { + if (!scene) + { + return {}; + } + auto activeCameraOpt = SceneRenderer::GetActiveCamera(scene->GetRegistry()); + if (activeCameraOpt.has_value() && EditorLayer::Get().GetSceneManager().GetSceneState() == SceneState::Play) + { + return activeCameraOpt.value(); + } + return m_CameraController->ToCamera3D(); + } + + void ViewportPanel::ClearSceneBackground(Scene* scene) + { + auto mode = scene->GetSettings().Mode; + if (mode == BackgroundMode::Color) + { + GraphicsDevice::Get().Clear(scene->GetSettings().BackgroundColor); + } + else if (mode == BackgroundMode::Texture) + { + auto& path = scene->GetSettings().BackgroundTexturePath; + if (!path.empty()) + { + // Fallback for now + GraphicsDevice::Get().Clear(scene->GetSettings().BackgroundColor); + } + } + else if (mode == BackgroundMode::Environment3D) + { + GraphicsDevice::Get().Clear({0, 0, 0, 255}); + } + } + + static uint32_t GetIconHandle(const std::shared_ptr& icon) + { + if (icon && icon->IsReady()) + { + auto tex = icon->GetTexture(); + if (tex) + { + return tex->GetNativeHandle(); + } + } + return 0; + } + + static float ComputeIconSize(const glm::vec3& worldPos, const glm::vec3& cameraPos, float minSize, float maxSize, + float scale) + { + const float distance = glm::distance(worldPos, cameraPos); + return std::clamp(distance * scale, minSize, maxSize); + } + + static const GizmoBtn s_GizmoBtns[] = { + {GizmoType::NONE, ICON_FA_ARROW_POINTER "##Select", "Select (Q)", Chained::KeyCode::Q}, + {GizmoType::TRANSLATE, ICON_FA_UP_DOWN_LEFT_RIGHT "##Translate", "Translate (W)", Chained::KeyCode::W}, + {GizmoType::ROTATE, ICON_FA_ARROWS_ROTATE "##Rotate", "Rotate (E)", Chained::KeyCode::E}, + {GizmoType::SCALE, ICON_FA_UP_RIGHT_FROM_SQUARE "##Scale", "Scale (R)", Chained::KeyCode::R}}; + + void ViewportPanel::DrawCameraSelector(Scene* scene) + { + if (!scene) + { + return; + } + + ImGui::PushStyleColor(ImGuiCol_Button, EditorColors::FloatingToolbarBg); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f); + + auto view = scene->GetRegistry().view(); + Entity primaryCam = SceneRenderer::GetPrimaryCameraEntity(scene->GetRegistry(), scene->GetRegistryPtr()); + std::string currentLabel = primaryCam ? primaryCam.GetComponent().Tag : "Editor Camera"; + + ImGui::SetNextItemWidth(150); + if (ImGui::BeginCombo("##CameraSelector", (ICON_FA_VIDEO " " + currentLabel).c_str(), ImGuiComboFlags_None)) + { + for (auto entityHandle : view) + { + Entity entity(entityHandle, &scene->GetRegistry()); + bool isSelected = (entity == primaryCam); + std::string tag = entity.GetComponent().Tag; + + if (ImGui::Selectable(tag.c_str(), isSelected)) + { + // Unset all and set this one as primary + for (auto otherHandle : view) + { + scene->GetRegistry().get(otherHandle).Primary = false; + } + entity.GetComponent().Primary = true; + } + + if (isSelected) + { + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndCombo(); + } + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } + + void ViewportPanel::DrawGizmoButtons() + { + ImGui::PushStyleColor(ImGuiCol_Button, EditorColors::TransparentButton); // Transparent buttons in toolbar + + for (const auto& btn : s_GizmoBtns) + { + bool selected = (m_CurrentTool == btn.type); + if (selected) + { + ImGui::PushStyleColor(ImGuiCol_Button, EditorColors::ActiveToolOrange); + } + + if (ImGui::Button(btn.icon, {28, 28})) + { + m_CurrentTool = btn.type; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", btn.tooltip); + } + + if (selected) + { + ImGui::PopStyleColor(); + } + ImGui::SameLine(0, 5); + } + + ImGui::PopStyleColor(); + } + + // Project's RenderSettings::AntiAliasingSamples is 0/2/4/8 ("0 = off"); Framebuffer's + // Samples field uses "1 = off" (matches GL's own multisample vs. non-multisample distinction). + static uint32_t GetConfiguredMSAASamples() + { + auto project = Project::GetActive(); + int samples = project ? project->GetConfig().Render.AntiAliasingSamples : 4; + return samples > 1 ? (uint32_t)samples : 1u; + } + + ViewportPanel::ViewportPanel(ImVec2& editorViewportSize) + : m_EditorViewportSize(editorViewportSize) + { + m_Name = "Viewport"; + + FramebufferSpecification spec; + spec.Width = 1280; + spec.Height = 720; + spec.ColorFormat = FramebufferColorFormat::RGBA8; + + if (Application::Get().GetWindow().GetNativeWindow()) + { + spec.Width = + Application::Get().GetWindow().GetWidth() > 0 ? Application::Get().GetWindow().GetWidth() : 1280; + spec.Height = + Application::Get().GetWindow().GetHeight() > 0 ? Application::Get().GetWindow().GetHeight() : 720; + } + + m_ViewportFramebuffer = Framebuffer::Create(spec); + + FramebufferSpecification hdrSpec = spec; + hdrSpec.ColorFormat = FramebufferColorFormat::RGBA16F; + hdrSpec.Samples = GetConfiguredMSAASamples(); + m_MSAAFramebufferSamples = hdrSpec.Samples; + m_HDRFramebuffer = Framebuffer::Create(hdrSpec); + + m_SceneRenderer = std::make_unique(); + m_CameraController = std::make_unique(); + } + + ViewportPanel::~ViewportPanel() + { + if (m_CursorLocked && m_LockedWindow) + { + glfwSetInputMode(m_LockedWindow, GLFW_CURSOR, GLFW_CURSOR_NORMAL); + m_CursorLocked = false; + m_LockedWindow = nullptr; + } + } + + void ViewportPanel::OnImGuiRender(bool readOnly) + { + if (!m_IsOpen) + { + return; + } + + // Track the current platform window (native GLFW window) hosting this panel + ImGuiViewport* vp = ImGui::GetWindowViewport(); + if (vp && vp->PlatformHandle) + { + m_PlatformWindow = static_cast(vp->PlatformHandle); + } + else + { + m_PlatformWindow = static_cast(Application::Get().GetWindow().GetNativeWindow()); + } + + auto activeScene = EditorLayer::Get().GetSceneManager().GetActiveScene(); + + std::string sceneName = "None"; + if (activeScene && !activeScene->GetSettings().Name.empty()) + { + sceneName = activeScene->GetSettings().Name; + } + std::string title = m_Name + " [" + sceneName + "]###" + m_Name; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2{0, 0}); + ImGui::Begin(title.c_str(), &m_IsOpen); + + ImVec2 viewportSize = ImGui::GetContentRegionAvail(); + ImVec2 viewportScreenPos = ImGui::GetCursorScreenPos(); + + // 1. Initial State & Resizing + HandleResize(viewportSize, activeScene.get()); + + if (!activeScene || viewportSize.x <= 0 || viewportSize.y <= 0) + { + ImGui::End(); + ImGui::PopStyleVar(); + return; + } + + m_Focused = ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows); + m_Hovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows); + + // 2. Rendering + if (!activeScene->IsStartingUp()) + { + RenderViewportScene(activeScene.get()); + } + + // 3. UI Image & Interaction + if (!m_ViewportFramebuffer || !m_ViewportFramebuffer->IsValid()) + { + ImGui::End(); + ImGui::PopStyleVar(); + return; + } + uint32_t finalTextureID = m_ViewportFramebuffer->GetColorAttachmentRendererID(); + + // Capture the EXACT screen position where the image starts to prevent gizmo offset + viewportScreenPos = ImGui::GetCursorScreenPos(); + + ImGui::Image((ImTextureID)(uintptr_t)finalTextureID, viewportSize, {0, 1}, {1, 0}); + + bool isTransitioning = EditorLayer::Get().GetSceneManager().IsTransitioning(); + if (activeScene->IsStartingUp() || isTransitioning) + { + ImDrawList* drawList = ImGui::GetWindowDrawList(); + ImVec2 p0 = ImGui::GetItemRectMin(); + ImVec2 p1 = ImGui::GetItemRectMax(); + + drawList->AddRectFilled(p0, p1, IM_COL32(15, 15, 20, 200)); + + std::string status = EditorLayer::Get().GetSceneManager().GetLoadingStatus(); + if (status.empty()) + { + status = "Loading Scene..."; + } + const char* text = status.c_str(); + ImVec2 textSize = ImGui::CalcTextSize(text); + ImVec2 textPos = ImVec2(p0.x + (p1.x - p0.x - textSize.x) * 0.5f, p0.y + (p1.y - p0.y - textSize.y) * 0.5f); + drawList->AddText(textPos, IM_COL32(255, 255, 255, 255), text); + } + + // 4. Drag & Drop + HandleDragDrop(activeScene.get()); + + // 5. Overlays (Gizmos, UI, Highlights) + RenderOverlays(activeScene.get(), viewportSize, viewportScreenPos); + + // 6. Picking + HandlePicking(activeScene.get(), viewportSize, viewportScreenPos); + + // 7. Toolbars + RenderToolbar(activeScene.get(), viewportScreenPos); + + // Shortcuts & Keyboard Input — must be before ImGui::End() so IsWindowFocused works + if (m_Focused || m_Hovered) + { + HandleKeyboardShortcuts(); + } + + ImGui::End(); + ImGui::PopStyleVar(); + } + + void ViewportPanel::OnUpdate(Timestep ts) + { + bool hasImGui = ImGui::GetCurrentContext() != nullptr; + bool rightDown = hasImGui ? ImGui::IsMouseDown(ImGuiMouseButton_Right) + : Chained::Core::Input::IsMouseButtonDown(Chained::MouseCode::ButtonRight); + + // Unlock cursor if right mouse is released while locked + if (m_CursorLocked && !rightDown) + { + GLFWwindow* win = m_LockedWindow ? m_LockedWindow : m_PlatformWindow; + if (!win) + { + win = static_cast(Application::Get().GetWindow().GetNativeWindow()); + } + if (win) + { + glfwSetInputMode(win, GLFW_CURSOR, GLFW_CURSOR_NORMAL); + } + m_CursorLocked = false; + m_LockedWindow = nullptr; + } + + // Auto-switch camera 2D mode based on scene type + auto activeScene = EditorLayer::Get().GetSceneManager().GetActiveScene(); + if (activeScene) + { + SceneType sceneType = activeScene->GetSettings().Type; + if (sceneType != m_LastSceneType) + { + bool want2D = (sceneType == SceneType::UI); + if (m_CameraController->Is2DMode() != want2D) + { + m_CameraController->Set2DMode(want2D); + m_Gizmo.Set2DMode(want2D); + } + m_LastSceneType = sceneType; + } + } + + // Cursor lock for camera rotation + if ((m_Hovered || m_CursorLocked) && rightDown && !m_CursorLocked) + { + GLFWwindow* win = m_PlatformWindow + ? m_PlatformWindow + : static_cast(Application::Get().GetWindow().GetNativeWindow()); + if (win) + { + glfwSetInputMode(win, GLFW_CURSOR, GLFW_CURSOR_DISABLED); + m_CursorLocked = true; + m_LockedWindow = win; + } + } + + // Update editor camera in Edit and Simulate modes (not during Play) + SceneState state = EditorLayer::Get().GetSceneManager().GetSceneState(); + if (state == SceneState::Edit || state == SceneState::Simulate) + { + auto activeScene = EditorLayer::Get().GetSceneManager().GetActiveScene(); + // Use m_Hovered that was set in the PREVIOUS frame's ImGuiRender. + // Also allow update if right mouse is held (user clicked into viewport from outside). + bool mouseInViewport = m_Hovered || rightDown; + if (activeScene && mouseInViewport) + { + const auto& editorCfg = EditorLayer::Get().GetConfig(); + m_CameraController->SetMoveSpeed(editorCfg.CameraMoveSpeed); + m_CameraController->SetBoostMultiplier(editorCfg.CameraBoostMultiplier); + m_CameraController->SetDisableZoom(editorCfg.DisableCameraZoom); + m_CameraController->SetRotationSpeed(editorCfg.CameraRotationSpeed); + m_CameraController->SetZoomSpeedMultiplier(editorCfg.CameraZoomSpeedMultiplier); + m_CameraController->SetFovDegrees(editorCfg.CameraFovDegrees); + m_CameraController->SetNearClip(editorCfg.CameraNearClip); + m_CameraController->SetFarClip(editorCfg.CameraFarClip); + + Entity primaryCamera = + SceneRenderer::GetPrimaryCameraEntity(activeScene->GetRegistry(), activeScene->GetRegistryPtr()); + m_CameraController->OnUpdate(primaryCamera, ts, m_ViewportSize); + } + } + } + + void ViewportPanel::OnEvent(Event& e) + { + EventDispatcher dispatcher(e); + dispatcher.Dispatch([this](ViewportFocusEntityEvent& ev) { + Entity entity = ev.GetEntity(); + if (entity && entity.HasComponent()) + { + auto& transform = entity.GetComponent(); + m_CameraController->SetFocalPoint(*reinterpret_cast(&transform.Translation)); + return true; + } + return false; + }); + } + + void ViewportPanel::HandleKeyboardShortcuts() + { + bool hasImGui = ImGui::GetCurrentContext() != nullptr; + bool rightDown = hasImGui ? ImGui::IsMouseDown(ImGuiMouseButton_Right) + : Chained::Core::Input::IsMouseButtonDown(Chained::MouseCode::ButtonRight); + + if (!rightDown) + { + if (hasImGui) + { + if (ImGui::IsKeyPressed(ImGuiKey_Q, false)) + { + m_CurrentTool = GizmoType::NONE; + } + else if (ImGui::IsKeyPressed(ImGuiKey_W, false)) + { + m_CurrentTool = GizmoType::TRANSLATE; + } + else if (ImGui::IsKeyPressed(ImGuiKey_E, false)) + { + m_CurrentTool = GizmoType::ROTATE; + } + else if (ImGui::IsKeyPressed(ImGuiKey_R, false)) + { + m_CurrentTool = GizmoType::SCALE; + } + } + else + { + for (const auto& btn : s_GizmoBtns) + { + if (Chained::Core::Input::IsKeyPressed(btn.key)) + { + m_CurrentTool = btn.type; + } + } + } + } + + bool isCtrl = hasImGui ? (ImGui::IsKeyDown(ImGuiKey_LeftCtrl) || ImGui::IsKeyDown(ImGuiKey_RightCtrl)) + : (Chained::Core::Input::IsKeyDown(Chained::KeyCode::LeftControl) || + Chained::Core::Input::IsKeyDown(Chained::KeyCode::RightControl)); + bool isDPressed = + hasImGui ? ImGui::IsKeyPressed(ImGuiKey_D, false) : Chained::Core::Input::IsKeyPressed(Chained::KeyCode::D); + + if (isCtrl && isDPressed) + { + Entity selected = EditorLayer::Get().GetEditorState().SelectedEntity; + if (selected) + { + EditorLayer::Get().GetCommandHistory().PushCommand(std::make_unique(selected)); + } + } + } + + Ray ViewportPanel::GetMouseRay(const glm::vec2& mousePosition) + { + auto activeScene = EditorLayer::Get().GetSceneManager().GetActiveScene(); + if (!activeScene) + { + return {}; + } + Camera3D camera = GetActiveOrEditorCamera(activeScene.get()); + return ScenePicker::CreateRayFromViewport(camera, mousePosition, m_ViewportSize); + } + + void ViewportPanel::HandleResize(const ImVec2& viewportSize, Scene* activeScene) + { + if (viewportSize.x != m_ViewportSize.x || viewportSize.y != m_ViewportSize.y) + { + m_ViewportSize = {viewportSize.x, viewportSize.y}; + if (m_ViewportSize.x > 0 && m_ViewportSize.y > 0) + { + if (m_ViewportFramebuffer) + { + m_ViewportFramebuffer->Resize((uint32_t)m_ViewportSize.x, (uint32_t)m_ViewportSize.y); + } + if (m_HDRFramebuffer) + { + m_HDRFramebuffer->Resize((uint32_t)m_ViewportSize.x, (uint32_t)m_ViewportSize.y); + } + + // Keep Renderer in sync so frustum & projection use correct aspect ratio + if (auto* renderer = ServiceLocator::TryGet()) + { + renderer->SetViewportSize((uint32_t)m_ViewportSize.x, (uint32_t)m_ViewportSize.y); + } + + m_EditorViewportSize = {m_ViewportSize.x, m_ViewportSize.y}; + m_CameraController->SetViewportSize((uint32_t)m_ViewportSize.x, (uint32_t)m_ViewportSize.y); + + if (activeScene) + { + activeScene->OnViewportResize((uint32_t)m_ViewportSize.x, (uint32_t)m_ViewportSize.y); + } + } + } + + // Recreate FBOs if they became invalid (e.g. after context loss or bad resize), + // or if the project's AntiAliasingSamples setting changed since we last (re)created them - + // the sample count is baked into the framebuffer at creation and can't change in place. + uint32_t configuredSamples = GetConfiguredMSAASamples(); + if (m_HDRFramebuffer && configuredSamples != m_MSAAFramebufferSamples) + { + m_HDRFramebuffer.reset(); + } + + if (m_ViewportSize.x > 0 && m_ViewportSize.y > 0) + { + if (!m_ViewportFramebuffer || !m_ViewportFramebuffer->IsValid()) + { + FramebufferSpecification spec; + spec.Width = (uint32_t)m_ViewportSize.x; + spec.Height = (uint32_t)m_ViewportSize.y; + spec.ColorFormat = FramebufferColorFormat::RGBA8; + m_ViewportFramebuffer = Framebuffer::Create(spec); + } + if (!m_HDRFramebuffer || !m_HDRFramebuffer->IsValid()) + { + FramebufferSpecification hdrSpec; + hdrSpec.Width = (uint32_t)m_ViewportSize.x; + hdrSpec.Height = (uint32_t)m_ViewportSize.y; + hdrSpec.ColorFormat = FramebufferColorFormat::RGBA16F; + hdrSpec.Samples = configuredSamples; + m_MSAAFramebufferSamples = configuredSamples; + m_HDRFramebuffer = Framebuffer::Create(hdrSpec); + } + } + } + + void ViewportPanel::RenderViewportScene(Scene* activeScene) + { + if (!m_HDRFramebuffer || !m_HDRFramebuffer->IsValid()) + { + return; + } + + m_HDRFramebuffer->Bind(); + ClearSceneBackground(activeScene); + + if (!activeScene) + { + m_HDRFramebuffer->Unbind(); + return; + } + + auto camera = GetActiveOrEditorCamera(activeScene); + + if (glm::distance(glm::vec3(camera.Position), glm::vec3(camera.Target)) < 0.001f) + { + camera.Position.z += 1.0f; + camera.ViewMatrix = glm::lookAt(glm::vec3(camera.Position), glm::vec3(camera.Target), glm::vec3(camera.Up)); + } + + SceneRenderOptions options; + auto& currentDebugFlags = activeScene->GetSettings().DebugFlags; + options.DrawGrid = currentDebugFlags.DrawGrid; + options.ShowDebugColliders = currentDebugFlags.DrawColliders; + options.ShowDebugSpawnZones = currentDebugFlags.DrawSpawnZones; + options.SetCollisionWireframeMode = currentDebugFlags.SetCollisionWireframeMode; + m_SceneRenderer->RenderScene(activeScene->GetRegistry(), activeScene->GetSettings(), camera, options); + + // Render proper editor icons (camera, light, spawn) with loaded textures + if (EditorLayer::Get().GetSceneManager().GetSceneState() != SceneState::Play && + EditorLayer::Get().GetConfig().ShowEditorIcons) + { + RenderEditorIcons(activeScene->GetRegistry(), camera); + } + + m_HDRFramebuffer->Unbind(); + // Multisample attachments aren't directly sampleable - resolve into the single-sample + // texture that ApplyPostProcessing()/GetColorAttachmentRendererID() below reads from. + m_HDRFramebuffer->Resolve(); + + if (!m_ViewportFramebuffer || !m_ViewportFramebuffer->IsValid()) + { + return; + } + + m_ViewportFramebuffer->Bind(); + GraphicsDevice::Get().Clear({0, 0, 0, 255}); + + if (auto* renderer = ServiceLocator::TryGet()) + { + renderer->ApplyPostProcessing(m_HDRFramebuffer->GetColorAttachmentRendererID(), + m_HDRFramebuffer->GetDepthAttachmentRendererID(), camera, nullptr, {}); + } + + m_ViewportFramebuffer->Unbind(); + } + + void ViewportPanel::HandleDragDrop(Scene* activeScene) + { + if (ImGui::BeginDragDropTarget()) + { + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("CONTENT_BROWSER_ITEM")) + { + const char* path = (const char*)payload->Data; + std::filesystem::path filepath = std::filesystem::path(path); // Ensure cross-platform path handling + std::string ext = filepath.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + + if (ext == ".chscene") + { + EditorLayer::Get().GetSceneManager().OpenScene(filepath); + } + else if (ext == ".chprefab") + { + PrefabSerializer::Deserialize(activeScene, filepath.string()); + } + else if (ext == ".gltf" || ext == ".glb" || ext == ".obj") + { + std::string filename = filepath.stem().string(); + Entity entity = activeScene->CreateEntity(filename); + auto& modelcomp = entity.AddComponent(); + // Use relative path if possible to satisfy portability + modelcomp.ModelPath = Project::GetActive()->GetRelativePath(filepath); + + SelectEntity(entity, activeScene); + } + } + ImGui::EndDragDropTarget(); + } + } + + void ViewportPanel::RenderOverlays(Scene* activeScene, const ImVec2& viewportSize, const ImVec2& viewportScreenPos) + { + auto selectedEntity = EditorLayer::Get().GetEditorState().SelectedEntity; + if (selectedEntity) + { + if (!activeScene || selectedEntity.GetRegistryPtr() != &activeScene->GetRegistry() || + !selectedEntity.IsValid()) + { + EditorLayer::Get().GetEditorState().SelectedEntity = {}; + selectedEntity = {}; + } + } + bool isUISelected = selectedEntity && selectedEntity.HasComponent(); + auto camera = GetActiveOrEditorCamera(activeScene); + + ImGui::SetCursorScreenPos(viewportScreenPos); + + // 1. Gizmo handling (using absolute screen coordinates) + m_Gizmo.RenderAndHandle(!isUISelected ? m_CurrentTool : GizmoType::NONE, viewportScreenPos, viewportSize, + camera); + + // 2. Game UI Overlay + ImVec2 canvasOrigin = viewportScreenPos; + if (auto* widgetRenderer = ServiceLocator::TryGet()) + { + widgetRenderer->DrawCanvas(activeScene, canvasOrigin, viewportSize, + EditorLayer::Get().GetSceneManager().GetSceneState() == SceneState::Edit); + } + + // 2b. Script UI Overlay (OnGUI) + SceneState sceneState = EditorLayer::Get().GetSceneManager().GetSceneState(); + if (activeScene && (sceneState == SceneState::Play || sceneState == SceneState::Simulate)) + { + ImGui::SetCursorScreenPos(ImVec2(viewportScreenPos.x + 10.0f, viewportScreenPos.y + 10.0f)); + activeScene->OnRenderUI(); + } + + // 3. Selection Highlight + if (isUISelected && selectedEntity && EditorLayer::Get().GetSceneManager().GetSceneState() == SceneState::Edit) + { + auto* widgetRenderer = ServiceLocator::TryGet(); + auto rect = widgetRenderer ? widgetRenderer->GetEntityRect(selectedEntity) : UIRect{0, 0, 0, 0}; + + ImVec2 p1 = ImVec2(rect.x, rect.y); + ImVec2 p2 = ImVec2(p1.x + rect.width, p1.y + rect.height); + + ImGui::GetWindowDrawList()->AddRect(p1, p2, IM_COL32(255, 255, 0, 255), 0, 0, 2.0f); + + // Use the new UI Manipulator + m_UIManipulator.OnImGuiRender(selectedEntity, viewportScreenPos, viewportSize); + + // Debug info + if (ImGui::IsMouseHoveringRect(p1, p2)) + { + ImGui::GetWindowDrawList()->AddRect(p1, p2, IM_COL32(0, 255, 0, 255), 0, 0, 1.0f); + } + } + } + + Entity ViewportPanel::HandleIconPicking(Scene* scene, const Camera3D& camera, const ImVec2& mousePos, + const ImVec2& viewportSize, const ImVec2& viewportScreenPos) + { + if (!EditorLayer::Get().GetConfig().ShowEditorIcons) + { + return {}; + } + + const auto& editorCfg = EditorLayer::Get().GetConfig(); + const float iconMin = editorCfg.IconSizeMin; + const float iconMax = editorCfg.IconSizeMax; + const float iconScale = editorCfg.IconSizeScale; + + const glm::mat4 vp = camera.ProjectionMatrix * camera.ViewMatrix; + + auto worldToScreen = [&](const glm::vec3& wp) -> glm::vec2 { + glm::vec4 clip = vp * glm::vec4(wp, 1.0f); + if (clip.w <= 0.0f) + { + return {-1.f, -1.f}; + } + const glm::vec3 ndc = glm::vec3(clip) / clip.w; + return {(ndc.x * 0.5f + 0.5f) * viewportSize.x + viewportScreenPos.x, + (1.0f - (ndc.y * 0.5f + 0.5f)) * viewportSize.y + viewportScreenPos.y}; + }; + + auto iconPixelRadius = [&](const glm::vec3& wp) -> float { + const float dist = glm::distance(wp, camera.Position); + const float worldSz = std::clamp(dist * iconScale, iconMin, iconMax); + float ppu; + if (camera.Projection == ProjectionType::Perspective && dist > 0.001f) + { + ppu = (viewportSize.y * 0.5f) / (std::tan(glm::radians(camera.FovDegrees) * 0.5f) * dist); + } + else + { + ppu = (viewportSize.y * 0.5f) / std::max(camera.OrthographicSize, 0.001f); + } + return std::max(worldSz * ppu * 0.5f, kMinIconClickRadius); + }; + + Entity bestHit = {}; + float bestIconDist = FLT_MAX; + + auto testIcon = [&](entt::entity id, const glm::vec3& wp) { + const glm::vec2 sp = worldToScreen(wp); + if (sp.x < 0.f) + { + return; + } + const float r = iconPixelRadius(wp); + const float dx = mousePos.x - sp.x; + const float dy = mousePos.y - sp.y; + if (dx * dx + dy * dy <= r * r) + { + const float d = glm::distance(wp, camera.Position); + if (d < bestIconDist) + { + bestIconDist = d; + bestHit = Entity(id, &scene->GetRegistry()); + } + } + }; + + auto& reg = scene->GetRegistry(); + + reg.view().each( + [&](entt::entity id, TransformComponent& tc, CameraComponent&) { + const glm::vec3 wp = glm::vec3(tc.WorldTransform[3]); + if (glm::distance(wp, camera.Position) >= 0.25f) + { + testIcon(id, wp); + } + }); + + reg.view().each( + [&](entt::entity id, TransformComponent& tc, LightComponent&) { + testIcon(id, glm::vec3(tc.WorldTransform[3])); + }); + + reg.view().each( + [&](entt::entity id, TransformComponent& tc, SpawnComponent&) { + testIcon(id, glm::vec3(tc.WorldTransform[3])); + }); + + reg.view().each( + [&](entt::entity id, TransformComponent& tc, AudioComponent&) { + testIcon(id, glm::vec3(tc.WorldTransform[3])); + }); + + return bestHit; + } + + void ViewportPanel::HandlePicking(Scene* activeScene, const ImVec2& viewportSize, const ImVec2& viewportScreenPos) + { + if (EditorLayer::Get().GetSceneManager().IsTransitioning()) + { + return; + } + + // Object picking logic + bool isClicked = ImGui::IsMouseClicked(ImGuiMouseButton_Left); + bool isDragging = m_UIManipulator.IsActive(); + bool isGizmoDragging = m_Gizmo.IsDragging(); + bool isGizmoHovered = m_Gizmo.IsHovered(); + SceneState sceneState = EditorLayer::Get().GetSceneManager().GetSceneState(); + ImVec2 mousePos = ImGui::GetMousePos(); + bool mouseInViewport = + (mousePos.x >= viewportScreenPos.x && mousePos.x <= viewportScreenPos.x + viewportSize.x && + mousePos.y >= viewportScreenPos.y && mousePos.y <= viewportScreenPos.y + viewportSize.y); + + if ((sceneState == SceneState::Edit || sceneState == SceneState::Simulate) && mouseInViewport && isClicked && + !isGizmoDragging && !isGizmoHovered && !isDragging) + { + ImVec2 localMouseImGui = {mousePos.x - viewportScreenPos.x, mousePos.y - viewportScreenPos.y}; + + Ray ray = GetMouseRay({localMouseImGui.x, localMouseImGui.y}); + + Entity bestHit = {}; + + // UI Picking + auto uiView = activeScene->GetRegistry().view(); + int bestZOrder = std::numeric_limits::min(); + for (auto entityID : uiView) + { + Entity entity(entityID, &activeScene->GetRegistry()); + auto& cc = uiView.get(entityID); + if (!cc.IsActive || cc.HiddenInHierarchy) + { + continue; + } + + auto* widgetRenderer = ServiceLocator::TryGet(); + auto rect = widgetRenderer ? widgetRenderer->GetEntityRect(entity) : UIRect{0, 0, 0, 0}; + if (mousePos.x >= rect.x && mousePos.x <= rect.x + rect.width && mousePos.y >= rect.y && + mousePos.y <= rect.y + rect.height) + { + if (cc.ZOrder >= bestZOrder) + { + bestZOrder = cc.ZOrder; + bestHit = entity; + } + } + } + + // Icon Picking — screen-space hit test against billboard icons + if (!bestHit) + { + Camera3D cam = m_CameraController->ToCamera3D(); + bestHit = HandleIconPicking(activeScene, cam, mousePos, viewportSize, viewportScreenPos); + } + + // 3D Picking + if (!bestHit) + { + RaycastResult result = ScenePicker::Raycast(activeScene, ray); + if (result.Hit) + { + bestHit = Entity(result.Entity, &activeScene->GetRegistry()); + } + } + + if (bestHit) + { + SelectEntity(bestHit, activeScene); + } + else + { + if (mouseInViewport) + { + DeselectEntity(activeScene); + } + } + } + } + + void ViewportPanel::RenderToolbar(Scene* activeScene, const ImVec2& viewportScreenPos) + { + SceneState sceneState = EditorLayer::Get().GetSceneManager().GetSceneState(); + if (sceneState == SceneState::Play || sceneState == SceneState::Simulate) + { + // In Play/Simulate mode, Playback controls are in the Main Menu Bar at the top. + // Viewport canvas stays completely clean for game rendering & HUD scripts. + return; + } + + ImVec2 toolbarPos = {viewportScreenPos.x + 10.0f, viewportScreenPos.y + 10.0f}; + ImGui::SetNextWindowPos(toolbarPos); + ImGui::PushStyleColor(ImGuiCol_ChildBg, EditorColors::ToolbarBg); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(5, 5)); + + if (ImGui::BeginChild("##FloatingToolbar", ImVec2(750, 40), true, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) + { + ImGui::SetCursorPosY(6); // Center align vertically-ish + ImGui::Indent(5); + + DrawGizmoButtons(); + + ImGui::SameLine(0, 10); + bool is2D = m_CameraController->Is2DMode(); + bool isUIScene = activeScene && activeScene->GetSettings().Type == SceneType::UI; + if (is2D) + { + ImGui::PushStyleColor(ImGuiCol_Text, EditorColors::ActiveToolOrange); + } + if (!isUIScene) + { + ImGui::BeginDisabled(); + } + if (ImGui::Button(is2D ? (ICON_FA_CAMERA " 2D") : (ICON_FA_CUBE " 3D"), {50, 28})) + { + m_CameraController->Set2DMode(!is2D); + m_Gizmo.Set2DMode(!is2D); + } + if (!isUIScene) + { + ImGui::EndDisabled(); + } + if (is2D) + { + ImGui::PopStyleColor(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip(isUIScene ? "Toggle 2D/3D Editor Mode" : "2D mode available for UI scenes only"); + } + + ImGui::SameLine(0, 10); + DrawCameraSelector(activeScene); + + ImGui::SameLine(0, 10); + ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); + ImGui::SameLine(0, 10); + + DrawSnapSection(); + DrawTransformSpaceToggle(); + + ImGui::SameLine(0, 15); + ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); + ImGui::SameLine(0, 15); + + DrawScriptReloadButton(); + } + ImGui::EndChild(); + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(); + } + + void ViewportPanel::DrawSnapSection() + { + bool snapping = m_Gizmo.IsSnappingEnabled(); + if (snapping) + { + ImGui::PushStyleColor(ImGuiCol_Text, EditorColors::ActiveSnapBlue); + } + if (ImGui::Button(ICON_FA_MAGNET "##SnapToggle", {28, 28})) + { + m_Gizmo.SetSnapping(!snapping); + } + if (snapping) + { + ImGui::PopStyleColor(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Enable Grid Snapping"); + } + + ImGui::SameLine(0, 5); + auto scene = EditorLayer::Get().GetActiveScene(); + float gridSize = scene->GetSettings().Grid.Spacing; + ImGui::SetNextItemWidth(60); + if (ImGui::DragFloat("##SnapValue", &gridSize, 0.1f, 0.1f, 50.0f, "%.1f")) + { + scene->GetSettings().Grid.Spacing = gridSize; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Grid Snap Size (synced with visual grid)"); + } + } + + void ViewportPanel::DrawTransformSpaceToggle() + { + ImGui::SameLine(0, 10); + + bool isLocal = m_Gizmo.IsLocalSpace(); + if (ImGui::Button(isLocal ? (ICON_FA_CUBE " Local") : (ICON_FA_EARTH_AMERICAS " World"), {70, 28})) + { + m_Gizmo.SetLocalSpace(!isLocal); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Toggle Local/World Space"); + } + } + + void ViewportPanel::DrawScriptReloadButton() + { + ImGui::SameLine(0, 5); + if (ImGui::Button(ICON_FA_FILE_CODE "##ReloadToolbar", ImVec2(28, 28))) + { + auto project = Project::GetActive(); + if (project) + { + auto assemblyPath = ScriptEngine::ResolveAssemblyPath(project->GetConfig().Scripting, + project->GetConfig().ProjectDirectory); + if (auto* scriptEngine = ServiceLocator::TryGet()) + { + scriptEngine->RequestAssemblyReload(assemblyPath.string(), "ViewportPanel"); + } + } + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Reload Scripts (Ctrl+R)"); + } + } + + void ViewportPanel::RenderLightIcons(entt::registry& registry, const Camera3D& camera, float iconMin, float iconMax, + float iconScale) + { + const glm::vec3 activeCameraPos = camera.Position; + + auto lightView = registry.view(); + for (auto entity : lightView) + { + auto [transform, light] = lightView.get(entity); + const glm::vec3 iconPos = glm::vec3(transform.WorldTransform[3]); + const float iconSize = ComputeIconSize(iconPos, activeCameraPos, iconMin, iconMax, iconScale); + + glm::vec4 lightTint = {light.LightColor.r / 255.0f, light.LightColor.g / 255.0f, + light.LightColor.b / 255.0f, 0.95f}; + + uint32_t iconTextureId = GetIconHandle(m_EditorIcons.LightIcon); + DrawBillboardIcon(camera, iconTextureId, iconPos, iconSize, lightTint); + + if (iconTextureId != 0 && light.Type == LightType::Directional) + { + glm::vec3 dir = glm::normalize(glm::vec3(transform.WorldTransform[2])) * 0.45f; + if (auto* debugRenderer = ServiceLocator::TryGet()) + { + debugRenderer->DrawLine(iconPos, iconPos + dir, lightTint); + } + } + else if (iconTextureId == 0 && light.Type == LightType::Directional) + { + glm::vec3 dir = glm::normalize(glm::vec3(transform.WorldTransform[2])) * 0.5f; + if (auto* debugRenderer = ServiceLocator::TryGet()) + { + debugRenderer->DrawLine(iconPos, iconPos + dir, lightTint); + } + } + } + } + + void ViewportPanel::DrawBillboardIcon(const Camera3D& camera, uint32_t textureId, const glm::vec3& worldPos, + float iconSize, const glm::vec4& tint) + { + if (textureId != 0) + { + if (auto* renderer = ServiceLocator::TryGet()) + { + renderer->DrawBillboard(camera, textureId, worldPos, iconSize, tint); + } + } + } + + void ViewportPanel::RenderEditorIcons(entt::registry& registry, const Camera3D& camera) + { + const glm::vec3 activeCameraPos = camera.Position; + + auto tryLoadIcon = [&](const char* path, std::shared_ptr& cachedIcon) { + if (cachedIcon) + { + return; + } + + if (auto* assetManager = ServiceLocator::TryGet()) + { + cachedIcon = assetManager->Load(path); + } + }; + + tryLoadIcon("engine/resources/icons/camera_icon.png", m_EditorIcons.CameraIcon); + tryLoadIcon("engine/resources/icons/light_bulb.png", m_EditorIcons.LightIcon); + tryLoadIcon("engine/resources/icons/leaf_icon.png", m_EditorIcons.SpawnIcon); + tryLoadIcon("engine/resources/icons/audio.png", m_EditorIcons.AudioIcon); + + // Gizmo icon sizing comes from the global editor settings (Editor Settings > Appearance). + const auto& editorCfg = EditorLayer::Get().GetConfig(); + const float iconMin = editorCfg.IconSizeMin; + const float iconMax = editorCfg.IconSizeMax; + const float iconScale = editorCfg.IconSizeScale; + + // Camera icons + auto cameraView = registry.view(); + for (auto entity : cameraView) + { + auto [transform, cameraComp] = cameraView.get(entity); + const glm::vec3 iconPos = glm::vec3(transform.WorldTransform[3]); + if (glm::distance(iconPos, activeCameraPos) < 0.25f) + { + continue; + } + + const float iconSize = ComputeIconSize(iconPos, activeCameraPos, iconMin, iconMax, iconScale); + const glm::vec4 cameraTint = glm::vec4(0.65f, 0.95f, 1.0f, 0.95f); + DrawBillboardIcon(camera, GetIconHandle(m_EditorIcons.CameraIcon), iconPos, iconSize, cameraTint); + } + + // Light icons + RenderLightIcons(registry, camera, iconMin, iconMax, iconScale); + + // Spawn icons + { + auto spawnView = registry.view(); + for (auto entity : spawnView) + { + auto [transform, spawn] = spawnView.get(entity); + const glm::vec3 iconPos = glm::vec3(transform.WorldTransform[3]); + const float iconSize = ComputeIconSize(iconPos, activeCameraPos, iconMin, iconMax, iconScale); + const glm::vec4 spawnTint = {1.0f, 1.0f, 1.0f, 0.95f}; + DrawBillboardIcon(camera, GetIconHandle(m_EditorIcons.SpawnIcon), iconPos, iconSize, spawnTint); + } + } + { + auto audioView = registry.view(); + for (auto entity : audioView) + { + auto [transform, audio] = audioView.get(entity); + const glm::vec3 iconPos = glm::vec3(transform.WorldTransform[3]); + const float iconSize = ComputeIconSize(iconPos, activeCameraPos, iconMin, iconMax, iconScale); + const glm::vec4 audioTint = {1.0f, 1.0f, 1.0f, 0.95f}; + DrawBillboardIcon(camera, GetIconHandle(m_EditorIcons.AudioIcon), iconPos, iconSize, audioTint); + } + } + } +} // namespace Chained diff --git a/editor/panels/viewport_panel.h b/editor/panels/viewport_panel.h index 79f65a5b7..342abf266 100644 --- a/editor/panels/viewport_panel.h +++ b/editor/panels/viewport_panel.h @@ -1,93 +1,131 @@ #ifndef CH_VIEWPORT_PANEL_H #define CH_VIEWPORT_PANEL_H -#include "engine/core/timestep.h" #include "panel.h" -#include "imgui.h" -#include "IconsFontAwesome6.h" -#include -#include "engine/graphics/api/framebuffer.h" -#include "viewport/editor_camera.h" -#include "viewport/editor_gizmo.h" +#include "engine/common/timestep.h" +#include "engine/physics/raycast_result.h" +#include "engine/scene/scene_settings.h" +#include "viewport/camera.h" +#include "viewport/gizmo.h" #include "viewport/ui_manipulator.h" +#include "icons.h" -namespace CHEngine -{ -struct GizmoBtn -{ - GizmoType type; - const char* icon; - const char* tooltip; - int key; -}; +#include +#include +#include +#include "engine/core/key_codes.h" -class ViewportPanel : public Panel +struct GLFWwindow; + +namespace Chained { -public: - ViewportPanel(); - ~ViewportPanel(); - -public: - virtual void OnImGuiRender(bool readOnly = false) override; - virtual void OnUpdate(Timestep ts) override; - virtual void OnEvent(Event& e) override; - -public: - bool IsFocused() const - { - return m_Focused; - } - bool IsHovered() const - { - return m_Hovered; - } - glm::vec2 GetSize() const - { - return m_ViewportSize; - } - - GizmoType& GetCurrentTool() - { - return m_CurrentTool; - } - -public: - void DrawGizmoButtons(); - void DrawCameraSelector(class Scene* scene); - -private: - std::shared_ptr m_ViewportFramebuffer; - std::shared_ptr m_HDRFramebuffer; - glm::vec2 m_ViewportSize = {0, 0}; - bool m_Focused = false; - bool m_Hovered = false; - - std::unique_ptr m_CameraController; - EditorGizmo m_Gizmo; - EditorUIManipulator m_UIManipulator; - GizmoType m_CurrentTool = GizmoType::TRANSLATE; - Entity m_SelectedEntity; - std::unique_ptr m_SceneRenderer; - - // UI Interaction state - ImVec2 m_UIDragOffset = {0, 0}; - - // Viewport Camera State - uint64_t m_ViewportCameraEntityUUID = 0; // 0 = Editor Camera - -private: - void HandleResize(const ImVec2& viewportSize, class Scene* activeScene); - void RenderViewportScene(class Scene* activeScene, const ImVec2& viewportSize); - void HandleDragDrop(class Scene* activeScene); - void RenderOverlays(class Scene* activeScene, const ImVec2& viewportSize, const ImVec2& viewportScreenPos); - void HandlePicking(class Scene* activeScene, const ImVec2& viewportSize, const ImVec2& viewportScreenPos); - void RenderToolbar(class Scene* activeScene, const ImVec2& viewportSize, const ImVec2& viewportScreenPos); - void RenderLaunchHUD(const ImVec2& viewportSize, const ImVec2& viewportScreenPos); - -private: - void ClearSceneBackground(Scene* scene); -}; - -} // namespace CHEngine - -#endif // CH_VIEWPORT_PANEL_H + + class Framebuffer; + class Scene; + class SceneRenderer; + class Renderer; + struct SceneSettings; + struct Camera3D; + class Event; + + struct GizmoBtn + { + GizmoType type; + const char* icon; + const char* tooltip; + KeyCode key; + }; + + class ViewportPanel : public Panel + { + public: + ViewportPanel(ImVec2& editorViewportSize); + ~ViewportPanel() override; + + void OnImGuiRender(bool readOnly = false) override; + void OnUpdate(Timestep ts) override; + void OnEvent(Event& e) override; + + bool IsFocused() const + { + return m_Focused; + } + bool IsHovered() const + { + return m_Hovered; + } + glm::vec2 GetSize() const + { + return m_ViewportSize; + } + GizmoType& GetCurrentTool() + { + return m_CurrentTool; + } + + void DrawGizmoButtons(); + void DrawCameraSelector(Scene* scene); + Ray GetMouseRay(const glm::vec2& mousePosition); + + std::shared_ptr GetViewportFramebuffer() const + { + return m_ViewportFramebuffer; + } + + private: + glm::vec2 m_ViewportSize = {0, 0}; + bool m_Focused = false; + bool m_Hovered = false; + bool m_CursorLocked = false; + GLFWwindow* m_PlatformWindow = nullptr; + GLFWwindow* m_LockedWindow = nullptr; + GizmoType m_CurrentTool = GizmoType::TRANSLATE; + + std::unique_ptr m_CameraController; + EditorGizmo m_Gizmo; + EditorUIManipulator m_UIManipulator; + EditorIcons m_EditorIcons; + + SceneType m_LastSceneType = SceneType::Default; + + std::shared_ptr m_ViewportFramebuffer; + std::shared_ptr m_HDRFramebuffer; + uint32_t m_MSAAFramebufferSamples = 1; // MSAA sample count m_HDRFramebuffer was last (re)created with + + // Engine subsystem pointers are now accessed via static APIs (Renderer::Get(), etc.) + std::unique_ptr m_SceneRenderer; + + ImVec2& m_EditorViewportSize; + + private: + void HandleResize(const ImVec2& viewportSize, Scene* activeScene); + void RenderViewportScene(Scene* activeScene); + void HandleDragDrop(Scene* activeScene); + void RenderOverlays(Scene* activeScene, const ImVec2& viewportSize, const ImVec2& viewportScreenPos); + void HandlePicking(Scene* activeScene, const ImVec2& viewportSize, const ImVec2& viewportScreenPos); + void RenderToolbar(Scene* activeScene, const ImVec2& viewportScreenPos); + void HandleKeyboardShortcuts(); + + // Toolbar sub-sections + void DrawSnapSection(); + void DrawTransformSpaceToggle(); + void DrawScriptReloadButton(); + + // Picking helpers + Entity HandleIconPicking(Scene* scene, const Camera3D& camera, const ImVec2& mousePos, + const ImVec2& viewportSize, const ImVec2& viewportScreenPos); + + // Icon rendering helpers + void DrawBillboardIcon(const Camera3D& camera, uint32_t textureId, const glm::vec3& worldPos, float iconSize, + const glm::vec4& tint); + void RenderEditorIcons(entt::registry& registry, const Camera3D& camera); + void RenderLightIcons(entt::registry& registry, const Camera3D& camera, float iconMin, float iconMax, + float iconScale); + void ClearSceneBackground(Scene* scene); + + Camera3D GetActiveOrEditorCamera(Scene* scene) const; + }; + +} // namespace Chained + +#endif // CH_VIEWPORT_PANEL_H \ No newline at end of file diff --git a/editor/panels/world_panel.cpp b/editor/panels/world_panel.cpp index fb1e6d3e7..04d9d0473 100644 --- a/editor/panels/world_panel.cpp +++ b/editor/panels/world_panel.cpp @@ -1,450 +1,590 @@ #include "world_panel.h" -#include "IconsFontAwesome6.h" -#include "editor/editor_layer.h" -#include "engine/core/assets/asset_manager.h" -#include "engine/graphics/assets/environment.h" -#include "engine/platform/utils/dialogs.h" -#include "engine/scene/project.h" +#include "editor/layer.h" +#include "editor/project/project_serializer.h" +#include "engine/assets/asset_manager.h" +#include "engine/assets/types/environment_asset.h" +#include "engine/core/service_locator.h" +#include "engine/ui/widget_renderer.h" +#include "engine/imgui/imgui_layer.h" +#include "engine/physics/physics.h" +#include "engine/platform/dialogs/dialogs.h" +#include "engine/project/project.h" #include "scene/scene.h" +#include "thirdparty/IconsFontAwesome6.h" #include +#include - -namespace CHEngine -{ - -WorldPanel::WorldPanel() +namespace Chained { - m_Name = "World Settings"; -} -void WorldPanel::OnImGuiRender(bool readOnly) -{ - if (!m_IsOpen) - { - return; - } - - ImGui::Begin(m_Name.c_str(), &m_IsOpen); - - if (!m_Context) - { - ImGui::Text("No active scene."); - ImGui::End(); - return; - } - - if (ImGui::CollapsingHeader(ICON_FA_GLOBE " Scene Background", ImGuiTreeNodeFlags_DefaultOpen)) - { - ImGui::Indent(10.0f); - if (readOnly) - { - ImGui::BeginDisabled(); - } - - const char* bgModes[] = {"Solid Color", "Texture", "3D Environment"}; - int currentMode = (int)m_Context->GetSettings().Mode; - - ImGui::AlignTextToFramePadding(); - ImGui::Text("Mode"); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(-1); - if (ImGui::Combo("##BGMode", ¤tMode, bgModes, 3)) - { - m_Context->GetSettings().Mode = (BackgroundMode)currentMode; - } - - if (m_Context->GetSettings().Mode == BackgroundMode::Color) - { - Color bgColor = m_Context->GetSettings().BackgroundColor; - float c[4] = {bgColor.r / 255.f, bgColor.g / 255.f, bgColor.b / 255.f, bgColor.a / 255.f}; - ImGui::AlignTextToFramePadding(); - ImGui::Text("Color"); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(-1); - if (ImGui::ColorEdit4("##BGColor", c)) - { - m_Context->GetSettings().BackgroundColor = {(uint8_t)(c[0] * 255), (uint8_t)(c[1] * 255), - (uint8_t)(c[2] * 255), (uint8_t)(c[3] * 255)}; - } - } - else if (m_Context->GetSettings().Mode == BackgroundMode::Texture) - { - char buffer[256]; - memset(buffer, 0, sizeof(buffer)); - strncpy(buffer, m_Context->GetSettings().BackgroundTexturePath.c_str(), sizeof(buffer) - 1); - - ImGui::AlignTextToFramePadding(); - ImGui::Text("Texture"); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 35); - if (ImGui::InputText("##BGPath", buffer, sizeof(buffer))) - { - m_Context->GetSettings().BackgroundTexturePath = buffer; - } - - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_FOLDER_OPEN "##BGSelect")) - { - std::vector filters = {{"Textures", "png,jpg,tga,bmp"}}; - auto result = Dialogs::OpenFile(filters); - if (result) - { - std::filesystem::path p = *result; - if (Project::GetActive()) - { - m_Context->GetSettings().BackgroundTexturePath = - std::filesystem::relative(p, Project::GetAssetDirectory()).string(); - } - else - { - m_Context->GetSettings().BackgroundTexturePath = p.filename().string(); - } - } - } - } - - if (readOnly) - { - ImGui::EndDisabled(); - } - ImGui::Unindent(10.0f); - } - - if (ImGui::CollapsingHeader(ICON_FA_MICROCHIP " Physics", ImGuiTreeNodeFlags_DefaultOpen)) - { - ImGui::Indent(10.0f); - if (readOnly) - { - ImGui::BeginDisabled(); - } - - if (auto project = Project::GetActive()) - { - auto& settings = project->GetConfig().Physics; - - ImGui::AlignTextToFramePadding(); - ImGui::Text("Gravity"); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(-1); - ImGui::DragFloat("##Gravity", &settings.Gravity, 0.1f); - - float fps = 1.0f / settings.FixedTimestep; - ImGui::AlignTextToFramePadding(); - ImGui::Text("Fixed FPS"); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(-1); - if (ImGui::DragFloat("##FixedFPS", &fps, 1.0f, 10.0f, 240.0f)) - { - settings.FixedTimestep = 1.0f / fps; - } - } - else - { - ImGui::TextDisabled("No active project."); - } - - if (readOnly) - { - ImGui::EndDisabled(); - } - ImGui::Unindent(10.0f); - } - - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - - auto env = m_Context->GetSettings().Environment; - - if (!readOnly) - { - if (ImGui::Button(ICON_FA_FILE_IMPORT " Load Environment")) - { - std::vector filters = {{"Environment", "chenv"}}; - auto result = Dialogs::OpenFile(filters); - if (result) - { - if (auto project = Project::GetActive()) - { - m_Context->GetSettings().Environment = AssetManager::Get().Get(result->string()); - } - } - } - - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_FILE_CIRCLE_PLUS " New")) - { - std::vector filters = {{"Environment", "chenv"}}; - auto result = Dialogs::SaveFile(filters); - if (result) - { - auto newEnv = std::make_shared(); - newEnv->SetPath(result->string()); - m_Context->GetSettings().Environment = newEnv; - } - } - } - - if (env) - { - if (!readOnly) - { - ImGui::TextDisabled(ICON_FA_FILE_SIGNATURE " %s", - std::filesystem::path(env->GetPath()).filename().string().c_str()); - ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60); - - if (ImGui::Button(ICON_FA_FLOPPY_DISK " Save")) - { - const auto& settings = env->GetSettings(); - - YAML::Emitter out; - out << YAML::BeginMap; - out << YAML::Key << "Environment" << YAML::BeginMap; - out << YAML::Key << "Lighting" << YAML::BeginMap; - out << YAML::Key << "Direction" << YAML::BeginMap; - out << YAML::Key << "X" << YAML::Value << settings.Lighting.Direction.x; - out << YAML::Key << "Y" << YAML::Value << settings.Lighting.Direction.y; - out << YAML::Key << "Z" << YAML::Value << settings.Lighting.Direction.z; - out << YAML::EndMap; - - out << YAML::Key << "LightColor" << YAML::BeginMap; - out << YAML::Key << "R" << YAML::Value << (int)settings.Lighting.LightColor.r; - out << YAML::Key << "G" << YAML::Value << (int)settings.Lighting.LightColor.g; - out << YAML::Key << "B" << YAML::Value << (int)settings.Lighting.LightColor.b; - out << YAML::Key << "A" << YAML::Value << (int)settings.Lighting.LightColor.a; - out << YAML::EndMap; - out << YAML::Key << "Ambient" << YAML::Value << settings.Lighting.Ambient; - out << YAML::Key << "Exposure" << YAML::Value << settings.Lighting.Exposure; - out << YAML::Key << "Gamma" << YAML::Value << settings.Lighting.Gamma; - out << YAML::EndMap; - - out << YAML::Key << "Skybox" << YAML::BeginMap; - out << YAML::Key << "TexturePath" << YAML::Value << settings.Skybox.TexturePath; - out << YAML::Key << "Exposure" << YAML::Value << settings.Skybox.Exposure; - out << YAML::Key << "Brightness" << YAML::Value << settings.Skybox.Brightness; - out << YAML::Key << "Contrast" << YAML::Value << settings.Skybox.Contrast; - out << YAML::EndMap; - - out << YAML::Key << "Fog" << YAML::BeginMap; - out << YAML::Key << "Enabled" << YAML::Value << settings.Fog.Enabled; - out << YAML::Key << "Color" << YAML::BeginMap; - out << YAML::Key << "R" << YAML::Value << (int)settings.Fog.FogColor.r; - out << YAML::Key << "G" << YAML::Value << (int)settings.Fog.FogColor.g; - out << YAML::Key << "B" << YAML::Value << (int)settings.Fog.FogColor.b; - out << YAML::Key << "A" << YAML::Value << (int)settings.Fog.FogColor.a; - out << YAML::EndMap; - out << YAML::Key << "Density" << YAML::Value << settings.Fog.Density; - out << YAML::Key << "Start" << YAML::Value << settings.Fog.Start; - out << YAML::Key << "End" << YAML::Value << settings.Fog.End; - out << YAML::EndMap; - - out << YAML::EndMap; - - out << YAML::EndMap; - out << YAML::EndMap; - - std::string path = env->GetPath(); - std::filesystem::path fullPath(path); - std::filesystem::create_directories(fullPath.parent_path()); - std::ofstream fout(fullPath); - if (fout.is_open()) - { - fout << out.c_str(); - } - } - } - - DrawEnvironmentSettings(env, readOnly); - } - - ImGui::End(); -} - -void WorldPanel::DrawEnvironmentSettings(std::shared_ptr env, bool readOnly) -{ - auto& settings = env->GetSettings(); - - if (ImGui::CollapsingHeader(ICON_FA_SUN " Global Lighting", ImGuiTreeNodeFlags_DefaultOpen)) - { - ImGui::PushID("GlobalLighting"); - ImGui::Indent(10.0f); - if (readOnly) - { - ImGui::BeginDisabled(); - } - - auto drawDragFloat = [&](const char* label, float* value, float speed, float min, float max, - const char* format = "%.3f") { - ImGui::AlignTextToFramePadding(); - ImGui::Text("%s", label); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(-1); - std::string id = "##"; - id += label; - return ImGui::DragFloat(id.c_str(), value, speed, min, max, format); - }; - - ImGui::AlignTextToFramePadding(); - ImGui::Text("Direction"); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(-1); - ImGui::DragFloat3("##Direction", &settings.Lighting.Direction.x, 0.01f, -1.0f, 1.0f); - - float color[4] = {settings.Lighting.LightColor.r / 255.f, settings.Lighting.LightColor.g / 255.f, - settings.Lighting.LightColor.b / 255.f, settings.Lighting.LightColor.a / 255.f}; - ImGui::AlignTextToFramePadding(); - ImGui::Text("Color"); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(-1); - if (ImGui::ColorEdit4("##LightColor", color)) - { - settings.Lighting.LightColor = {(uint8_t)(color[0] * 255), (uint8_t)(color[1] * 255), - (uint8_t)(color[2] * 255), (uint8_t)(color[3] * 255)}; - } - - drawDragFloat("Ambient", &settings.Lighting.Ambient, 0.005f, 0.0f, 2.0f); - drawDragFloat("Exposure", &settings.Lighting.Exposure, 0.01f, 0.0f, 10.0f); - drawDragFloat("Gamma", &settings.Lighting.Gamma, 0.01f, 1.0f, 4.0f); - - if (readOnly) - { - ImGui::EndDisabled(); - } - ImGui::Unindent(10.0f); - ImGui::PopID(); - } - - if (ImGui::CollapsingHeader(ICON_FA_CLOUD " Skybox", ImGuiTreeNodeFlags_DefaultOpen)) - { - ImGui::PushID("Skybox"); - ImGui::Indent(10.0f); - if (readOnly) - { - ImGui::BeginDisabled(); - } - - auto drawDragFloat = [&](const char* label, float* value, float speed, float min, float max, - const char* format = "%.3f") { - ImGui::AlignTextToFramePadding(); - ImGui::Text("%s", label); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(-1); - std::string id = "##"; - id += label; - return ImGui::DragFloat(id.c_str(), value, speed, min, max, format); - }; - - char buffer[256]; - memset(buffer, 0, sizeof(buffer)); - strncpy(buffer, settings.Skybox.TexturePath.c_str(), sizeof(buffer) - 1); - - ImGui::AlignTextToFramePadding(); - ImGui::Text("Texture"); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 35); - if (ImGui::InputText("##SkyPath", buffer, sizeof(buffer))) - { - settings.Skybox.TexturePath = buffer; - } - - ImGui::SameLine(); - if (ImGui::Button(ICON_FA_FOLDER_OPEN "##SkySelect")) - { - std::vector filters = {{"Textures/HDR", "png,jpg,hdr"}}; - auto result = Dialogs::OpenFile(filters); - if (result) - { - std::filesystem::path p = *result; - if (Project::GetActive()) - { - settings.Skybox.TexturePath = std::filesystem::relative(p, Project::GetAssetDirectory()).string(); - } - else - { - settings.Skybox.TexturePath = p.filename().string(); - } - } - } - - const char* mapModes[] = {"Sphere", "Cross", "Cubemap"}; - ImGui::AlignTextToFramePadding(); - ImGui::Text("Mapping"); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(-1); - ImGui::Combo("##MapMode", &settings.Skybox.Mode, mapModes, 3); - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("Sphere: Equirectangular\nCross: Horizontal Cross\nCubemap: GPU Generated"); - } - - drawDragFloat("Exposure", &settings.Skybox.Exposure, 0.01f, 0.0f, 10.0f); - drawDragFloat("Brightness", &settings.Skybox.Brightness, 0.01f, -2.0f, 2.0f); - drawDragFloat("Contrast", &settings.Skybox.Contrast, 0.01f, 0.0f, 5.0f); - - if (readOnly) - { - ImGui::EndDisabled(); - } - ImGui::Unindent(10.0f); - ImGui::PopID(); - } - - if (ImGui::CollapsingHeader(ICON_FA_SMOG " Fog Visibility", ImGuiTreeNodeFlags_DefaultOpen)) - { - ImGui::PushID("FogVisibility"); - ImGui::Indent(10.0f); - if (readOnly) - { - ImGui::BeginDisabled(); - } - - auto drawDragFloat = [&](const char* label, float* value, float speed, float min, float max, - const char* format = "%.3f") { - ImGui::AlignTextToFramePadding(); - ImGui::Text("%s", label); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(-1); - std::string id = "##"; - id += label; - return ImGui::DragFloat(id.c_str(), value, speed, min, max, format); - }; - - auto& fog = settings.Fog; - ImGui::AlignTextToFramePadding(); - ImGui::Text("Enabled"); - ImGui::SameLine(100); - ImGui::Checkbox("##FogEnabled", &fog.Enabled); - - float fogColor[4] = {fog.FogColor.r / 255.f, fog.FogColor.g / 255.f, fog.FogColor.b / 255.f, - fog.FogColor.a / 255.f}; - ImGui::AlignTextToFramePadding(); - ImGui::Text("Color"); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(-1); - if (ImGui::ColorEdit4("##FogColor", fogColor)) - { - fog.FogColor = {(uint8_t)(fogColor[0] * 255), (uint8_t)(fogColor[1] * 255), (uint8_t)(fogColor[2] * 255), - (uint8_t)(fogColor[3] * 255)}; - } - - const char* fogModes[] = {"Linear", "Exponential", "Exponential Squared"}; - ImGui::AlignTextToFramePadding(); - ImGui::Text("Mode"); - ImGui::SameLine(100); - ImGui::SetNextItemWidth(-1); - ImGui::Combo("##FogMode", &fog.Mode, fogModes, 3); - - drawDragFloat("Density", &fog.Density, 0.0001f, 0.0f, 0.1f, "%.4f"); - drawDragFloat("Start", &fog.Start, 1.0f, 0.0f, 10000.0f); - drawDragFloat("End", &fog.End, 1.0f, 0.0f, 10000.0f); - - if (readOnly) - { - ImGui::EndDisabled(); - } - ImGui::Unindent(10.0f); - ImGui::PopID(); - } -} - -} // namespace CHEngine + static void ColorToFloat4(const Color& c, float out[4]) + { + out[0] = c.r / 255.f; + out[1] = c.g / 255.f; + out[2] = c.b / 255.f; + out[3] = c.a / 255.f; + } + + static Color Float4ToColor(const float c[4]) + { + return {(uint8_t)(c[0] * 255), (uint8_t)(c[1] * 255), (uint8_t)(c[2] * 255), (uint8_t)(c[3] * 255)}; + } + + static bool DrawDragFloat(const char* label, float* value, float speed = 0.1f, float min = 0.0f, float max = 0.0f, + const char* format = "%.3f") + { + ImGui::AlignTextToFramePadding(); + ImGui::Text("%s", label); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(-1); + ImGui::PushID(label); + bool changed = ImGui::DragFloat("##v", value, speed, min, max, format); + ImGui::PopID(); + return changed; + } + + static void SaveProjectConfig() + { + if (auto project = Project::GetActive()) + { + std::filesystem::path path = project->GetConfig().ProjectDirectory / (project->GetName() + ".chproject"); + EditorProjectSerializer::Serialize(project, path); + } + } + + WorldPanel::WorldPanel() + { + m_Name = "World Settings"; + } + + void WorldPanel::OnImGuiRender(bool readOnly) + { + if (!m_IsOpen) + { + return; + } + + ImGui::Begin(m_Name.c_str(), &m_IsOpen); + + if (!m_Context) + { + ImGui::Text("No active scene."); + ImGui::End(); + return; + } + + DrawSceneGeneral(readOnly); + DrawSceneBackground(readOnly); + + if (m_Context->GetSettings().Mode == BackgroundMode::Environment3D) + { + DrawPhysicsSettings(readOnly); + DrawEnvironmentSection(readOnly); + } + + ImGui::End(); + } + + void WorldPanel::DrawSceneGeneral(bool readOnly) + { + if (!ImGui::CollapsingHeader(ICON_FA_SLIDERS " Scene General", ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + + ImGui::Indent(10.0f); + if (readOnly) + { + ImGui::BeginDisabled(); + } + + auto& settings = m_Context->GetSettings(); + const char* typeModes[] = {"Default (3D)", "UI"}; + int currentType = (int)settings.Type; + + ImGui::AlignTextToFramePadding(); + ImGui::Text("Type"); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(-1); + if (ImGui::Combo("##SceneType", ¤tType, typeModes, 2)) + { + settings.Type = (SceneType)currentType; + } + + if (readOnly) + { + ImGui::EndDisabled(); + } + ImGui::Unindent(10.0f); + } + + void WorldPanel::DrawSceneBackground(bool readOnly) + { + if (!ImGui::CollapsingHeader(ICON_FA_GLOBE " Scene Background", ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + + ImGui::Indent(10.0f); + if (readOnly) + { + ImGui::BeginDisabled(); + } + + auto& settings = m_Context->GetSettings(); + const char* bgModes[] = {"Solid Color", "Texture", "3D Environment"}; + int currentMode = (int)settings.Mode; + + ImGui::AlignTextToFramePadding(); + ImGui::Text("Mode"); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(-1); + if (ImGui::Combo("##BGMode", ¤tMode, bgModes, 3)) + { + settings.Mode = (BackgroundMode)currentMode; + } + + if (settings.Mode == BackgroundMode::Color) + { + float c[4]; + ColorToFloat4(settings.BackgroundColor, c); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Color"); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(-1); + if (ImGui::ColorEdit4("##BGColor", c)) + { + settings.BackgroundColor = Float4ToColor(c); + } + } + else if (settings.Mode == BackgroundMode::Texture) + { + char buffer[256]; + snprintf(buffer, sizeof(buffer), "%s", settings.BackgroundTexturePath.c_str()); + + ImGui::AlignTextToFramePadding(); + ImGui::Text("Texture"); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 35); + if (ImGui::InputText("##BGPath", buffer, sizeof(buffer))) + { + settings.BackgroundTexturePath = buffer; + } + + ImGui::SameLine(); + if (ImGui::Button(ICON_FA_FOLDER_OPEN "##BGSelect")) + { + std::vector filters = {{"Textures", "png,jpg,tga,bmp"}}; + auto result = Chained::Dialogs::OpenFile(filters); + if (result) + { + std::filesystem::path p = *result; + if (Project::GetActive()) + { + settings.BackgroundTexturePath = + std::filesystem::relative(p, Project::GetActive()->GetAssetDirectory()).string(); + } + else + { + settings.BackgroundTexturePath = p.filename().string(); + } + } + } + } + + if (readOnly) + { + ImGui::EndDisabled(); + } + ImGui::Unindent(10.0f); + } + + void WorldPanel::DrawPhysicsSettings(bool readOnly) + { + if (!ImGui::CollapsingHeader(ICON_FA_MICROCHIP " Physics", ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + + ImGui::Indent(10.0f); + if (readOnly) + { + ImGui::BeginDisabled(); + } + + if (auto project = Project::GetActive()) + { + auto& physicsSettings = project->GetConfig().Physics; + + ImGui::AlignTextToFramePadding(); + ImGui::Text("Gravity"); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(-1); + ImGui::DragFloat("##Gravity", &physicsSettings.Gravity, 0.1f); + + if (ImGui::IsItemDeactivatedAfterEdit()) + { + SceneState state = EditorLayer::Get().GetSceneState(); + if (state == SceneState::Play || state == SceneState::Simulate) + { + if (auto* physics = ServiceLocator::TryGet()) + { + if (auto* world = physics->GetWorld()) + { + world->SetGravity(physicsSettings.Gravity); + } + } + } + SaveProjectConfig(); + } + + float fps = 1.0f / physicsSettings.FixedTimestep; + ImGui::AlignTextToFramePadding(); + ImGui::Text("Fixed FPS"); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(-1); + if (ImGui::DragFloat("##FixedFPS", &fps, 1.0f, 10.0f, 240.0f)) + { + physicsSettings.FixedTimestep = 1.0f / fps; + } + + if (ImGui::IsItemDeactivatedAfterEdit()) + { + SaveProjectConfig(); + } + } + else + { + ImGui::TextDisabled("No active project."); + } + + if (readOnly) + { + ImGui::EndDisabled(); + } + ImGui::Unindent(10.0f); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + } + + void WorldPanel::DrawEnvironmentSection(bool readOnly) + { + auto env = m_Context->GetSettings().Environment; + + if (!readOnly) + { + if (ImGui::Button(ICON_FA_FILE_IMPORT " Load Environment")) + { + std::vector filters = {{"Environment", "chenv"}}; + auto result = Chained::Dialogs::OpenFile(filters); + if (result) + { + if (auto project = Project::GetActive()) + { + if (auto* am = ServiceLocator::TryGet()) + { + auto handle = am->ResolveToHandle(result->string()); + m_Context->GetSettings().Environment = am->Get(result->string()); + } + } + } + } + + ImGui::SameLine(); + if (ImGui::Button(ICON_FA_FILE_CIRCLE_PLUS " New")) + { + std::vector filters = {{"Environment", "chenv"}}; + auto result = Chained::Dialogs::SaveFile(filters); + if (result) + { + if (result->extension().empty()) + { + result->replace_extension(".chenv"); + } + + auto newEnv = std::make_shared(); + newEnv->SetPath(result->string()); + m_Context->GetSettings().Environment = newEnv; + } + } + } + + if (env) + { + if (!readOnly) + { + ImGui::TextDisabled(ICON_FA_FILE_SIGNATURE " %s", + std::filesystem::path(env->GetPath()).filename().string().c_str()); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60); + + if (ImGui::Button(ICON_FA_FLOPPY_DISK " Save")) + { + const auto& s = env->GetSettings(); + + YAML::Emitter out; + out << YAML::BeginMap; + out << YAML::Key << "Environment" << YAML::BeginMap; + + out << YAML::Key << "Lighting" << YAML::BeginMap; + out << YAML::Key << "Direction" << YAML::BeginMap; + out << YAML::Key << "X" << YAML::Value << s.Lighting.Direction.x; + out << YAML::Key << "Y" << YAML::Value << s.Lighting.Direction.y; + out << YAML::Key << "Z" << YAML::Value << s.Lighting.Direction.z; + out << YAML::EndMap; + out << YAML::Key << "LightColor" << YAML::BeginMap; + out << YAML::Key << "R" << YAML::Value << (int)s.Lighting.LightColor.r; + out << YAML::Key << "G" << YAML::Value << (int)s.Lighting.LightColor.g; + out << YAML::Key << "B" << YAML::Value << (int)s.Lighting.LightColor.b; + out << YAML::Key << "A" << YAML::Value << (int)s.Lighting.LightColor.a; + out << YAML::EndMap; + out << YAML::Key << "Ambient" << YAML::Value << s.Lighting.Ambient; + out << YAML::Key << "Exposure" << YAML::Value << s.Lighting.Exposure; + out << YAML::Key << "Gamma" << YAML::Value << s.Lighting.Gamma; + out << YAML::EndMap; + + out << YAML::Key << "Skybox" << YAML::BeginMap; + out << YAML::Key << "TexturePath" << YAML::Value << s.Skybox.TexturePath; + out << YAML::Key << "Mode" << YAML::Value << s.Skybox.Mode; + for (int i = 0; i < 6; ++i) + { + out << YAML::Key << ("CubeFace" + std::to_string(i)) << YAML::Value << s.Skybox.CubeFaces[i]; + } + out << YAML::Key << "Exposure" << YAML::Value << s.Skybox.Exposure; + out << YAML::Key << "Brightness" << YAML::Value << s.Skybox.Brightness; + out << YAML::Key << "Contrast" << YAML::Value << s.Skybox.Contrast; + out << YAML::EndMap; + + out << YAML::Key << "Fog" << YAML::BeginMap; + out << YAML::Key << "Enabled" << YAML::Value << s.Fog.Enabled; + out << YAML::Key << "Color" << YAML::BeginMap; + out << YAML::Key << "R" << YAML::Value << (int)s.Fog.FogColor.r; + out << YAML::Key << "G" << YAML::Value << (int)s.Fog.FogColor.g; + out << YAML::Key << "B" << YAML::Value << (int)s.Fog.FogColor.b; + out << YAML::Key << "A" << YAML::Value << (int)s.Fog.FogColor.a; + out << YAML::EndMap; + out << YAML::Key << "Density" << YAML::Value << s.Fog.Density; + out << YAML::Key << "Start" << YAML::Value << s.Fog.Start; + out << YAML::Key << "End" << YAML::Value << s.Fog.End; + out << YAML::Key << "HeightFalloff" << YAML::Value << s.Fog.HeightFalloff; + out << YAML::EndMap; + + out << YAML::EndMap; + out << YAML::EndMap; + out << YAML::EndMap; + + std::string path = env->GetPath(); + std::filesystem::path fullPath(path); + std::filesystem::create_directories(fullPath.parent_path()); + std::ofstream fout(fullPath); + if (fout.is_open()) + { + fout << out.c_str(); + } + } + } + + DrawEnvironmentSettings(env, readOnly); + } + } + + void WorldPanel::DrawEnvironmentSettings(std::shared_ptr env, bool readOnly) + { + auto& settings = env->GetSettings(); + + if (ImGui::CollapsingHeader(ICON_FA_SUN " Global Lighting", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::PushID("GlobalLighting"); + ImGui::Indent(10.0f); + if (readOnly) + { + ImGui::BeginDisabled(); + } + + ImGui::AlignTextToFramePadding(); + ImGui::Text("Direction"); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(-1); + ImGui::DragFloat3("##Direction", &settings.Lighting.Direction.x, 0.01f, -1.0f, 1.0f); + + float color[4]; + ColorToFloat4(settings.Lighting.LightColor, color); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Color"); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(-1); + if (ImGui::ColorEdit4("##LightColor", color)) + { + settings.Lighting.LightColor = Float4ToColor(color); + } + + DrawDragFloat("Ambient", &settings.Lighting.Ambient, 0.005f, 0.0f, 2.0f); + DrawDragFloat("Exposure", &settings.Lighting.Exposure, 0.01f, 0.0f, 10.0f); + DrawDragFloat("Gamma", &settings.Lighting.Gamma, 0.01f, 1.0f, 4.0f); + + if (readOnly) + { + ImGui::EndDisabled(); + } + ImGui::Unindent(10.0f); + ImGui::PopID(); + } + + if (ImGui::CollapsingHeader(ICON_FA_CLOUD " Skybox", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::PushID("Skybox"); + ImGui::Indent(10.0f); + if (readOnly) + { + ImGui::BeginDisabled(); + } + + const char* mapModes[] = {"Sphere", "Cross", "Cubemap", "Six Faces"}; + ImGui::AlignTextToFramePadding(); + ImGui::Text("Mapping"); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(-1); + ImGui::Combo("##MapMode", &settings.Skybox.Mode, mapModes, 4); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Sphere: Equirectangular\nCross: Horizontal Cross\nCubemap: GPU Generated\nSix " + "Faces: 6 Separate Images"); + } + + if (settings.Skybox.Mode == 3) + { + const char* faceLabels[] = {"Right (+X)", "Left (-X)", "Up (+Y)", + "Down (-Y)", "Front (+Z)", "Back (-Z)"}; + for (int i = 0; i < 6; ++i) + { + char buffer[256]; + snprintf(buffer, sizeof(buffer), "%s", settings.Skybox.CubeFaces[i].c_str()); + + ImGui::AlignTextToFramePadding(); + ImGui::Text("%s", faceLabels[i]); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 35); + std::string inputId = "##SkyFace" + std::to_string(i); + if (ImGui::InputText(inputId.c_str(), buffer, sizeof(buffer))) + { + settings.Skybox.CubeFaces[i] = buffer; + } + + ImGui::SameLine(); + std::string btnId = ICON_FA_FOLDER_OPEN "##SkyFaceBtn" + std::to_string(i); + if (ImGui::Button(btnId.c_str())) + { + std::vector filters = {{"Textures", "png,jpg,tga,bmp"}}; + auto result = Chained::Dialogs::OpenFile(filters); + if (result) + { + std::filesystem::path p = *result; + if (Project::GetActive()) + { + settings.Skybox.CubeFaces[i] = + std::filesystem::relative(p, Project::GetActive()->GetAssetDirectory()).string(); + } + else + { + settings.Skybox.CubeFaces[i] = p.filename().string(); + } + } + } + } + } + else + { + char buffer[256]; + snprintf(buffer, sizeof(buffer), "%s", settings.Skybox.TexturePath.c_str()); + + ImGui::AlignTextToFramePadding(); + ImGui::Text("Texture"); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 35); + if (ImGui::InputText("##SkyPath", buffer, sizeof(buffer))) + { + settings.Skybox.TexturePath = buffer; + } + + ImGui::SameLine(); + if (ImGui::Button(ICON_FA_FOLDER_OPEN "##SkySelect")) + { + std::vector filters = {{"Textures/HDR", "png,jpg,hdr"}}; + auto result = Chained::Dialogs::OpenFile(filters); + if (result) + { + std::filesystem::path p = *result; + if (Project::GetActive()) + { + settings.Skybox.TexturePath = + std::filesystem::relative(p, Project::GetActive()->GetAssetDirectory()).string(); + } + else + { + settings.Skybox.TexturePath = p.filename().string(); + } + } + } + } + + DrawDragFloat("Exposure", &settings.Skybox.Exposure, 0.01f, 0.0f, 10.0f); + DrawDragFloat("Brightness", &settings.Skybox.Brightness, 0.01f, -2.0f, 2.0f); + DrawDragFloat("Contrast", &settings.Skybox.Contrast, 0.01f, 0.0f, 5.0f); + + if (readOnly) + { + ImGui::EndDisabled(); + } + ImGui::Unindent(10.0f); + ImGui::PopID(); + } + + if (ImGui::CollapsingHeader(ICON_FA_SMOG " Fog Visibility", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::PushID("FogVisibility"); + ImGui::Indent(10.0f); + if (readOnly) + { + ImGui::BeginDisabled(); + } + + auto& fog = settings.Fog; + ImGui::AlignTextToFramePadding(); + ImGui::Text("Enabled"); + ImGui::SameLine(100); + ImGui::Checkbox("##FogEnabled", &fog.Enabled); + + float fogColor[4]; + ColorToFloat4(fog.FogColor, fogColor); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Color"); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(-1); + if (ImGui::ColorEdit4("##FogColor", fogColor)) + { + fog.FogColor = Float4ToColor(fogColor); + } + + const char* fogModes[] = {"Linear", "Exponential", "Exponential Squared"}; + ImGui::AlignTextToFramePadding(); + ImGui::Text("Mode"); + ImGui::SameLine(100); + ImGui::SetNextItemWidth(-1); + ImGui::Combo("##FogMode", &fog.Mode, fogModes, 3); + + DrawDragFloat("Density", &fog.Density, 0.0001f, 0.0f, 10.f, "%.4f"); + DrawDragFloat("Start", &fog.Start, 1.0f, 0.0f, 10000.0f); + DrawDragFloat("End", &fog.End, 1.0f, 0.0f, 10000.0f); + DrawDragFloat("Height Falloff", &fog.HeightFalloff, 0.01f, 0.0f, 1.0f, "%.2f"); + + if (readOnly) + { + ImGui::EndDisabled(); + } + ImGui::Unindent(10.0f); + ImGui::PopID(); + } + } + +} // namespace Chained diff --git a/editor/panels/world_panel.h b/editor/panels/world_panel.h index 892f4be1e..b9de53071 100644 --- a/editor/panels/world_panel.h +++ b/editor/panels/world_panel.h @@ -3,19 +3,23 @@ #include "panel.h" -namespace CHEngine +namespace Chained { -class WorldPanel : public Panel -{ -public: - WorldPanel(); + class WorldPanel : public Panel + { + public: + WorldPanel(); -public: - virtual void OnImGuiRender(bool readOnly = false) override; + public: + virtual void OnImGuiRender(bool readOnly = false) override; -private: - void DrawEnvironmentSettings(std::shared_ptr env, bool readOnly); -}; -} // namespace CHEngine + private: + void DrawSceneGeneral(bool readOnly); + void DrawSceneBackground(bool readOnly); + void DrawPhysicsSettings(bool readOnly); + void DrawEnvironmentSection(bool readOnly); + void DrawEnvironmentSettings(std::shared_ptr env, bool readOnly); + }; +} // namespace Chained #endif // CH_WORLD_PANEL_H diff --git a/editor/project/editor_settings.h b/editor/project/editor_settings.h new file mode 100644 index 000000000..dd605883a --- /dev/null +++ b/editor/project/editor_settings.h @@ -0,0 +1,55 @@ +#ifndef CH_EDITOR_SETTINGS_H +#define CH_EDITOR_SETTINGS_H + +#include +#include + +namespace Chained +{ + struct EditorConfig + { + std::string LastProjectPath; + std::string LastScenePath; + bool LoadLastProjectOnStartup = false; + bool AutoSaveEnabled = true; + float AutoSaveInterval = 300.0f; + std::vector RecentProjects; + + // --- Appearance: editor UI font (rebuilt live via EditorLayer::ReloadEditorFonts) --- + // FontPath is relative to the engine resources root, prefixed with "engine/" + // (e.g. "engine/resources/font/lato/lato-bold.ttf"). Empty means "use the built-in default". + std::string FontPath = "engine/resources/font/lato/lato-bold.ttf"; + float FontSize = 16.0f; + + // --- Viewport gizmo icons (camera/light/spawn/audio billboards) --- + // Screen-constant sizing: size = clamp(distanceToCamera * IconSizeScale, IconSizeMin, IconSizeMax). + float IconSizeScale = 0.12f; + float IconSizeMin = 1.2f; + float IconSizeMax = 8.0f; + + // --- Editor camera (edit mode). Global, not per-project. --- + float CameraMoveSpeed = 10.0f; + float CameraBoostMultiplier = 5.0f; + bool DisableCameraZoom = false; + float CameraRotationSpeed = 1.0f; + float CameraZoomSpeedMultiplier = 1.0f; + float CameraFovDegrees = 45.0f; + float CameraNearClip = 0.1f; + float CameraFarClip = 10000.0f; + + // --- Viewport --- + bool ShowEditorIcons = true; + float GizmoScale = 1.0f; + + // --- Content Browser --- + float DefaultThumbnailSize = 96.0f; + int DefaultSortOrder = 0; + bool ShowFileExtensions = true; + + // --- General --- + bool ConfirmOnSceneClose = true; + int MaxRecentProjects = 10; + }; +} // namespace Chained + +#endif // CH_EDITOR_SETTINGS_H diff --git a/editor/project/project_exporter.cpp b/editor/project/project_exporter.cpp new file mode 100644 index 000000000..c572ac6a0 --- /dev/null +++ b/editor/project/project_exporter.cpp @@ -0,0 +1,698 @@ +#include "project_exporter.h" + +#include "engine/core/log.h" +#include "engine/core/platform.h" +#include "engine/project/project.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace Chained +{ + + struct ExportCancelledException : public std::exception + { + const char* what() const noexcept override + { + return "Export cancelled by user."; + } + }; + + void ProjectExporter::CollectFiles(const fs::path& dir, std::vector& out) + { + std::error_code ec; + fs::recursive_directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec); + if (ec) + { + CH_CORE_ERROR("ProjectExporter: Cannot iterate '{}': {}", dir.string(), ec.message()); + return; + } + for (const auto& entry : it) + { + if (!entry.is_regular_file()) + { + continue; + } + + // Skip 0-byte files (pack reader rejects dataSize == 0) + std::error_code szEc; + if (entry.file_size(szEc) == 0) + { + continue; + } + + // Skip build artifacts that shouldn't be packed + const std::string filename = entry.path().filename().string(); + const std::string ext = entry.path().extension().string(); + + // Skip .Up2Date marker files, .pdb, .ilk, .obj, .tlog, .log build intermediates + if (ext == ".pdb" || ext == ".ilk" || ext == ".obj" || ext == ".tlog" || ext == ".log" || + ext == ".Up2Date" || ext == ".FileListAbsolute.txt" || ext == ".lastbuildstate" || ext == ".cache" || + ext == ".nupkg" || ext == ".nuget.g.props" || ext == ".nuget.g.targets") + { + continue; + } + + // Skip obj/ and Debug/ and Release/ build output directories + const auto rel = fs::relative(entry.path(), dir, ec); + if (!ec) + { + std::string relStr = rel.string(); + // Normalize path separators to forward slash for comparison + std::replace(relStr.begin(), relStr.end(), '\\', '/'); + if (relStr.find("/obj/") != std::string::npos || relStr.find("/Debug/") != std::string::npos || + relStr.find("/Release/") != std::string::npos || relStr.find("/x64/") != std::string::npos) + { + continue; + } + out.push_back(rel); + } + } + } + + bool ProjectExporter::CopyFile(const fs::path& src, const fs::path& dst, std::string& outError) + { + std::error_code ec; + fs::create_directories(dst.parent_path(), ec); + if (ec) + { + outError = "Failed to create directory '" + dst.parent_path().string() + "': " + ec.message(); + return false; + } + fs::copy_file(src, dst, fs::copy_options::update_existing, ec); + if (ec) + { + outError = "Failed to copy '" + src.string() + "': " + ec.message(); + return false; + } + return true; + } + + bool ProjectExporter::IsPackStale(const fs::path& packPath, const std::vector& fileItemPaths, + uint64_t expectedItemCount) + { + std::error_code ec; + if (!fs::exists(packPath, ec)) + { + return true; + } + + // A differing item count means files were added or removed, which mtime alone cannot detect. + try + { + pack::Reader reader(packPath); + if (reader.getItemCount() != expectedItemCount) + { + return true; + } + } catch (...) + { + return true; + } + + const auto packTime = fs::last_write_time(packPath, ec); + if (ec) + { + return true; + } + + for (size_t i = 0; i < fileItemPaths.size(); i += 2) + { + std::error_code srcEc; + const auto srcTime = fs::last_write_time(fs::path(fileItemPaths[i]), srcEc); + if (srcEc || srcTime > packTime) + { + return true; + } + } + + return false; + } + + ExportResult ProjectExporter::ExportTo(const fs::path& outputDir, ExportProgressCallback onProgress, + const std::atomic* cancelFlag, bool forceRepack) + { + ExportResult result; + result.OutDir = outputDir; + + const bool outputExisted = fs::exists(outputDir); + // packPath is resolved once we've read the project config (after project is loaded) + // For CleanupAndCancel we just wipe all .pack files in outputDir + fs::path packPath; // set after project config is read + + // Only remove the output directory when this run created it — otherwise a cancel + // would destroy a previous working export, including the pack we tried to preserve. + auto CleanupAndCancel = [&](const std::string& phaseLog) -> ExportResult { + if (!outputExisted) + { + std::error_code cleanEc; + fs::remove_all(outputDir, cleanEc); + } + else + { + // Remove potentially incomplete/corrupted pack files so next export succeeds + std::error_code cleanEc; + for (const auto& entry : fs::directory_iterator(outputDir, cleanEc)) + { + if (entry.is_regular_file() && entry.path().extension() == ".pack") + { + fs::remove(entry.path(), cleanEc); + } + } + } + result.Cancelled = true; + CH_CORE_INFO("ProjectExporter: Cancelled {}. {}", phaseLog, + outputExisted ? "Kept existing output directory." : "Cleaned output directory."); + return result; + }; + + auto project = Project::GetActive(); + if (!project) + { + result.Error = "No active project. Please open a project before exporting."; + CH_CORE_ERROR("ProjectExporter: {}", result.Error); + return result; + } + + const auto& cfg = project->GetConfig(); + const fs::path projectDir = project->GetConfig().ProjectDirectory; + const fs::path assetDir = project->GetConfig().ProjectDirectory / project->GetConfig().AssetDirectory; + const fs::path exeDir = Platform::GetExecutableDirectory(); +#ifdef CH_BUILD_PRESET + const std::string buildPreset = CH_BUILD_PRESET; +#else + const std::string buildPreset = ""; +#endif +#ifdef CH_BUILD_CONFIG + const std::string buildConfig = CH_BUILD_CONFIG; +#else + const std::string buildConfig = exeDir.string().find("Debug") != std::string::npos ? "Debug" : "Release"; +#endif + const bool isDebugExport = (buildConfig == "Debug"); + CH_CORE_INFO("ProjectExporter: Build preset='{}', config='{}', exeDir='{}'", buildPreset, buildConfig, + exeDir.string()); + + // ── 0. Prepare output directory ────────────────────────────────────────── + std::error_code ec; + fs::create_directories(outputDir, ec); + if (ec) + { + result.Error = "Could not create output directory: " + ec.message(); + CH_CORE_ERROR("ProjectExporter: {}", result.Error); + return result; + } + + // ── 1. Find .chproject file ────────────────────────────────────────────── + fs::path chprojectFile; + for (const auto& entry : fs::directory_iterator(projectDir, ec)) + { + if (entry.is_regular_file() && entry.path().extension() == ".chproject") + { + chprojectFile = entry.path(); + break; + } + } + if (chprojectFile.empty()) + { + result.Error = "No .chproject file found in '" + projectDir.string() + "'"; + CH_CORE_ERROR("ProjectExporter: {}", result.Error); + return result; + } + + // ── 2. Collect files to pack ───────────────────────────────────────────── + std::vector fileItemPaths; + + // .chproject → "project.chproject" + fileItemPaths.push_back(chprojectFile.generic_string()); + fileItemPaths.push_back("project.chproject"); + + // assets/ → "assets/..." + if (fs::exists(assetDir)) + { + std::vector assetFiles; + CollectFiles(assetDir, assetFiles); + for (const auto& rel : assetFiles) + { + fileItemPaths.push_back((assetDir / rel).generic_string()); + fileItemPaths.push_back((fs::path("assets") / rel).generic_string()); + } + } + + // resources/ → "resources/..." + fs::path resourcesDir = exeDir / "resources"; + if (fs::exists(resourcesDir)) + { + std::vector resFiles; + CollectFiles(resourcesDir, resFiles); + for (const auto& rel : resFiles) + { + fileItemPaths.push_back((resourcesDir / rel).generic_string()); + fileItemPaths.push_back((fs::path("resources") / rel).generic_string()); + } + } + else + { + CH_CORE_WARN("ProjectExporter: resources/ not found at '{}' — shaders/fonts may be missing from pack.", + resourcesDir.string()); + } + + const auto& exp = project->GetConfig().Export; + const bool isRawMode = (exp.Mode == PackMode::Raw); + + // Resolve pack base path from PackName setting (default: "resources") + const std::string packBaseName = exp.PackName.empty() ? "resources" : exp.PackName; + packPath = outputDir / (packBaseName + ".pack"); + + if (fileItemPaths.size() <= 2) + { + result.Error = isRawMode ? "No files to copy (no assets or resources found)." + : "No files to pack (no assets or resources found)."; + CH_CORE_ERROR("ProjectExporter: {}", result.Error); + return result; + } + + if (cancelFlag && cancelFlag->load(std::memory_order_relaxed)) + { + return CleanupAndCancel("before start"); + } + + uint64_t fileCount = fileItemPaths.size() / 2; + result.PackedFileCount = fileCount; + + // ── 3. Calculate uncompressed size ─────────────────────────────────────── + result.TotalUncompressedSize = 0; + for (size_t i = 0; i < fileItemPaths.size(); i += 2) + { + std::error_code sizeEc; + auto sz = fs::file_size(fs::path(fileItemPaths[i]), sizeEc); + if (!sizeEc) + { + result.TotalUncompressedSize += sz; + } + } + + // ── 4. PARALLEL EXECUTION: Task A (Packing) & Task B (Copying Binaries) ─ + + // --- TASK B: Копіювання EXE, DLL та додаткових папок у фоновому потоці --- + auto copyBinariesTask = std::async(std::launch::async, [&]() -> bool { + // 1. Copy Executable + std::vector exeCandidates; + std::error_code dirEc; + for (const auto& f : fs::directory_iterator(exeDir, dirEc)) + { + if (cancelFlag && cancelFlag->load(std::memory_order_relaxed)) + { + return false; + } + if (!f.is_regular_file()) + { + continue; + } + + std::string fname = f.path().filename().string(); + if (fname.find("Editor") != std::string::npos || fname.find("editor") != std::string::npos || + fname.find("test") != std::string::npos) + { + continue; + } + if (fname.ends_with(".exe") || (fname.find('.') == std::string::npos && !fname.empty())) + { + exeCandidates.push_back(f.path()); + } + } + + if (!exeCandidates.empty()) + { + const fs::path& srcExe = exeCandidates[0]; + std::string exeName = cfg.Name + srcExe.extension().string(); + fs::path dstExe = outputDir / exeName; + std::string copyErr; + if (!CopyFile(srcExe, dstExe, copyErr)) + { + CH_CORE_ERROR("ProjectExporter: Failed to copy executable: {}", copyErr); + return false; + } + } + + // 2. Copy DLLs — only from the current build config + // Always skip MSVC CRT DLLs (wrong toolchain) and build-time tools. + // In Debug: skip release DLLs (no 'd' suffix). In Release: skip debug DLLs ('d' suffix). + for (const auto& f : fs::directory_iterator(exeDir, dirEc)) + { + if (cancelFlag && cancelFlag->load(std::memory_order_relaxed)) + { + return false; + } + if (!f.is_regular_file()) + { + continue; + } + + const std::string ext = f.path().extension().string(); + if (ext != ".dll" && ext != ".so" && ext != ".dylib") + { + continue; + } + + const std::string fname = f.path().filename().string(); + + // Always skip MSVC CRT DLLs (e.g. assimp-vc145-mtd.dll) — wrong toolchain + if (fname.find("-vc") != std::string::npos) + { + continue; + } + + // Skip build-time generator (not needed at runtime) + if (fname.find("Generator") != std::string::npos) + { + continue; + } + + // Check if this DLL has a debug suffix (e.g. "assimpd.dll" → 'd' before ".dll") + bool isDebugDll = fname.size() > 4 && fname[fname.size() - 5] == 'd' && fname[fname.size() - 4] == '.'; + + // In Debug, skip release DLLs. In Release, skip debug DLLs. + if (isDebugExport && !isDebugDll) + { + continue; // Debug build — skip release DLLs + } + if (!isDebugExport && isDebugDll) + { + continue; // Release build — skip debug DLLs + } + + std::string copyErr; + CopyFile(f.path(), outputDir / f.path().filename(), copyErr); + } + + // 3. Copy Subdirectories (skip Generator DLLs in scripts/) + for (const std::string& subDirName : {"nethost", "dotnet", "scripts"}) + { + if (cancelFlag && cancelFlag->load(std::memory_order_relaxed)) + { + return false; + } + + fs::path subSrc = exeDir / subDirName; + if (fs::exists(subSrc)) + { + fs::path subDst = outputDir / subDirName; + std::error_code subEc; + fs::copy(subSrc, subDst, fs::copy_options::update_existing | fs::copy_options::recursive, subEc); + + // Remove Generator DLLs that were copied recursively + for (const auto& entry : fs::recursive_directory_iterator(subDst, subEc)) + { + if (entry.is_regular_file()) + { + const std::string fname = entry.path().filename().string(); + if (fname.find("Generator") != std::string::npos) + { + fs::remove(entry.path(), subEc); + } + } + } + } + } + + // 4. Copy Coral runtime config files to root (needed by ScriptEngine) + // Coral files are at exeDir/ (next to the exe), not in scripts/{Name}/ + fs::path coralManagedDir = exeDir; + const char* coralConfigs[] = {"Coral.Managed.runtimeconfig.json", "Coral.Managed.deps.json", + "Coral.Managed.pdb"}; + for (const auto& name : coralConfigs) + { + fs::path src = coralManagedDir / name; + if (fs::exists(src)) + { + std::string copyErr; + if (!CopyFile(src, outputDir / name, copyErr)) + { + CH_CORE_ERROR("ProjectExporter: Failed to copy Coral file '{}': {}", name, copyErr); + } + } + else + { + CH_CORE_WARN("ProjectExporter: Coral file '{}' not found in '{}'", name, coralManagedDir.string()); + } + } + + return true; + }); + + // --- TASK A: Пакування або копіювання ресурсів у головному потоці --- + bool packSuccess = false; + + if (isRawMode) + { + // Raw mode: copy files directly into outputDir, preserving assets/ and resources/ structure. + // resources.pack is not created; the runtime falls back to the filesystem automatically. + CH_CORE_INFO("ProjectExporter: Raw mode — copying {} files to '{}'.", fileCount, outputDir.string()); + + // Copy .chproject with original name so runtime can discover it by {AppName}.chproject + auto chProjFile = project->GetConfig().ProjectDirectory / (cfg.Name + ".chproject"); + if (!fs::exists(chProjFile)) + { + for (const auto& entry : fs::directory_iterator(project->GetConfig().ProjectDirectory)) + { + if (entry.is_regular_file() && entry.path().extension() == ".chproject") + { + chProjFile = entry.path(); + break; + } + } + } + if (fs::exists(chProjFile)) + { + std::string copyErr; + if (!CopyFile(chProjFile, outputDir / (cfg.Name + ".chproject"), copyErr)) + { + CH_CORE_ERROR("ProjectExporter: Failed to copy .chproject: {}", copyErr); + } + } + + for (size_t i = 0; i < fileItemPaths.size(); i += 2) + { + if (cancelFlag && cancelFlag->load(std::memory_order_relaxed)) + { + copyBinariesTask.wait(); + return CleanupAndCancel("during raw copy"); + } + + const fs::path srcFile(fileItemPaths[i]); + const fs::path dstFile = outputDir / fileItemPaths[i + 1]; + std::string copyErr; + if (!CopyFile(srcFile, dstFile, copyErr)) + { + CH_CORE_ERROR("ProjectExporter: Raw copy failed: {}", copyErr); + copyBinariesTask.wait(); + CleanupAndCancel("due to raw copy error"); + result.Error = copyErr; + return result; + } + + if (onProgress) + { + onProgress((i / 2) + 1, fileCount, fileItemPaths[i + 1]); + } + } + result.PackSkipped = true; // no .pack file produced + result.PackFileSize = 0; + packSuccess = true; + } + else if (!forceRepack && !IsPackStale(packPath, fileItemPaths, fileCount)) + { + CH_CORE_INFO("ProjectExporter: {}.pack is up to date ({} items) — skipping repack.", packBaseName, + fileCount); + result.PackSkipped = true; + packSuccess = true; + } + else + { + bool preferSpeed = (exp.Mode == PackMode::Fast); + float threshold = (exp.Mode == PackMode::Max) ? 0.0f : exp.ZipThreshold; + + // --- Build chunks ------------------------------------------------------- + // Each entry in fileItemPaths is: [srcPath, packKey, srcPath, packKey, ...] + // We split by uncompressed (on-disk) file size per chunk. + // SplitSizeMB == 0 → one chunk containing all files. + struct Chunk + { + std::vector items; // flat [srcPath, packKey, ...] + uint64_t itemCount = 0; + }; + + std::vector chunks; + { + const uint64_t limitBytes = + exp.SplitSizeMB > 0 ? static_cast(exp.SplitSizeMB) * 1024 * 1024 : UINT64_MAX; + + chunks.emplace_back(); + uint64_t chunkBytes = 0; + + for (size_t i = 0; i < fileItemPaths.size(); i += 2) + { + const std::string& srcPath = fileItemPaths[i]; + std::error_code sizeEc; + uint64_t fileBytes = static_cast(fs::file_size(srcPath, sizeEc)); + if (sizeEc) + { + fileBytes = 0; + } + + // Start a new chunk if this file would push the current chunk over the limit + // (always put at least one file per chunk to avoid infinite loops) + if (chunkBytes > 0 && chunkBytes + fileBytes > limitBytes) + { + chunks.emplace_back(); + chunkBytes = 0; + } + + chunks.back().items.push_back(fileItemPaths[i]); + chunks.back().items.push_back(fileItemPaths[i + 1]); + ++chunks.back().itemCount; + chunkBytes += fileBytes; + } + } + + // Track the global item offset for progress reporting across all chunks + uint64_t globalItemOffset = 0; + + struct PackCtx + { + const std::vector& chunkItems; // items for this chunk + uint64_t chunkOffset; // first item index in global list + uint64_t totalItems; // total items across all chunks + ExportProgressCallback& cb; + const std::atomic* cancelFlag; + }; + + try + { + for (size_t chunkIdx = 0; chunkIdx < chunks.size(); ++chunkIdx) + { + const Chunk& chunk = chunks[chunkIdx]; + if (chunk.itemCount == 0) + { + continue; + } + + // Build pack path: {name}.pack, {name}_1.pack, {name}_2.pack ... + fs::path chunkPackPath; + if (chunkIdx == 0) + { + chunkPackPath = packPath; // == outputDir / "{packBaseName}.pack" + } + else + { + chunkPackPath = outputDir / (packBaseName + "_" + std::to_string(chunkIdx) + ".pack"); + } + + std::vector rawPaths; + rawPaths.reserve(chunk.items.size()); + for (const auto& s : chunk.items) + { + rawPaths.push_back(s.c_str()); + } + + PackCtx ctx{chunk.items, globalItemOffset, fileCount, onProgress, cancelFlag}; + + OnPackFile cCallback = [](uint64_t itemIndex, void* arg) { + auto* ctx = static_cast(arg); + + if (ctx->cancelFlag && ctx->cancelFlag->load(std::memory_order_relaxed)) + { + throw ExportCancelledException(); + } + + if (ctx->cb) + { + uint64_t globalPacked = ctx->chunkOffset + itemIndex + 1; + const std::string& itemPath = ctx->chunkItems[itemIndex * 2 + 1]; + ctx->cb(globalPacked, ctx->totalItems, itemPath); + } + }; + + CH_CORE_INFO("ProjectExporter: Packing chunk {}/{} → '{}' ({} items)", chunkIdx + 1, chunks.size(), + chunkPackPath.filename().string(), chunk.itemCount); + + pack::Writer::pack(chunkPackPath, chunk.itemCount, rawPaths.data(), exp.DataVersion, threshold, + preferSpeed, false, cCallback, &ctx); + + globalItemOffset += chunk.itemCount; + } + + packSuccess = true; + + if (chunks.size() > 1) + { + CH_CORE_INFO("ProjectExporter: Created {} pack chunks.", chunks.size()); + } + } catch (const ExportCancelledException&) + { + copyBinariesTask.wait(); + return CleanupAndCancel("during packing process"); + } catch (const pack::Error& err) + { + copyBinariesTask.wait(); + CleanupAndCancel("due to pack error"); + result.Error = "Pack failed: " + std::string(err.what()); + CH_CORE_ERROR("ProjectExporter: {}", result.Error); + return result; + } + } + + // Чекаємо завершення копіювання файлів + bool copySuccess = copyBinariesTask.get(); + + // Перевірка на скасування після обох завдань + if (cancelFlag && cancelFlag->load(std::memory_order_relaxed)) + { + return CleanupAndCancel("after execution tasks"); + } + + if (!copySuccess || !packSuccess) + { + return CleanupAndCancel("due to task failure"); + } + + // ── 5. Get Pack File Size ──────────────────────────────────────────────── + if (!isRawMode) + { + // Sum up all .pack files in outputDir + std::error_code sizeEc; + for (const auto& entry : fs::directory_iterator(outputDir, sizeEc)) + { + if (entry.is_regular_file() && entry.path().extension() == ".pack") + { + result.PackFileSize += static_cast(fs::file_size(entry.path(), sizeEc)); + } + } + } + + result.Success = true; + if (isRawMode) + { + CH_CORE_INFO("ProjectExporter: Export complete (Raw) → '{}' ({} files copied)", outputDir.string(), + result.PackedFileCount); + } + else + { + CH_CORE_INFO("ProjectExporter: Export complete → '{}' ({} {}, {} MB pack)", outputDir.string(), + result.PackedFileCount, result.PackSkipped ? "reused" : "packed", + result.PackFileSize / (1024 * 1024)); + } + return result; + } + +} // namespace Chained \ No newline at end of file diff --git a/editor/project/project_exporter.h b/editor/project/project_exporter.h new file mode 100644 index 000000000..4839c7e06 --- /dev/null +++ b/editor/project/project_exporter.h @@ -0,0 +1,58 @@ +#ifndef CH_PROJECT_EXPORTER_H +#define CH_PROJECT_EXPORTER_H + +#include +#include +#include +#include +#include + +namespace Chained +{ + + struct ExportResult + { + bool Success = false; + bool Cancelled = false; + std::string Error; + std::filesystem::path OutDir; + std::string File; + uint64_t PackedFileCount = 0; + uint64_t TotalUncompressedSize = 0; + uint64_t PackFileSize = 0; + bool PackSkipped = false; + }; + + /// @brief Callback invoked after each file is packed. + /// @param packed Number of files packed so far (1-based). + /// @param total Total number of files to pack. + /// @param file Virtual item path of the just-packed file. + using ExportProgressCallback = std::function; + + class ProjectExporter + { + public: + /// @brief Export the active project to @p outputDir. + /// @param outputDir Path to the target folder (will be created if missing). + /// @param onProgress Optional callback invoked after each file is packed. + /// @param cancelFlag If non-null, export is aborted between phases when set to true. + /// @param forceRepack Repack resources.pack even if it is up to date. + /// @return ExportResult with success flag and optional error message. + static ExportResult ExportTo(const std::filesystem::path& outputDir, + ExportProgressCallback onProgress = nullptr, + const std::atomic* cancelFlag = nullptr, bool forceRepack = false); + + private: + /// @brief Recursively collect files in @p dir, appending relative paths to @p out. + static void CollectFiles(const std::filesystem::path& dir, std::vector& out); + + /// @brief True when @p packPath predates any source file, or covers a different file count. + static bool IsPackStale(const std::filesystem::path& packPath, const std::vector& fileItemPaths, + uint64_t expectedItemCount); + + /// @brief Copy a single file from @p src to @p dst. + static bool CopyFile(const std::filesystem::path& src, const std::filesystem::path& dst, std::string& outError); + }; + +} // namespace Chained +#endif // CH_PROJECT_EXPORTER_H \ No newline at end of file diff --git a/editor/project/project_serializer.cpp b/editor/project/project_serializer.cpp new file mode 100644 index 000000000..83e218ba4 --- /dev/null +++ b/editor/project/project_serializer.cpp @@ -0,0 +1,233 @@ +#include "project_serializer.h" +#include "engine/core/log.h" +#include "engine/scene/serialization.h" +#include +#include + +namespace Chained +{ + using namespace Serialization; + + bool EditorProjectSerializer::Serialize(const std::shared_ptr& project, + const std::filesystem::path& filepath) + { + const auto& config = project->GetConfig(); + + YAML::Emitter out; + out << YAML::BeginMap; + out << YAML::Key << "Project" << YAML::Value; + { + out << YAML::BeginMap; + out << YAML::Key << "Name" << YAML::Value << config.Name; + out << YAML::Key << "IconPath" << YAML::Value << config.IconPath; + out << YAML::Key << "StartScene" << YAML::Value << config.StartScene; + out << YAML::Key << "AssetDirectory" << YAML::Value << config.AssetDirectory.string(); + + SerializePath(out, "ActiveScene", config.ActiveScenePath.string()); + + out << YAML::Key << "Physics" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "Gravity" << YAML::Value << config.Physics.Gravity; + out << YAML::Key << "FixedTimestep" << YAML::Value << config.Physics.FixedTimestep; + out << YAML::EndMap; + + out << YAML::Key << "Animation" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "TargetFPS" << YAML::Value << config.Animation.TargetFPS; + out << YAML::EndMap; + + out << YAML::Key << "Render" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "ShadowResolution" << YAML::Value << config.Render.ShadowResolution; + out << YAML::Key << "EnableShadows" << YAML::Value << config.Render.EnableShadows; + out << YAML::Key << "AntiAliasingSamples" << YAML::Value << config.Render.AntiAliasingSamples; + out << YAML::EndMap; + + out << YAML::Key << "Mesh" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "ImportMaterials" << YAML::Value << config.Mesh.ImportMaterials; + out << YAML::Key << "CalculateTangents" << YAML::Value << config.Mesh.CalculateTangents; + out << YAML::Key << "FlipUVs" << YAML::Value << config.Mesh.FlipUVs; + out << YAML::EndMap; + + out << YAML::Key << "Window" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "Width" << YAML::Value << config.Window.Width; + out << YAML::Key << "Height" << YAML::Value << config.Window.Height; + out << YAML::Key << "VSync" << YAML::Value << config.Window.VSync; + out << YAML::EndMap; + + out << YAML::Key << "Audio" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "MasterVolume" << YAML::Value << config.Audio.MasterVolume; + out << YAML::Key << "MusicVolume" << YAML::Value << config.Audio.MusicVolume; + out << YAML::Key << "SFXVolume" << YAML::Value << config.Audio.SFXVolume; + out << YAML::EndMap; + + out << YAML::Key << "Runtime" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "Fullscreen" << YAML::Value << config.Runtime.Fullscreen; + out << YAML::Key << "ShowStats" << YAML::Value << config.Runtime.ShowStats; + out << YAML::Key << "EnableConsole" << YAML::Value << config.Runtime.EnableConsole; + out << YAML::Key << "TargetFPS" << YAML::Value << config.Runtime.TargetFPS; + out << YAML::EndMap; + + out << YAML::Key << "Scripting" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "ModuleName" << YAML::Value << config.Scripting.ModuleName; + out << YAML::Key << "ModuleDirectory" << YAML::Value << config.Scripting.ModuleDirectory.string(); + out << YAML::Key << "AutoLoad" << YAML::Value << config.Scripting.AutoLoad; + out << YAML::EndMap; + + out << YAML::Key << "Export" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "Mode" << YAML::Value << static_cast(config.Export.Mode); + out << YAML::Key << "ZipThreshold" << YAML::Value << config.Export.ZipThreshold; + out << YAML::Key << "DataVersion" << YAML::Value << config.Export.DataVersion; + out << YAML::Key << "SplitSizeMB" << YAML::Value << config.Export.SplitSizeMB; + out << YAML::Key << "PackName" << YAML::Value << config.Export.PackName; + out << YAML::EndMap; + + out << YAML::Key << "BuildConfig" << YAML::Value << static_cast(config.BuildConfig); + + out << YAML::EndMap; + } + out << YAML::EndMap; + + std::ofstream fout(filepath); + if (!fout.is_open()) + { + CH_CORE_ERROR("Failed to save project file: {}", filepath.string()); + return false; + } + + fout << out.c_str(); + if (fout.fail()) + { + CH_CORE_ERROR("Failed to write project file: {}", filepath.string()); + return false; + } + + return true; + } + + bool EditorProjectSerializer::Deserialize(const std::shared_ptr& project, + const std::filesystem::path& filepath) + { + std::ifstream stream(filepath); + if (!stream.is_open()) + { + CH_CORE_ERROR("Failed to open project file: {}", filepath.string()); + return false; + } + + YAML::Node data; + try + { + data = YAML::Load(stream); + } catch (const YAML::Exception& e) + { + CH_CORE_ERROR("Failed to parse project file: {} ({})", filepath.string(), e.what()); + return false; + } + + auto projectNode = data["Project"]; + if (!projectNode) + { + CH_CORE_ERROR("Missing 'Project' root node in: {}", filepath.string()); + return false; + } + + auto& config = project->GetConfig(); + + DeserializeProperty(projectNode, "Name", config.Name); + DeserializeProperty(projectNode, "IconPath", config.IconPath); + DeserializeProperty(projectNode, "StartScene", config.StartScene); + DeserializePath(projectNode, "AssetDirectory", config.AssetDirectory); + + DeserializePath(projectNode, "ActiveScene", config.ActiveScenePath); + + if (auto physics = projectNode["Physics"]) + { + DeserializeProperty(physics, "Gravity", config.Physics.Gravity); + DeserializeProperty(physics, "FixedTimestep", config.Physics.FixedTimestep); + } + + if (auto anim = projectNode["Animation"]) + { + DeserializeProperty(anim, "TargetFPS", config.Animation.TargetFPS); + } + + if (auto render = projectNode["Render"]) + { + DeserializeProperty(render, "ShadowResolution", config.Render.ShadowResolution); + DeserializeProperty(render, "EnableShadows", config.Render.EnableShadows); + DeserializeProperty(render, "AntiAliasingSamples", config.Render.AntiAliasingSamples); + } + + if (auto mesh = projectNode["Mesh"]) + { + DeserializeProperty(mesh, "ImportMaterials", config.Mesh.ImportMaterials); + DeserializeProperty(mesh, "CalculateTangents", config.Mesh.CalculateTangents); + DeserializeProperty(mesh, "FlipUVs", config.Mesh.FlipUVs); + } + + if (auto window = projectNode["Window"]) + { + DeserializeProperty(window, "Width", config.Window.Width); + DeserializeProperty(window, "Height", config.Window.Height); + DeserializeProperty(window, "VSync", config.Window.VSync); + } + + if (auto audio = projectNode["Audio"]) + { + DeserializeProperty(audio, "MasterVolume", config.Audio.MasterVolume); + DeserializeProperty(audio, "MusicVolume", config.Audio.MusicVolume); + DeserializeProperty(audio, "SFXVolume", config.Audio.SFXVolume); + } + + if (auto runtime = projectNode["Runtime"]) + { + DeserializeProperty(runtime, "Fullscreen", config.Runtime.Fullscreen); + DeserializeProperty(runtime, "ShowStats", config.Runtime.ShowStats); + DeserializeProperty(runtime, "EnableConsole", config.Runtime.EnableConsole); + DeserializeProperty(runtime, "TargetFPS", config.Runtime.TargetFPS); + } + + if (auto scripting = projectNode["Scripting"]) + { + DeserializeProperty(scripting, "ModuleName", config.Scripting.ModuleName); + DeserializeProperty(scripting, "AutoLoad", config.Scripting.AutoLoad); + + std::string moduleDir; + DeserializeProperty(scripting, "ModuleDirectory", moduleDir); + if (!moduleDir.empty()) + { + config.Scripting.ModuleDirectory = moduleDir; + } + } + + if (auto exportNode = projectNode["Export"]) + { + if (exportNode["Mode"]) + { + int mode = 0; + DeserializeProperty(exportNode, "Mode", mode); + config.Export.Mode = static_cast(mode); + } + else if (exportNode["PreferSpeed"]) + { + bool preferSpeed = false; + DeserializeProperty(exportNode, "PreferSpeed", preferSpeed); + config.Export.Mode = preferSpeed ? PackMode::Fast : PackMode::Balanced; + } + DeserializeProperty(exportNode, "ZipThreshold", config.Export.ZipThreshold); + DeserializeProperty(exportNode, "DataVersion", config.Export.DataVersion); + DeserializeProperty(exportNode, "SplitSizeMB", config.Export.SplitSizeMB); + DeserializeProperty(exportNode, "PackName", config.Export.PackName); + if (config.Export.PackName.empty()) + { + config.Export.PackName = "resources"; + } + } + + int buildConfig = static_cast(config.BuildConfig); + DeserializeProperty(projectNode, "BuildConfig", buildConfig); + config.BuildConfig = static_cast(buildConfig); + + config.ProjectDirectory = filepath.parent_path(); + + return true; + } +} // namespace Chained diff --git a/editor/project/project_serializer.h b/editor/project/project_serializer.h new file mode 100644 index 000000000..dee614715 --- /dev/null +++ b/editor/project/project_serializer.h @@ -0,0 +1,18 @@ +#ifndef CH_PROJECT_SERIALIZER_H +#define CH_PROJECT_SERIALIZER_H + +#include "engine/project/project.h" +#include +#include + +namespace Chained +{ + class EditorProjectSerializer + { + public: + static bool Serialize(const std::shared_ptr& project, const std::filesystem::path& filepath); + static bool Deserialize(const std::shared_ptr& project, const std::filesystem::path& filepath); + }; +} // namespace Chained + +#endif // CH_PROJECT_SERIALIZER_H diff --git a/editor/project_manager.cpp b/editor/project_manager.cpp new file mode 100644 index 000000000..419446e0e --- /dev/null +++ b/editor/project_manager.cpp @@ -0,0 +1,695 @@ +#include "engine/platform/dialogs/dialogs.h" +#include "engine/core/service_locator.h" +#include "project_manager.h" +#include "layer.h" +#include "engine/project/project.h" +#include "project/project_serializer.h" +#include "engine/graphics/pipeline/renderer.h" +#include "engine/ui/ui_font_registry.h" +#include "engine/ui/widget_renderer.h" +#include "engine/scene/scene_events.h" +#include "engine/scripting/scriptengine.h" +#include "engine/assets/asset_manager.h" +#include "engine/imgui/imgui_layer.h" +#include +#include +#include +#include +#include +#include "engine/scene/scene_serializer.h" +#include "engine/core/profiler.h" + +#if CH_PLATFORM_WINDOWS +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#endif + +namespace Chained +{ + + static std::filesystem::path FindProjectRoot() + { + std::filesystem::path root; +#ifdef PROJECT_ROOT_DIR + root = PROJECT_ROOT_DIR; +#else + root = std::filesystem::current_path(); + while (root.has_parent_path() && !std::filesystem::exists(root / "CMakeLists.txt")) + { + root = root.parent_path(); + } +#endif + return root; + } + + static const std::vector& GetSearchSubdirs() + { + static const std::vector subdirs = {"build/bin", "bin", "out/bin", "cmake-build-debug/bin", + "cmake-build-release/bin"}; + return subdirs; + } + + static std::filesystem::path FindRuntimeExecutable(const std::string& projectName, const std::string& configStr) + { + CH_PROFILE_FUNCTION(); + + std::filesystem::path root = FindProjectRoot(); + + if (!std::filesystem::exists(root)) + { + CH_CORE_ERROR("FindRuntimeExecutable: Root path not found: {}", root.string()); + return {}; + } + +#if CH_PLATFORM_WINDOWS + const std::string perGameName = projectName + ".exe"; + const std::string fallbackName = "ChainedRuntime.exe"; +#else + const std::string perGameName = projectName; + const std::string fallbackName = "ChainedRuntime"; +#endif + + auto searchFor = [&](const std::string& targetName) -> std::filesystem::path { + std::filesystem::path currentBin = std::filesystem::current_path() / targetName; + if (std::filesystem::exists(currentBin)) + { + return currentBin; + } + + auto searchSubdirs = GetSearchSubdirs(); + + if (std::filesystem::exists(root / "build")) + { + for (const auto& entry : std::filesystem::directory_iterator(root / "build")) + { + if (entry.is_directory()) + { + if (std::filesystem::exists(entry.path() / "bin" / targetName)) + { + searchSubdirs.push_back("build/" + entry.path().filename().string() + "/bin"); + } + } + } + } + + for (const auto& sub : searchSubdirs) + { + std::filesystem::path p = root / sub / targetName; + if (std::filesystem::exists(p)) + { + CH_CORE_INFO("FindRuntimeExecutable: Found '{}' at: {}", targetName, p.string()); + return p; + } + } + return {}; + }; + + auto result = searchFor(perGameName); + if (!result.empty()) + { + return result; + } + + result = searchFor(fallbackName); + if (!result.empty()) + { + return result; + } + + CH_CORE_INFO("FindRuntimeExecutable: Fast path failed, starting scoped recursive search..."); + try + { + for (auto it = std::filesystem::recursive_directory_iterator(root); + it != std::filesystem::recursive_directory_iterator(); ++it) + { + const auto& entry = *it; + auto filename = entry.path().filename().string(); + + if (entry.is_directory()) + { + if (filename == ".git" || filename == ".cache" || filename == ".idea" || filename == "include" || + filename == "engine") + { + it.disable_recursion_pending(); + continue; + } + } + + if (entry.is_regular_file() && (filename == perGameName || filename == fallbackName)) + { + CH_CORE_INFO("FindRuntimeExecutable: Deep search found at: {}", entry.path().string()); + return entry.path(); + } + } + } catch (const std::exception& e) + { + CH_CORE_WARN("FindRuntimeExecutable: Deep search error: {}", e.what()); + } + + return {}; + } + + static std::string ResolveLaunchVariables(std::string str, std::shared_ptr project) + { + CH_PROFILE_FUNCTION(); + if (!project) + { + return str; + } + + std::filesystem::path root = FindProjectRoot(); + + std::filesystem::path projectFile = project->GetConfig().ProjectDirectory / (project->GetName() + ".chproject"); + std::string projectPathStr = std::filesystem::absolute(projectFile).string(); + + auto replaceAll = [&](const std::string& from, const std::string& to) { + size_t pos = 0; + while ((pos = str.find(from, pos)) != std::string::npos) + { + str.replace(pos, from.length(), to); + pos += to.length(); + } + }; + + replaceAll("${ROOT}", std::filesystem::absolute(root).string()); + replaceAll("${PROJECT_FILE}", projectPathStr); + + if (str.find("${BUILD}") != std::string::npos) + { + std::string configStr = (project->GetBuildConfig() == Configuration::Release) ? "Release" : "Debug"; + std::filesystem::path exePath = FindRuntimeExecutable(project->GetName(), configStr); + std::filesystem::path buildPath = exePath.parent_path(); + + if (buildPath.empty()) + { + for (const auto& sub : GetSearchSubdirs()) + { + if (std::filesystem::exists(root / sub)) + { + buildPath = root / sub; + break; + } + } + } + replaceAll("${BUILD}", std::filesystem::absolute(buildPath).string()); + } + + return str; + } + + EditorProjectManager::EditorProjectManager() + { + } + + void EditorProjectManager::NewProject() + { + // Simple default: close active project to show Project Browser + Project::SetActive(nullptr); + } + + void EditorProjectManager::NewProject(const std::string& name, const std::string& path) + { + auto project = std::make_shared(); + project->GetConfig().Name = name; + project->GetConfig().ProjectDirectory = path; + + auto projectDir = std::filesystem::path(path); + auto assetsDir = projectDir / "assets"; + + // Create standard directory structure + std::filesystem::create_directories(assetsDir / "scenes"); + std::filesystem::create_directories(assetsDir / "materials"); + std::filesystem::create_directories(assetsDir / "models"); + std::filesystem::create_directories(assetsDir / "audio"); + std::filesystem::create_directories(assetsDir / "animations"); + std::filesystem::create_directories(assetsDir / "shaders"); + std::filesystem::create_directories(assetsDir / "skyboxes"); + std::filesystem::create_directories(assetsDir / "prefab"); + std::filesystem::create_directories(assetsDir / "environments"); + std::filesystem::create_directories(assetsDir / "scripts" / "src"); + + // Generate .csproj + { + auto scriptsDir = assetsDir / "scripts"; + auto engineRoot = std::filesystem::path(PROJECT_ROOT_DIR); + auto managedCsproj = engineRoot / "scripting" / "managed" / "Chained.Managed.csproj"; + auto relativeManaged = std::filesystem::relative(managedCsproj, scriptsDir); + + std::string csprojContent = + "\n" + "\n" + " \n" + " net9.0\n" + " " + + name + + ".Scripts\n" + " " + + name + + ".Scripts\n" + " disable\n" + " enable\n" + " true\n" + " ../bin\n" + " false\n" + " true\n" + " false\n" + " \n" + "\n" + " \n" + " \n" + " \n" + "\n" + " \n" + " \n" + " \n" + "\n" + "\n"; + + std::ofstream csprojOut(scriptsDir / (name + ".Scripts.csproj")); + if (csprojOut.is_open()) + { + csprojOut << csprojContent; + } + else + { + CH_CORE_ERROR("NewProject: Failed to create .csproj file '{}'", + (scriptsDir / (name + ".Scripts.csproj")).string()); + } + } + + // ── CMake scaffolding ────────────────────────────────────────────── + // Create CMakeLists.txt at project root (enables standalone builds; + // place the project folder under `game/` for auto-discovery by the + // root CMakeLists.txt). + { + std::filesystem::path cmakeListsPath = projectDir / "CMakeLists.txt"; + std::ofstream cmakeOut(cmakeListsPath); + if (cmakeOut.is_open()) + { + std::string gameDir = std::filesystem::path(path).filename().string(); + cmakeOut << "chained_add_game(" + name + "\n" + << " PROJECT_GAME " << gameDir << "\n" + << " CSHARP_PROJECT \"assets/scripts/" << name << ".Scripts.csproj\"\n" + << ")\n"; + cmakeOut.close(); + } + else + { + CH_CORE_ERROR("NewProject: Failed to create CMakeLists.txt '{}'", cmakeListsPath.string()); + } + } + + // Create src/main.cpp entry point + { + std::filesystem::path srcDir = projectDir / "src"; + std::filesystem::create_directories(srcDir); + std::filesystem::path mainPath = srcDir / "main.cpp"; + std::ofstream mainOut(mainPath); + if (mainOut.is_open()) + { + mainOut << "#include \"engine/app/entry_point.h\"\n" + << "#include \"engine/core/platform.h\"\n" + << "#include \"engine/runtime/runtime_layer.h\"\n" + << "\n" + << "namespace Chained\n" + << "{\n" + << "Application* CreateApplication(ApplicationCommandLineArgs args)\n" + << "{\n" + << " ApplicationSpecification spec;\n" + << " spec.Name = \"" << name << "\";\n" + << " spec.CommandLineArgs = args;\n" + << " spec.EnableScripting = true;\n" + << " spec.EngineRoot = Platform::GetExecutableDirectory();\n" + << " spec.WorkingDirectory = Platform::GetExecutableDirectory().string();\n" + << "\n" + << " // Resolve project path: first CLI arg or default\n" + << " std::filesystem::path projectPath;\n" + << " for (int i = 0; i < args.Count; ++i)\n" + << " {\n" + << " std::string arg = args.Args[i];\n" + << " if (arg.ends_with(\".chproject\"))\n" + << " {\n" + << " projectPath = arg;\n" + << " break;\n" + << " }\n" + << " }\n" + << "\n" + << " if (projectPath.empty() || !std::filesystem::exists(projectPath))\n" + << " {\n" + << " projectPath = std::filesystem::path(spec.WorkingDirectory) / (spec.Name + " + "\".chproject\");\n" + << " }\n" + << "\n" + << " auto* app = new Application(spec);\n" + << " app->PushLayer(std::make_unique(projectPath.string()));\n" + << " return app;\n" + << "}\n" + << "} // namespace Chained\n"; + mainOut.close(); + } + } + + // Generate starter script + { + auto scriptsDir = assetsDir / "scripts"; + std::string scriptContent = "using Chained;\n" + "\n" + "namespace " + + name + + ".Scripts\n" + "{\n" + " public class Starter : Script\n" + " {\n" + " public override void OnCreate()\n" + " {\n" + " }\n" + "\n" + " public override void OnUpdate(float dt)\n" + " {\n" + " }\n" + " }\n" + "}\n"; + + std::ofstream scriptOut(scriptsDir / "src" / "Starter.cs"); + if (scriptOut.is_open()) + { + scriptOut << scriptContent; + } + else + { + CH_CORE_ERROR("NewProject: Failed to create Starter.cs file '{}'", + (scriptsDir / "src" / "Starter.cs").string()); + } + } + + // Create starter scene + { + auto scenePath = assetsDir / "scenes" / "untitled.chscene"; + std::ofstream sceneOut(scenePath); + if (sceneOut.is_open()) + { + sceneOut << "Scene: Untitled\n" + << "Settings:\n" + << " Name: Untitled\n" + << " Type: 0\n" + << " Background:\n" + << " Mode: 2\n" + << "Entities: []\n"; + } + } + + // Create .gitignore + { + auto gitignorePath = projectDir / ".gitignore"; + std::ofstream gitignoreOut(gitignorePath); + if (gitignoreOut.is_open()) + { + gitignoreOut << "build/\n" + << "*.exe\n" + << "*.dll\n" + << "*.pdb\n" + << "*.obj\n" + << "assets/bin/\n" + << "assets/scripts/bin/\n" + << "assets/scripts/obj/\n"; + } + } + + // Configure scripting settings + project->SetScripting(name + ".Scripts.dll", "assets/bin"); + + EditorProjectSerializer::Serialize(project, (std::filesystem::path(path) / (name + ".chproject"))); + + Project::SetActive(project); + + ProjectOpenedEvent e((std::filesystem::path(path) / (name + ".chproject")).string()); + Application::Get().OnEvent(e); + } + + void EditorProjectManager::OpenProject() + { + std::vector filters = {{"Chained Project", "chproject"}}; + auto result = Chained::Dialogs::OpenFile(filters); + if (result) + { + OpenProject(*result); + } + } + + void EditorProjectManager::OpenProject(const std::filesystem::path& path) + { + auto project = std::make_shared(); + if (EditorProjectSerializer::Deserialize(project, path)) + { + m_LastProjectPath = path.string(); + Project::SetActive(project); + + ProjectOpenedEvent e(path.string()); + Application::Get().OnEvent(e); + } + } + + void EditorProjectManager::SaveProject() + { + auto project = Project::GetActive(); + if (!project) + { + return; + } + + EditorProjectSerializer::Serialize( + project, (project->GetConfig().ProjectDirectory / (project->GetName() + ".chproject"))); + } + + bool EditorProjectManager::OnProjectOpened(ProjectOpenedEvent& e) + { + if (!Project::GetActive()) + { + return false; + } + + // This event usually fires mid-ImGui-frame (a button click in the Project + // Selector). Mutating the font atlas while its fonts are in use crashes + // (stbtt_InitFont on freed FontData), so defer the heavy work to the next + // EditorLayer::OnUpdate(), which runs before ImGui::NewFrame(). + m_PendingOpenedProjectPath = e.GetPath(); + return true; + } + + void EditorProjectManager::ProcessPendingProjectOpen() + { + const std::string openedPath = ConsumePendingProjectPath(); + if (openedPath.empty()) + { + return; + } + + auto project = Project::GetActive(); + if (project) + { + std::filesystem::path resolvedPath = openedPath; + std::filesystem::path projDir = + resolvedPath.extension() == ".chproject" ? resolvedPath.parent_path() : resolvedPath; + + auto* assetMgr = ServiceLocator::TryGet(); + auto* renderer = ServiceLocator::TryGet(); + auto* widgetRenderer = ServiceLocator::TryGet(); + + if (!assetMgr || !renderer) + { + CH_CORE_ERROR("ProjectManager: AssetManager or Renderer not available"); + return; + } + + assetMgr->SetProjectDirectory(project->GetConfig().ProjectDirectory); + assetMgr->SetAssetDirectory(project->GetConfig().ProjectDirectory / project->GetConfig().AssetDirectory); + + // Load engine shaders and resources + renderer->LoadEngineResources(); + // Rebuild font atlas with both editor fonts and project fonts. + // Must be done in one pass: Clear → add editor fonts → add project fonts → Build(). + // Calling Build() twice (once per font group) crashes because ImGui frees + // font file data after the first Build(), making a second Build() invalid. + EditorLayer::Get().ReloadEditorFonts(); + + m_LastProjectPath = openedPath; + + // Track in recent projects list (move to front, cap at 10) + auto& config = EditorLayer::Get().GetConfig(); + auto& recents = config.RecentProjects; + recents.erase(std::remove(recents.begin(), recents.end(), m_LastProjectPath), recents.end()); + recents.insert(recents.begin(), m_LastProjectPath); + if (recents.size() > 10) + { + recents.resize(10); + } + + EditorLayer::Get().SaveConfig(); + + // Auto-load script assembly if configured + if (auto* scriptEngine = ServiceLocator::TryGet()) + { + scriptEngine->TryAutoLoad(project->GetConfig()); + } + + // Auto-load scene if available + std::filesystem::path sceneToLoad; + + // 1. Try loading ActiveScene + if (!project->GetActiveScenePath().empty()) + { + sceneToLoad = project->GetConfig().ProjectDirectory / project->GetActiveScenePath(); + } + + // 2. Fallback to StartScene + if (sceneToLoad.empty() || !std::filesystem::exists(sceneToLoad)) + { + if (!project->GetStartScene().empty()) + { + sceneToLoad = project->GetConfig().ProjectDirectory / project->GetConfig().AssetDirectory / + project->GetStartScene(); + } + } + + // 3. Load the scene if found + if (!sceneToLoad.empty() && std::filesystem::exists(sceneToLoad)) + { + CH_CORE_INFO("EditorProjectManager: Auto-loading scene: {}", sceneToLoad.string()); + EditorLayer::Get().GetSceneManager().OpenScene(sceneToLoad); + } + } + } + + const std::string& EditorProjectManager::GetLastProjectPath() const + { + return m_LastProjectPath; + } + + void EditorProjectManager::RestoreLastProjectPath(const std::string& path) + { + m_LastProjectPath = path; + } + + std::string EditorProjectManager::ConsumePendingProjectPath() + { + return std::exchange(m_PendingOpenedProjectPath, {}); + } + + void EditorProjectManager::LaunchStandalone(std::shared_ptr editorScene) + { + CH_PROFILE_FUNCTION(); + auto project = Project::GetActive(); + if (!project) + { + CH_CORE_ERROR("LaunchStandalone: No active project to launch!"); + return; + } + + auto& config = project->GetConfig(); + std::string sceneArgument; + + if (editorScene) + { + std::filesystem::path scenePath = editorScene->GetSettings().ScenePath; + if (scenePath.empty()) + { + scenePath = config.ActiveScenePath; + } + + if (!scenePath.empty()) + { + if (!editorScene->GetSettings().ScenePath.empty()) + { + SceneSerializer serializer(editorScene.get()); + if (!serializer.Serialize(editorScene->GetSettings().ScenePath)) + { + CH_CORE_ERROR("LaunchStandalone: Failed to save current editor scene before launching."); + return; + } + } + + if (scenePath.is_relative()) + { + scenePath = project->GetAssetPath(scenePath); + } + + scenePath = std::filesystem::absolute(scenePath); + project->SetActiveScenePath(project->GetRelativePath(scenePath)); + sceneArgument = std::format(" --scene \"{}\"", scenePath.string()); + } + } + + std::string configStr = (config.BuildConfig == Configuration::Release) ? "Release" : "Debug"; + std::string runtimePath = FindRuntimeExecutable(config.Name, configStr).string(); + + std::filesystem::path projectFile = project->GetConfig().ProjectDirectory / (project->GetName() + ".chproject"); + std::string arguments = std::format("\"{}\"", std::filesystem::absolute(projectFile).string()); + + if (!sceneArgument.empty()) + { + arguments += sceneArgument; + } + + if (runtimePath.empty() || !std::filesystem::exists(runtimePath)) + { + CH_CORE_WARN("LaunchStandalone: Runtime binary not found at '{}'. Searching heuristic...", runtimePath); + runtimePath = FindRuntimeExecutable(config.Name, configStr).string(); + + if (runtimePath.empty() || !std::filesystem::exists(runtimePath)) + { + CH_CORE_ERROR( + "LaunchStandalone: Runtime executable not found! Searched for '{}.exe' and 'ChainedRuntime.exe'.", + config.Name); + return; + } + } + + if (!std::filesystem::exists(projectFile)) + { + CH_CORE_ERROR("LaunchStandalone: Project file not found: {}", + std::filesystem::absolute(projectFile).string()); + return; + } + +#if CH_PLATFORM_WINDOWS + std::string normalizedRuntime = runtimePath; + std::replace(normalizedRuntime.begin(), normalizedRuntime.end(), '/', '\\'); + + std::string normalizedArgs = arguments; + std::replace(normalizedArgs.begin(), normalizedArgs.end(), '/', '\\'); + + CH_CORE_INFO("LaunchStandalone: Executing via ShellExecute: {} {}", normalizedRuntime, normalizedArgs); + + // Provide the executable's directory as the working directory so it doesn't inherit the editor's CWD + std::string exeDir = std::filesystem::path(normalizedRuntime).parent_path().string(); + + std::wstring wExeDir(exeDir.begin(), exeDir.end()); + HINSTANCE result = + ShellExecuteW(NULL, L"open", std::wstring(normalizedRuntime.begin(), normalizedRuntime.end()).c_str(), + std::wstring(normalizedArgs.begin(), normalizedArgs.end()).c_str(), wExeDir.c_str(), SW_SHOW); + if ((uintptr_t)result <= 32) + { + DWORD err = GetLastError(); + CH_CORE_ERROR("LaunchStandalone: ShellExecute failed with code {} (Win32 error: {})", (uintptr_t)result, + err); + } +#else + pid_t pid = fork(); + if (pid == 0) + { + execl(runtimePath.c_str(), runtimePath.c_str(), arguments.c_str(), nullptr); + _exit(127); + } + else if (pid < 0) + { + CH_CORE_ERROR("LaunchStandalone: fork() failed"); + } +#endif + } + +} // namespace Chained diff --git a/editor/project_manager.h b/editor/project_manager.h new file mode 100644 index 000000000..700fc7d07 --- /dev/null +++ b/editor/project_manager.h @@ -0,0 +1,43 @@ +#ifndef CH_EDITOR_PROJECT_MANAGER_H +#define CH_EDITOR_PROJECT_MANAGER_H + +#include "editor/project/editor_settings.h" +#include "engine/scene/scene_events.h" +#include +#include + +namespace Chained +{ + class EditorProjectManager + { + public: + EditorProjectManager(); + ~EditorProjectManager() = default; + + void NewProject(); + void NewProject(const std::string& name, const std::string& path); + void OpenProject(); + void OpenProject(const std::filesystem::path& path); + void SaveProject(); + void LaunchStandalone(std::shared_ptr editorScene); + + bool OnProjectOpened(ProjectOpenedEvent& e); + + // Runs the deferred part of project opening (font atlas rebuild, scene load). + // Must be called outside the ImGui frame — see EditorLayer::OnUpdate(). + void ProcessPendingProjectOpen(); + + const std::string& GetLastProjectPath() const; + void RestoreLastProjectPath(const std::string& path); + + // Returns the pending project path and clears it (consume-once). + std::string ConsumePendingProjectPath(); + + private: + std::string m_LastProjectPath; + std::string m_PendingOpenedProjectPath; + }; + +} // namespace Chained + +#endif // CH_EDITOR_PROJECT_MANAGER_H diff --git a/editor/scene_manager.cpp b/editor/scene_manager.cpp new file mode 100644 index 000000000..65779982c --- /dev/null +++ b/editor/scene_manager.cpp @@ -0,0 +1,552 @@ +#include "scene_manager.h" +#include "engine/assets/asset_manager.h" +#include "engine/common/thread_pool.h" +#include "engine/core/service_locator.h" +#include "engine/ui/widget_renderer.h" +#include "engine/platform/dialogs/dialogs.h" +#include "engine/project/project.h" +#include "engine/scene/scene.h" +#include "engine/scene/scene_events.h" +#include "engine/scene/scene_serializer.h" +#include "layer.h" + +namespace Chained +{ + + void EditorSceneManager::NewScene() + { + auto& cfg = EditorLayer::Get().GetConfig(); + if (cfg.ConfirmOnSceneClose && m_SceneDirty) + { + m_PendingNewScene = true; + return; + } + SetScene(Scene::CreateDefault()); + } + + void EditorSceneManager::OpenScene() + { + std::vector filters = {{"Chained Scene", "chscene"}}; + auto result = Chained::Dialogs::OpenFile(filters); + if (result) + { + OpenScene(*result); + } + } + + void EditorSceneManager::OpenScene(const std::filesystem::path& path) + { + auto& cfg = EditorLayer::Get().GetConfig(); + if (cfg.ConfirmOnSceneClose && m_SceneDirty) + { + m_PendingOpenScene = true; + m_PendingOpenPath = path; + return; + } + + if (m_Transition.state != TransitionState::None) + { + CH_CORE_WARN("EditorSceneManager: Transition to '{}' ignored - already in progress", path.string()); + return; + } + + bool forPlayMode = GetSceneState() == SceneState::Play; + StartSceneLoad(path, forPlayMode); + } + + void EditorSceneManager::SaveScene() + { + auto scene = GetActiveScene(); + if (!scene) + { + return; + } + + if (scene->GetSettings().ScenePath.empty()) + { + SaveSceneAs(); + return; + } + + SceneSerializer serializer(scene.get()); + serializer.Serialize(scene->GetSettings().ScenePath); + m_SceneDirty = false; + CH_INFO("Scene saved to {0}", scene->GetSettings().ScenePath); + } + + void EditorSceneManager::SaveSceneAs() + { + std::vector filters = {{"Chained Scene", "chscene"}}; + auto result = Chained::Dialogs::SaveFile(filters); + if (result) + { + auto scene = GetActiveScene(); + if (!scene) + { + return; + } + + if (result->extension().empty()) + { + result->replace_extension(".chscene"); + } + + scene->GetSettings().ScenePath = result->string(); + SceneSerializer serializer(scene.get()); + serializer.Serialize(result->string()); + } + } + + void EditorSceneManager::AutoSave(float interval, float ts) + { + auto scene = GetActiveScene(); + if (!scene || scene->GetSettings().ScenePath.empty()) + { + return; + } + + m_AutoSaveTimer += ts; + if (m_AutoSaveTimer < interval) + { + return; + } + + m_AutoSaveTimer = 0.0f; + SceneSerializer serializer(scene.get()); + serializer.Serialize(scene->GetSettings().ScenePath); + CH_TRACE("Scene auto-saved to {0}", scene->GetSettings().ScenePath); + } + + void EditorSceneManager::SetScene(const std::shared_ptr& scene) + { + CancelTransition(); + + EditorLayer::Get().GetEditorState().SelectedEntity = {}; + + m_EditorScene = scene; + if (m_EditorScene) + { + m_EditorScene->TransitionToState(SceneState::Edit); + } + } + + SceneState EditorSceneManager::GetSceneState() const + { + auto activeScene = GetActiveScene(); + return activeScene ? activeScene->GetSceneState() : SceneState::Edit; + } + + std::shared_ptr EditorSceneManager::GetActiveScene() const + { + if (m_RuntimeScene) + { + return m_RuntimeScene; + } + return m_EditorScene; + } + + void EditorSceneManager::SetSceneState(SceneState state) + { + SceneState current = GetSceneState(); + + if (state == SceneState::Play || state == SceneState::Simulate) + { + if (m_Transition.state != TransitionState::None || current == SceneState::Play || + current == SceneState::Simulate) + { + return; + } + + if (!m_EditorScene) + { + CH_CORE_WARN("EditorSceneManager::SetSceneState - No editor scene available."); + return; + } + + CH_CORE_INFO("Editor: {} mode requested.", state == SceneState::Play ? "Play" : "Simulate"); + + CancelTransition(); + + m_Transition = {}; + m_Transition.state = TransitionState::PlayStarting; + m_Transition.targetState = state; + m_Transition.forPlayMode = true; + m_Transition.targetPath = m_EditorScene->GetSettings().ScenePath; + m_LoadingStatus = (state == SceneState::Play) ? "Preparing Play Mode..." : "Preparing Simulation..."; + + auto editorScene = m_EditorScene; + if (auto* tp = ServiceLocator::TryGet()) + { + m_Transition.future = tp->Enqueue([editorScene]() -> SceneLoadResult { + try + { + auto runtimeScene = Scene::Copy(editorScene); + if (!runtimeScene) + { + return SceneLoadResult{nullptr, "Failed to copy scene for play mode."}; + } + return SceneLoadResult{runtimeScene, {}}; + } catch (const std::exception& e) + { + return SceneLoadResult{nullptr, e.what()}; + } + }); + } + else + { + try + { + auto runtimeScene = Scene::Copy(editorScene); + m_RuntimeScene = runtimeScene; + m_Transition.state = TransitionState::Finalizing; + m_Transition.sceneReady = true; + } catch (const std::exception& e) + { + CH_CORE_ERROR("Editor: Exception copying scene: {}", e.what()); + CancelTransition(); + } + } + } + else + { + if (m_Transition.state != TransitionState::None) + { + CancelTransition(); + } + + if (current == SceneState::Edit) + { + return; + } + + // Map SelectedEntity back to the editor scene + auto& editorState = EditorLayer::Get().GetEditorState(); + if (editorState.SelectedEntity) + { + UUID uuid = editorState.SelectedEntity.GetUUID(); + Entity editorEntity = m_EditorScene ? m_EditorScene->GetEntityByUUID(uuid) : Entity{}; + editorState.SelectedEntity = editorEntity; + } + + CH_CORE_INFO("Editor: Play Mode Stopped"); + if (m_RuntimeScene) + { + CH_CORE_INFO("Editor: Cleaning up runtime scene..."); + m_RuntimeScene->OnRuntimeStop(); + m_RuntimeScene.reset(); + } + + if (m_EditorScene) + { + m_EditorScene->TransitionToState(SceneState::Edit); + } + } + } + + void EditorSceneManager::OnUpdate(Timestep ts) + { + switch (m_Transition.state) + { + case TransitionState::None: + break; + case TransitionState::PlayStarting: + case TransitionState::SceneLoading: + UpdateSceneLoading(); + break; + case TransitionState::Finalizing: + UpdateFinalizing(); + break; + } + } + + void EditorSceneManager::OnViewportResize(uint32_t width, uint32_t height) + { + if (m_EditorScene) + { + m_EditorScene->OnViewportResize(width, height); + } + + if (m_RuntimeScene) + { + m_RuntimeScene->OnViewportResize(width, height); + } + } + + // --- Transition helpers --- + + void EditorSceneManager::StartSceneLoad(const std::filesystem::path& path, bool forPlayMode) + { + if (path.empty() || m_Transition.state != TransitionState::None) + { + return; + } + + if (forPlayMode) + { + CancelTransition(); + } + + std::filesystem::path scenePath = path; + if (scenePath.is_relative()) + { + if (Project::GetActive()) + { + scenePath = Project::GetActive()->GetAssetPath(scenePath); + } + } + + m_Transition = {}; + m_Transition.state = TransitionState::SceneLoading; + m_Transition.targetPath = scenePath; + m_Transition.forPlayMode = forPlayMode; + m_Transition.targetState = forPlayMode ? SceneState::Play : SceneState::Edit; + m_LoadingStatus = "Loading scene..."; + + try + { + auto* threadPool = ServiceLocator::TryGet(); + if (!threadPool) + { + CH_CORE_ERROR("Editor: ThreadPool not available, cannot load scene"); + Dialogs::ShowError("Scene loading failed", "ThreadPool not available"); + CancelTransition(); + return; + } + m_Transition.future = threadPool->Enqueue([scenePath]() -> SceneLoadResult { + auto newScene = std::make_shared(); + SceneSerializer serializer(newScene.get()); + if (!serializer.Deserialize(scenePath.string())) + { + return SceneLoadResult{nullptr, serializer.GetLastError()}; + } + return SceneLoadResult{newScene, {}}; + }); + + CH_CORE_INFO("Editor: Loading scene '{}' on a worker thread.", scenePath.string()); + } catch (const std::exception& e) + { + CH_CORE_ERROR("Editor: Failed to start scene load: {}", e.what()); + Dialogs::ShowError("Scene loading failed", + "Could not start loading '" + scenePath.string() + "':\n\n" + e.what()); + CancelTransition(); + } + } + + void EditorSceneManager::UpdateSceneLoading() + { + if (!m_Transition.future.valid() || + m_Transition.future.wait_for(std::chrono::seconds(0)) != std::future_status::ready) + { + return; + } + + SceneLoadResult loadResult; + try + { + loadResult = m_Transition.future.get(); + } catch (const std::exception& e) + { + CH_CORE_ERROR("Editor: Scene load failed with exception: {}", e.what()); + Dialogs::ShowError("Scene loading failed", + "Failed to load scene:\n" + m_Transition.targetPath.string() + "\n\n" + e.what()); + CancelTransition(); + return; + } catch (...) + { + CH_CORE_ERROR("Editor: Scene load failed with unknown exception."); + Dialogs::ShowError("Scene loading failed", "Failed to load scene:\n" + m_Transition.targetPath.string() + + "\n\nAn unknown error occurred."); + CancelTransition(); + return; + } + + if (!loadResult.scene) + { + std::string reason = loadResult.error.empty() ? "The scene file is corrupt or is not a valid Chained scene." + : loadResult.error; + CH_CORE_ERROR("Editor: Scene load failed for '{}': {}", m_Transition.targetPath.string(), reason); + Dialogs::ShowError("Scene loading failed", + "Failed to load scene:\n" + m_Transition.targetPath.string() + "\n\n" + reason); + CancelTransition(); + return; + } + + if (m_Transition.forPlayMode) + { + if (m_RuntimeScene) + { + CH_CORE_INFO("Editor: Stopping current runtime scene to load '{}'.", m_Transition.targetPath.string()); + m_RuntimeScene->OnRuntimeStop(); + } + m_RuntimeScene = loadResult.scene; + + if (auto* uiRenderer = ServiceLocator::TryGet()) + { + uiRenderer->ResetButtonStates(m_RuntimeScene.get()); + } + + // Map SelectedEntity to the play/simulate scene so the Inspector shows and modifies running state! + auto& editorState = EditorLayer::Get().GetEditorState(); + if (editorState.SelectedEntity) + { + UUID uuid = editorState.SelectedEntity.GetUUID(); + Entity playEntity = m_RuntimeScene->GetEntityByUUID(uuid); + if (playEntity) + { + editorState.SelectedEntity = playEntity; + } + } + } + else + { + EditorLayer::Get().GetEditorState().SelectedEntity = {}; + m_EditorScene = loadResult.scene; + } + + m_Transition.state = TransitionState::Finalizing; + m_Transition.sceneReady = false; + m_AssetWaitLogTimer = 0.0f; + } + + void EditorSceneManager::UpdateFinalizing() + { + if (!m_Transition.sceneReady) + { + auto* assetMgr = ServiceLocator::TryGet(); + if (assetMgr) + { + assetMgr->Update(0.016f); + + if (assetMgr->HasBackgroundWork()) + { + m_AssetWaitLogTimer += 0.016f; + if (m_AssetWaitLogTimer > 1.0f) + { + uint32_t pendingCount = assetMgr->GetPendingFinalizeCount(); + if (pendingCount > 0) + { + CH_CORE_INFO("Editor: Waiting for {} assets...", pendingCount); + } + m_AssetWaitLogTimer = 0.0f; + } + return; + } + } + m_Transition.sceneReady = true; + } + + FinalizeTransition(); + } + + void EditorSceneManager::FinalizeTransition() + { + auto targetScene = m_Transition.forPlayMode ? m_RuntimeScene : m_EditorScene; + if (!targetScene) + { + CancelTransition(); + return; + } + + auto& layer = EditorLayer::Get(); + + if (auto project = Project::GetActive(); project && project->GetEnvironment()) + { + bool hasEnvironment = targetScene->GetSettings().Environment && + (!targetScene->GetSettings().Environment->GetPath().empty() || + !targetScene->GetSettings().Environment->GetSettings().Skybox.TexturePath.empty()); + + if (!hasEnvironment) + { + CH_CORE_INFO("Editor: Applying project environment to scene '{}'.", + targetScene->GetSettings().ScenePath); + targetScene->GetSettings().Environment = project->GetEnvironment(); + } + } + + targetScene->GetSettings().ScenePath = m_Transition.targetPath.string(); + + if (m_Transition.forPlayMode) + { + m_RuntimeScene->TransitionToState(m_Transition.targetState); + CH_CORE_INFO("Editor: Play Mode Started Successfully"); + } + else + { + m_EditorScene->TransitionToState(SceneState::Edit); + + SceneOpenedEvent e(m_Transition.targetPath.string()); + OnSceneOpened(e); + } + + layer.GetEditorState().SelectedEntity = {}; + m_LoadingStatus = ""; + m_Transition = {}; + CH_CORE_INFO("Editor: Scene transition complete."); + } + + void EditorSceneManager::CancelTransition() + { + if (m_RuntimeScene && m_Transition.forPlayMode && m_Transition.state != TransitionState::None) + { + CH_CORE_INFO("Editor: Stopping runtime scene during transition..."); + m_RuntimeScene->OnRuntimeStop(); + m_RuntimeScene.reset(); + } + + m_Transition = {}; + m_LoadingStatus = ""; + } + + // --- Events --- + + bool EditorSceneManager::OnSceneOpened(SceneOpenedEvent& e) + { + auto project = Project::GetActive(); + if (project && !e.GetPath().empty()) + { + project->SetActiveScenePath(std::filesystem::relative(e.GetPath(), project->GetConfig().ProjectDirectory)); + + auto& layer = EditorLayer::Get(); + layer.GetProjectManager().SaveProject(); + + layer.GetConfig().LastScenePath = e.GetPath(); + layer.SaveConfig(); + return true; + } + return false; + } + + // --- Confirm dialogs --- + + void EditorSceneManager::ConfirmPendingAction() + { + if (m_PendingNewScene) + { + m_PendingNewScene = false; + m_SceneDirty = false; + SetScene(Scene::CreateDefault()); + } + else if (m_PendingOpenScene) + { + m_PendingOpenScene = false; + m_SceneDirty = false; + std::filesystem::path path = m_PendingOpenPath; + m_PendingOpenPath.clear(); + + CH_CORE_INFO("EditorSceneManager: Transition requested to '{}'", path.string()); + if (m_Transition.state == TransitionState::None) + { + bool forPlayMode = GetSceneState() == SceneState::Play; + StartSceneLoad(path, forPlayMode); + } + } + } + + void EditorSceneManager::CancelPendingAction() + { + m_PendingNewScene = false; + m_PendingOpenScene = false; + m_PendingOpenPath.clear(); + } + +} // namespace Chained diff --git a/editor/scene_manager.h b/editor/scene_manager.h new file mode 100644 index 000000000..2b56f8619 --- /dev/null +++ b/editor/scene_manager.h @@ -0,0 +1,129 @@ +#ifndef CH_EDITOR_SCENE_MANAGER_H +#define CH_EDITOR_SCENE_MANAGER_H + +#include "engine/scene/scene.h" +#include "engine/scene/scene_events.h" +#include "editor/project/editor_settings.h" +#include +#include +#include +#include + +namespace Chained +{ + + class EditorSceneManager + { + public: + EditorSceneManager() = default; + ~EditorSceneManager() = default; + + void NewScene(); + void OpenScene(); + void OpenScene(const std::filesystem::path& path); + void OpenSceneInPlayMode(const std::filesystem::path& path); + void SaveScene(); + void SaveSceneAs(); + void AutoSave(float interval, float ts); + + void SetScene(const std::shared_ptr& scene); + void SetSceneState(SceneState state); + SceneState GetSceneState() const; + std::shared_ptr GetActiveScene() const; + std::shared_ptr GetRuntimeScene() const + { + return m_RuntimeScene; + } + std::shared_ptr GetEditorScene() const + { + return m_EditorScene; + } + + bool IsSceneDirty() const + { + return m_SceneDirty; + } + void MarkSceneDirty() + { + m_SceneDirty = true; + } + void ClearSceneDirty() + { + m_SceneDirty = false; + } + + bool IsConfirmPending() const + { + return m_PendingNewScene || m_PendingOpenScene; + } + void ConfirmPendingAction(); + void CancelPendingAction(); + + void OnUpdate(Timestep ts); + void OnViewportResize(uint32_t width, uint32_t height); + + bool IsLoading() const + { + return m_Transition.state != TransitionState::None; + } + bool IsTransitioning() const + { + return IsLoading(); + } + const std::string& GetLoadingStatus() const + { + return m_LoadingStatus; + } + + bool OnSceneOpened(SceneOpenedEvent& e); + + private: + enum class TransitionState + { + None, + PlayStarting, + SceneLoading, + Finalizing + }; + + struct SceneLoadResult + { + std::shared_ptr scene; + std::string error; + }; + + struct TransitionData + { + TransitionState state = TransitionState::None; + SceneState targetState = SceneState::Edit; + bool forPlayMode = false; + + std::future future; + std::filesystem::path targetPath; + bool sceneReady = false; + }; + + void StartSceneLoad(const std::filesystem::path& path, bool forPlayMode); + void UpdateSceneLoading(); + void UpdateFinalizing(); + void FinalizeTransition(); + void CancelTransition(); + + std::shared_ptr m_EditorScene; + std::shared_ptr m_RuntimeScene; + + TransitionData m_Transition; + std::string m_LoadingStatus; + float m_AutoSaveTimer = 0.0f; + float m_AssetWaitLogTimer = 0.0f; + + bool m_SceneDirty = false; + + bool m_PendingNewScene = false; + bool m_PendingOpenScene = false; + std::filesystem::path m_PendingOpenPath; + }; + +} // namespace Chained + +#endif // CH_EDITOR_SCENE_MANAGER_H diff --git a/editor/scene_picking.cpp b/editor/scene_picking.cpp new file mode 100644 index 000000000..dc8056220 --- /dev/null +++ b/editor/scene_picking.cpp @@ -0,0 +1,291 @@ +#include "scene_picking.h" +#include "engine/app/application.h" +#include "engine/assets/asset_manager.h" +#include "engine/assets/types/model_asset.h" +#include "engine/core/service_locator.h" +#include "engine/physics/physics.h" +#include "engine/scene/components.h" +#include "engine/scene/systems/transform_system.h" +#include "engine/scene/scene.h" +#include +#include +#include +#include +#include +#include + +namespace Chained +{ + + static bool RayAABBInternal(const glm::vec3& origin, const glm::vec3& dir, const glm::vec3& min, + const glm::vec3& max, float& t) + { + auto SafeInv = [](float d) -> float { + return (std::abs(d) < std::numeric_limits::epsilon()) + ? std::copysign(std::numeric_limits::infinity(), d) + : 1.0f / d; + }; + glm::vec3 invDir(SafeInv(dir.x), SafeInv(dir.y), SafeInv(dir.z)); + glm::vec3 t0 = (min - origin) * invDir; + glm::vec3 t1 = (max - origin) * invDir; + + glm::vec3 tMin = glm::min(t0, t1); + glm::vec3 tMax = glm::max(t0, t1); + + float nearT = std::max({tMin.x, tMin.y, tMin.z}); + float farT = std::min({tMax.x, tMax.y, tMax.z}); + + if (farT < std::max(0.0f, nearT)) + { + return false; + } + t = nearT; + return true; + } + + RaycastResult ScenePicker::Raycast(Scene* scene, const Ray& ray) + { + RaycastResult finalResult; + finalResult.Hit = false; + finalResult.Distance = FLT_MAX; + + if (!scene) + { + return finalResult; + } + + if (auto* physics = ServiceLocator::TryGet()) + { + RaycastResult physResult = physics->Raycast(ray); + if (physResult.Hit) + { + finalResult = physResult; + } + } + + // Primitive Component picking + auto primitiveView = scene->GetRegistry().view(); + primitiveView.each([&](entt::entity entityID, TransformComponent& tc, PrimitiveComponent& primComp) { + if (finalResult.Hit && finalResult.Entity == entityID) + { + return; + } + if (primComp.Type == PrimitiveType::None) + { + return; + } + + glm::mat4 modelTransform = TransformSystem::ComputeLocalMatrix(tc); + glm::mat4 invTransform = glm::inverse(modelTransform); + + Ray localRay; + localRay.position = glm::vec3(invTransform * glm::vec4(ray.position, 1.0f)); + glm::vec3 localTarget = glm::vec3(invTransform * glm::vec4(ray.position + ray.direction, 1.0f)); + localRay.direction = glm::normalize(localTarget - localRay.position); + + glm::vec3 halfSize = primComp.Dimensions * 0.5f; + if (primComp.Type == PrimitiveType::Sphere) + { + halfSize = glm::vec3(primComp.Radius); + } + + float t = 0.0f; + if (RayAABBInternal(localRay.position, localRay.direction, -halfSize, halfSize, t)) + { + glm::vec3 hitPosLocal = localRay.position + localRay.direction * t; + glm::vec3 hitPosWorld = glm::vec3(modelTransform * glm::vec4(hitPosLocal, 1.0f)); + float distWorld = glm::distance(ray.position, hitPosWorld); + + if (distWorld < finalResult.Distance) + { + finalResult.Distance = distWorld; + finalResult.Hit = true; + finalResult.Entity = entityID; + finalResult.Position = hitPosWorld; + } + } + }); + + auto modelView = scene->GetRegistry().view(); + modelView.each([&](entt::entity entityID, TransformComponent& tc, ModelComponent& modelComp) { + if (finalResult.Hit && finalResult.Entity == entityID) + { + return; + } + + if (modelComp.ModelPath.empty()) + { + return; + } + + AssetManager* assetManager = ServiceLocator::TryGet(); + if (!assetManager) + { + return; + } + auto modelAsset = assetManager->Get(modelComp.ModelPath); + if (!modelAsset || !modelAsset->IsReady()) + { + return; + } + + glm::mat4 modelTransform = TransformSystem::ComputeLocalMatrix(tc); + glm::mat4 invTransform = glm::inverse(modelTransform); + + Ray localRay; + localRay.position = glm::vec3(invTransform * glm::vec4(ray.position, 1.0f)); + glm::vec3 localTarget = glm::vec3(invTransform * glm::vec4(ray.position + ray.direction, 1.0f)); + localRay.direction = glm::normalize(localTarget - localRay.position); + + float aabbT = 0.0f; + glm::vec3 modelMin = modelAsset->GetBoundingBox().Min; + glm::vec3 modelMax = modelAsset->GetBoundingBox().Max; + + if (modelMin != modelMax) + { + if (!RayAABBInternal(localRay.position, localRay.direction, modelMin, modelMax, aabbT)) + { + return; + } + } + + float t_local = FLT_MAX; + glm::vec3 localNormal = {0, 0, 0}; + bool hit = false; + + const auto& instances = modelAsset->GetInstances(); + const auto& rawMeshes = modelAsset->GetMeshes(); + + for (const auto& inst : instances) + { + if (inst.meshIndex < 0 || inst.meshIndex >= (int)rawMeshes.size()) + { + continue; + } + + const MeshData& raw = rawMeshes[inst.meshIndex]; + if (raw.indices.size() < 3) + { + continue; + } + + glm::mat4 invLocalInst = glm::inverse(inst.localTransform); + glm::vec3 meshSpaceOrigin = glm::vec3(invLocalInst * glm::vec4(localRay.position, 1.0f)); + glm::vec3 meshSpaceDir = glm::normalize(glm::vec3(invLocalInst * glm::vec4(localRay.direction, 0.0f))); + + for (size_t i = 0; i + 2 < raw.indices.size(); i += 3) + { + uint32_t i0 = raw.indices[i]; + uint32_t i1 = raw.indices[i + 1]; + uint32_t i2 = raw.indices[i + 2]; + + size_t v0Idx = (size_t)i0 * 3; + size_t v1Idx = (size_t)i1 * 3; + size_t v2Idx = (size_t)i2 * 3; + + if (v0Idx + 2 >= raw.vertices.size() || v1Idx + 2 >= raw.vertices.size() || + v2Idx + 2 >= raw.vertices.size()) + { + continue; + } + + glm::vec3 v0 = {raw.vertices[v0Idx], raw.vertices[v0Idx + 1], raw.vertices[v0Idx + 2]}; + glm::vec3 v1 = {raw.vertices[v1Idx], raw.vertices[v1Idx + 1], raw.vertices[v1Idx + 2]}; + glm::vec3 v2 = {raw.vertices[v2Idx], raw.vertices[v2Idx + 1], raw.vertices[v2Idx + 2]}; + + glm::vec3 e1 = v1 - v0, e2 = v2 - v0; + glm::vec3 h = glm::cross(meshSpaceDir, e2); + float a = glm::dot(e1, h); + if (std::abs(a) < 1e-7f) + { + continue; + } + float f = 1.0f / a; + glm::vec3 s = meshSpaceOrigin - v0; + float u = f * glm::dot(s, h); + if (u < 0.0f || u > 1.0f) + { + continue; + } + glm::vec3 q = glm::cross(s, e1); + float v = f * glm::dot(meshSpaceDir, q); + if (v < 0.0f || u + v > 1.0f) + { + continue; + } + + float triT = f * glm::dot(e2, q); + if (triT > 0.0f) + { + + glm::vec3 hitMeshSpace = meshSpaceOrigin + meshSpaceDir * triT; + glm::vec3 hitLocalSpace = glm::vec3(inst.localTransform * glm::vec4(hitMeshSpace, 1.0f)); + + float currentLocalT = glm::distance(localRay.position, hitLocalSpace); + + if (currentLocalT < t_local) + { + t_local = currentLocalT; + hit = true; + + glm::vec3 meshNormal = glm::normalize(glm::cross(e1, e2)); + localNormal = + glm::normalize(glm::vec3(glm::transpose(invLocalInst) * glm::vec4(meshNormal, 0.0f))); + } + } + } + } + + if (hit) + { + glm::vec3 hitPosLocal = localRay.position + localRay.direction * t_local; + glm::vec3 hitPosWorld = glm::vec3(modelTransform * glm::vec4(hitPosLocal, 1.0f)); + float distWorld = glm::distance(ray.position, hitPosWorld); + + if (distWorld < finalResult.Distance) + { + finalResult.Distance = distWorld; + finalResult.Hit = true; + finalResult.Entity = entityID; + finalResult.Position = hitPosWorld; + } + } + }); + + return finalResult; + } + + Ray ScenePicker::CreateRayFromViewport(const Chained::Camera3D& camera, const glm::vec2& mousePosition, + const glm::vec2& viewportSize) + { + float ndc_x = (2.0f * mousePosition.x) / viewportSize.x - 1.0f; + float ndc_y = 1.0f - (2.0f * mousePosition.y) / viewportSize.y; + + glm::mat4 invVP = glm::inverse(camera.ProjectionMatrix * camera.ViewMatrix); + + auto Unproject = [&](float x, float y, float z) -> glm::vec3 { + glm::vec4 ndc(x, y, z, 1.0f); + glm::vec4 world = invVP * ndc; + if (std::abs(world.w) > 1e-6f) + { + return glm::vec3(world) / world.w; + } + return glm::vec3(0.0f); + }; + +#ifdef GLM_FORCE_DEPTH_ZERO_TO_ONE + float nearZ = 0.0f; +#else + float nearZ = -1.0f; +#endif + glm::vec3 nearPoint = Unproject(ndc_x, ndc_y, nearZ); + glm::vec3 farPoint = Unproject(ndc_x, ndc_y, 1.0f); + + Ray ray; + ray.position = nearPoint; + ray.direction = glm::normalize(farPoint - nearPoint); + + return ray; + } + +} // namespace Chained \ No newline at end of file diff --git a/editor/scene_picking.h b/editor/scene_picking.h new file mode 100644 index 000000000..c36aaaf2a --- /dev/null +++ b/editor/scene_picking.h @@ -0,0 +1,22 @@ +#ifndef CH_SCENE_PICKING_H +#define CH_SCENE_PICKING_H + +#include "engine/graphics/camera_types.h" +#include "engine/physics/raycast_result.h" +#include "engine/scene/entity.h" + +namespace Chained +{ + class Scene; + + class ScenePicker + { + public: + static RaycastResult Raycast(Scene* scene, const Ray& ray); + static Ray CreateRayFromViewport(const Chained::Camera3D& camera, const glm::vec2& mousePosition, + const glm::vec2& viewportSize); + }; + +} // namespace Chained + +#endif // CH_SCENE_PICKING_H diff --git a/editor/types.h b/editor/types.h new file mode 100644 index 000000000..c8ab84441 --- /dev/null +++ b/editor/types.h @@ -0,0 +1,22 @@ +#ifndef CH_EDITOR_TYPES_H +#define CH_EDITOR_TYPES_H + +#include "engine/graphics/pipeline/renderer.h" +#include "engine/scene/scene.h" +#include +#include + +namespace Chained +{ + struct EditorState + { + Entity SelectedEntity; + bool FullscreenGame = false; + bool NeedsLayoutReset = false; + int LastHitMeshIndex = -1; + DebugRenderFlags DebugRenderFlags; + }; + +} // namespace Chained + +#endif // CH_EDITOR_TYPES_H diff --git a/editor/ui/project_selector_ui.cpp b/editor/ui/project_selector_ui.cpp new file mode 100644 index 000000000..99e2636d1 --- /dev/null +++ b/editor/ui/project_selector_ui.cpp @@ -0,0 +1,355 @@ +#include "project_selector_ui.h" +#include "editor/editor_colors.h" +#include "editor/layer.h" +#include "engine/assets/asset_manager.h" +#include "engine/assets/types/texture_asset.h" +#include "engine/core/service_locator.h" +#include "engine/platform/dialogs/dialogs.h" +#include "imgui.h" +#include "imgui_internal.h" +#include "thirdparty/IconsFontAwesome6.h" +#include +#include +#include + +namespace Chained +{ + + namespace + { + void SafeCopy(char* dst, size_t dstSize, const char* src) + { + strncpy(dst, src, dstSize - 1); + dst[dstSize - 1] = '\0'; + } + + constexpr float kSidebarWidth = 320.0f; + constexpr float kCardWidth = 300.0f; + constexpr float kCardHeight = 300.0f; + constexpr float kCardGap = 40.0f; + constexpr float kCardTextWrap = 280.0f; + constexpr float kPopupWidth = 480.0f; + constexpr float kInputWidth = 432.0f; + constexpr float kBtnWidth = 110.0f; + constexpr float kBtnHeight = 28.0f; + constexpr size_t kNameBufSize = 128; + constexpr size_t kLocationBufSize = 256; + } // namespace + + ProjectSelectorUI::ProjectSelectorUI(EditorProjectManager& projectManager) + : m_ProjectManager(projectManager) + { + } + + void ProjectSelectorUI::LoadEditorIcons() + { + if (m_IconsLoaded) + { + return; + } + + auto assetManager = ServiceLocator::TryGet(); + if (assetManager) + { + assetManager->LoadAsset("engine/resources/icons/newproject.jpg", TextureAsset::GetStaticType()); + assetManager->LoadAsset("engine/resources/icons/folder.png", TextureAsset::GetStaticType()); + + m_NewProjectIcon = assetManager->Get("engine/resources/icons/newproject.jpg"); + m_OpenProjectIcon = assetManager->Get("engine/resources/icons/folder.png"); + m_IconsLoaded = true; + } + } + + void ProjectSelectorUI::OnImGuiRender() + { + LoadEditorIcons(); + + if (!m_Initialized) + { + std::string cwd = std::filesystem::current_path().string(); + SafeCopy(m_ProjectLocationBuffer, kLocationBufSize, cwd.c_str()); + m_Initialized = true; + } + + ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(viewport->WorkPos); + ImGui::SetNextWindowSize(viewport->WorkSize); + ImGui::SetNextWindowViewport(viewport->ID); + + ImGuiWindowFlags windowFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoNavFocus | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBringToFrontOnFocus; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + + ImGui::Begin("Project Selector", nullptr, windowFlags); + + // Sidebar + ImGui::PushStyleColor(ImGuiCol_ChildBg, EditorColors::DarkPanelBg); + ImGui::BeginChild("Sidebar", ImVec2(kSidebarWidth, 0), false); + + // Banner / Logo Area + ImGui::Dummy(ImVec2(0, 15)); + ImGui::SetCursorPosX(20.0f); + ImGui::SetWindowFontScale(1.5f); + ImGui::TextColored(ImVec4(0.2f, 0.7f, 1.0f, 1.0f), ICON_FA_LINK " Chained"); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 1.0f), "Engine"); + ImGui::SetWindowFontScale(1.0f); + ImGui::Separator(); + + ImGui::Dummy(ImVec2(0, 15)); + ImGui::TextDisabled(" RECENT PROJECTS"); + ImGui::Dummy(ImVec2(0, 10)); + + const auto& config = EditorLayer::Get().GetConfig(); + if (config.RecentProjects.empty()) + { + ImGui::TextDisabled(" No recent projects."); + } + else + { + ImGui::PushStyleColor(ImGuiCol_Button, EditorColors::SidebarBg); + ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.05f, 0.5f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 5.0f); + + for (const auto& projectPath : config.RecentProjects) + { + std::string fileName = std::filesystem::path(projectPath).filename().string(); + std::string dirName = std::filesystem::path(projectPath).parent_path().filename().string(); + + std::string label = ICON_FA_FOLDER_OPEN " " + fileName + "\n " + dirName; + + ImGui::SetCursorPosX(10.0f); + if (ImGui::Button(label.c_str(), ImVec2(kSidebarWidth - 20, 50))) + { + m_ProjectManager.OpenProject(projectPath); + break; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", projectPath.c_str()); + } + ImGui::Dummy(ImVec2(0, 5)); + } + + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(); + } + + ImGui::EndChild(); + ImGui::PopStyleColor(); // ChildBg Sidebar + + ImGui::SameLine(); + + // Main Area + ImGui::PushStyleColor(ImGuiCol_ChildBg, EditorColors::SubCardBg); + ImGui::BeginChild("MainArea"); + + float centerX = ImGui::GetContentRegionAvail().x * 0.5f; + float centerY = ImGui::GetContentRegionAvail().y * 0.5f; + + ImGui::SetCursorPos(ImVec2(centerX - kCardWidth / 2.0f - kCardGap / 2.0f, centerY - kCardHeight / 2.0f)); + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(20, 20)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 12.0f); + ImGui::PushStyleColor(ImGuiCol_Button, EditorColors::ProjectCardBg); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, EditorColors::ProjectCardHover); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, EditorColors::ProjectCardActive); + ImGui::PushStyleColor(ImGuiCol_Text, EditorColors::BrightText); + + ImGui::BeginGroup(); + { + ImTextureID newProjTex = 0; + if (m_NewProjectIcon) + { + auto* am = ServiceLocator::TryGet(); + if (am) + { + auto texAsset = am->Get("engine/resources/icons/newproject.jpg"); + if (texAsset && texAsset->IsReady()) + { + auto gpuTex = texAsset->GetTexture(); + if (gpuTex) + { + newProjTex = (ImTextureID)(uintptr_t)gpuTex->GetNativeHandle(); + } + } + } + } + + if (ImGui::ImageButton("##NewProject", newProjTex, {kCardWidth, kCardHeight}, {0, 0}, {1, 1})) + { + m_ShowCreateDialog = true; + } + } + ImGui::SetWindowFontScale(1.3f); + ImGui::Text("New Project"); + ImGui::SetWindowFontScale(1.0f); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + kCardTextWrap); + ImGui::TextDisabled("Start a fresh journey with a dedicated"); + ImGui::TextDisabled("project folder and optimized settings."); + ImGui::PopTextWrapPos(); + ImGui::EndGroup(); + + ImGui::SameLine(0, kCardGap); + + ImGui::BeginGroup(); + { + ImTextureID openProjTex = 0; + if (m_OpenProjectIcon) + { + auto* am = ServiceLocator::TryGet(); + if (am) + { + auto texAsset = am->Get("engine/resources/icons/folder.png"); + if (texAsset && texAsset->IsReady()) + { + auto gpuTex = texAsset->GetTexture(); + if (gpuTex) + { + openProjTex = (ImTextureID)(uintptr_t)gpuTex->GetNativeHandle(); + } + } + } + } + + if (ImGui::ImageButton("##OpenProject", openProjTex, {kCardWidth, kCardHeight}, {0, 0}, {1, 1})) + { + m_ProjectManager.OpenProject(); + } + } + ImGui::SetWindowFontScale(1.3f); + ImGui::Text("Open Project"); + ImGui::SetWindowFontScale(1.0f); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + kCardTextWrap); + ImGui::TextDisabled("Browse and load an existing Chained"); + ImGui::TextDisabled("Engine project (.chproject) file."); + ImGui::PopTextWrapPos(); + ImGui::EndGroup(); + + ImGui::PopStyleColor(4); + ImGui::PopStyleVar(2); + + ImGui::EndChild(); + ImGui::PopStyleColor(); // ChildBg MainArea + + if (m_ShowCreateDialog) + { + ImGui::OpenPopup("Create New Project"); + + ImVec2 mainAreaCenter = + ImVec2(kSidebarWidth + (viewport->WorkSize.x - kSidebarWidth) * 0.5f, viewport->WorkSize.y * 0.5f); + ImGui::SetNextWindowPos(mainAreaCenter, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSize(ImVec2(kPopupWidth, 0), ImGuiCond_Appearing); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(24, 20)); + if (ImGui::BeginPopupModal("Create New Project", &m_ShowCreateDialog, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) + { + // --- Header --- + ImGui::SetWindowFontScale(1.2f); + ImGui::TextColored(ImVec4(0.2f, 0.7f, 1.0f, 1.0f), ICON_FA_FILE " New Project"); + ImGui::SetWindowFontScale(1.0f); + ImGui::Dummy(ImVec2(0, 4)); + ImGui::Separator(); + ImGui::Dummy(ImVec2(0, 8)); + + // --- Project Name --- + ImGui::TextDisabled("PROJECT NAME"); + ImGui::SetNextItemWidth(kInputWidth); + ImGui::InputText("##ProjectName", m_ProjectNameBuffer, kNameBufSize); + + bool nameEmpty = (m_ProjectNameBuffer[0] == '\0'); + if (nameEmpty) + { + ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.45f, 1.0f), + ICON_FA_CIRCLE_EXCLAMATION " Name cannot be empty"); + } + else + { + ImGui::Dummy(ImVec2(0, ImGui::GetTextLineHeight())); + } + + ImGui::Dummy(ImVec2(0, 6)); + + // --- Location --- + ImGui::TextDisabled("LOCATION"); + ImGui::SetNextItemWidth(360); + ImGui::InputText("##ProjectLocation", m_ProjectLocationBuffer, kLocationBufSize); + ImGui::SameLine(0, 8); + ImGui::PushStyleColor(ImGuiCol_Button, EditorColors::ProjectCardBorder); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, EditorColors::ProjectCardBorderHover); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, EditorColors::ProjectCardBorderActive); + if (ImGui::Button(ICON_FA_FOLDER_OPEN " Browse", ImVec2(64, 0))) + { + auto picked = Dialogs::PickFolder(); + if (picked) + { + SafeCopy(m_ProjectLocationBuffer, kLocationBufSize, picked->string().c_str()); + } + } + ImGui::PopStyleColor(3); + + // --- Path preview box --- + ImGui::Dummy(ImVec2(0, 10)); + std::filesystem::path previewPath = + std::filesystem::path(m_ProjectLocationBuffer) / m_ProjectNameBuffer; + + ImGui::PushStyleColor(ImGuiCol_ChildBg, EditorColors::SubCardBg); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 4.0f); + ImGui::BeginChild("##preview", ImVec2(kInputWidth, 36), false); + ImGui::SetCursorPos(ImVec2(10, 10)); + ImGui::TextColored(EditorColors::MutedText, ICON_FA_CIRCLE_INFO " "); + ImGui::SameLine(0, 0); + ImGui::TextDisabled("%s", previewPath.string().c_str()); + ImGui::EndChild(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + + // --- Buttons --- + ImGui::Dummy(ImVec2(0, 12)); + ImGui::Separator(); + ImGui::Dummy(ImVec2(0, 8)); + + ImGui::SetCursorPosX(kInputWidth + 24 - kBtnWidth * 2 - 8); + + ImGui::PushStyleColor(ImGuiCol_Button, EditorColors::SubCardBorder); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, EditorColors::SubCardBorderHover); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, EditorColors::SubCardBorderActive); + if (ImGui::Button("Cancel", ImVec2(kBtnWidth, kBtnHeight))) + { + m_ShowCreateDialog = false; + ImGui::CloseCurrentPopup(); + } + ImGui::PopStyleColor(3); + + ImGui::SameLine(0, 8); + + ImGui::BeginDisabled(nameEmpty); + ImGui::PushStyleColor(ImGuiCol_Button, EditorColors::PrimaryButton); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, EditorColors::PrimaryButtonHover); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, EditorColors::PrimaryButtonActive); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 1.0f, 1.0f)); + if (ImGui::Button(ICON_FA_FOLDER " Create", ImVec2(kBtnWidth, kBtnHeight))) + { + m_ProjectManager.NewProject(m_ProjectNameBuffer, previewPath.string()); + m_ShowCreateDialog = false; + ImGui::CloseCurrentPopup(); + } + ImGui::PopStyleColor(4); + ImGui::EndDisabled(); + + ImGui::EndPopup(); + } + ImGui::PopStyleVar(); + } + + ImGui::End(); + ImGui::PopStyleVar(3); + } + +} // namespace Chained \ No newline at end of file diff --git a/editor/ui/project_selector_ui.h b/editor/ui/project_selector_ui.h new file mode 100644 index 000000000..bb37ae15d --- /dev/null +++ b/editor/ui/project_selector_ui.h @@ -0,0 +1,37 @@ +#ifndef CH_PROJECT_SELECTOR_UI_H +#define CH_PROJECT_SELECTOR_UI_H + +#include "editor/project_manager.h" +#include +#include +#include + +namespace Chained +{ + class TextureAsset; + + class ProjectSelectorUI + { + public: + ProjectSelectorUI(EditorProjectManager& projectManager); + + void OnImGuiRender(); + + private: + EditorProjectManager& m_ProjectManager; + + std::shared_ptr m_NewProjectIcon = nullptr; + std::shared_ptr m_OpenProjectIcon = nullptr; + bool m_IconsLoaded = false; + + bool m_ShowCreateDialog = false; + char m_ProjectNameBuffer[128] = "NewProject"; + char m_ProjectLocationBuffer[256] = ""; + bool m_Initialized = false; + + void LoadEditorIcons(); + }; + +} // namespace Chained + +#endif // CH_PROJECT_SELECTOR_UI_H \ No newline at end of file diff --git a/editor/ui_properties.cpp b/editor/ui_properties.cpp new file mode 100644 index 000000000..0b5faa58e --- /dev/null +++ b/editor/ui_properties.cpp @@ -0,0 +1,100 @@ +#include "ui_properties.h" +#include +#include + +namespace Chained +{ + + bool UIProperties::EnumPropertyInternal(const char* name, int& value, const char** names, int count, + const PropertyMeta& meta) + { + ImGui::BeginDisabled(meta.ReadOnly); + bool changed = EditorGUI::Property(name, value, names, count); + ImGui::EndDisabled(); + if (!meta.Tooltip.empty() && ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", meta.Tooltip.c_str()); + } + UpdateState(changed); + return changed; + } + + bool UIProperties::StringEnumInternal(const char* name, std::string& value, const std::vector& options, + const PropertyMeta& meta) + { + int currentIndex = 0; + for (size_t i = 0; i < options.size(); ++i) + { + if (options[i] == value) + { + currentIndex = (int)i; + break; + } + } + + std::vector optionNames; + for (const auto& option : options) + { + optionNames.push_back(option.c_str()); + } + + bool changed = false; + EditorGUI::DrawPropertyLabel(name); + ImGui::PushID(name); + ImGui::BeginDisabled(meta.ReadOnly); + ImGui::SetNextItemWidth(-1); + if (ImGui::Combo("##prop", ¤tIndex, optionNames.data(), (int)optionNames.size())) + { + value = (currentIndex >= 0 && currentIndex < (int)options.size()) ? options[currentIndex] : std::string(); + changed = true; + } + ImGui::EndDisabled(); + ImGui::PopID(); + + if (!meta.Tooltip.empty() && ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", meta.Tooltip.c_str()); + } + UpdateState(changed); + return changed; + } + + void UIProperties::HeaderInternal(const char* label) + { + if (ImGui::GetCurrentTable() != nullptr) + { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::AlignTextToFramePadding(); + } + ImGui::Spacing(); + ImGui::TextColored({0.2f, 0.7f, 0.9f, 1.0f}, "%s", label); + if (ImGui::GetCurrentTable() != nullptr) + { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + } + } + + void UIProperties::SeparatorInternal() + { + ImGui::Separator(); + } + + void UIProperties::UpdateState(bool changed) + { + if (changed) + { + m_Changed = true; + } + if (ImGui::IsItemActivated()) + { + m_Started = true; + } + if (ImGui::IsItemDeactivatedAfterEdit()) + { + m_Finished = true; + } + } + +} // namespace Chained diff --git a/editor/ui_properties.h b/editor/ui_properties.h index 75ddfe812..b5fbeb3c0 100644 --- a/editor/ui_properties.h +++ b/editor/ui_properties.h @@ -1,372 +1,357 @@ #ifndef CH_UI_PROPERTIES_H #define CH_UI_PROPERTIES_H -#include "editor_gui.h" -#include "engine/core/reflection.h" -#include "scripting/scriptengine.h" -#include "IconsFontAwesome6.h" +#include "thirdparty/IconsFontAwesome6.h" +#include "gui.h" +#include "engine/reflection/reflection.h" #include "imgui.h" #include "imgui_internal.h" #include +#include +#include #include +#include +#include -namespace CHEngine +namespace Chained { -// Implementation of the Archive concept for ImGui UI -class UIProperties -{ -public: - UIProperties() = default; - - template bool Property(const char* name, T& value) - { - bool changed = false; - if constexpr (is_variant_v) - { - changed = std::visit([&](auto&& v) { return EditorGUI::Property(name, v); }, value); - } - else if constexpr (std::is_same_v) - { - changed = EditorGUI::Property(name, *(glm::vec4*)&value); - } - else - { - changed = EditorGUI::Property(name, value); - } - - if (changed) m_Changed = true; - if (ImGui::IsItemActivated()) m_Started = true; - if (ImGui::IsItemDeactivatedAfterEdit()) m_Finished = true; - - return changed; - } - - // Overload for enums - bool Property(const char* name, int& value, const char** names, int count) - { - bool changed = EditorGUI::Property(name, value, names, count); - if (changed) m_Changed = true; - if (ImGui::IsItemActivated()) m_Started = true; - if (ImGui::IsItemDeactivatedAfterEdit()) m_Finished = true; - return changed; - } - - // --- Property methods with metadata --- - template bool Property(const char* name, T& value, const PropertyMeta& meta) - { - bool changed = false; - - // Use metadata hint to select widget - if constexpr (std::is_same_v) - { - if (meta.Hint == PropertyMeta::WidgetHint::Slider && meta.MaxValue > meta.MinValue) - { - changed = ImGui::SliderFloat(name, &value, meta.MinValue, meta.MaxValue); - } - else if (meta.Hint == PropertyMeta::WidgetHint::Default) - { - changed = ImGui::DragFloat(name, &value, meta.Speed); - } - else - { - changed = ImGui::InputFloat(name, &value); - } - } - else if constexpr (std::is_same_v) - { - if (meta.Hint == PropertyMeta::WidgetHint::Slider && meta.MaxValue > meta.MinValue) - { - changed = ImGui::SliderInt(name, &value, (int)meta.MinValue, (int)meta.MaxValue); - } - else - { - changed = EditorGUI::Property(name, value); - } - } - else if constexpr (std::is_same_v) - { - if (meta.Hint == PropertyMeta::WidgetHint::Enum && std::string_view(name) == "ClassName") - { - std::vector options; - options.emplace_back("-- Select script --"); - - for (const auto& [scriptName, scriptType] : ScriptEngine::Get().GetScriptClasses()) - { - (void)scriptType; - options.emplace_back(scriptName); - } - - if (options.size() > 2) - { - std::sort(options.begin() + 1, options.end()); - } - - int currentIndex = 0; - for (size_t i = 1; i < options.size(); ++i) - { - if (options[i] == value) - { - currentIndex = (int)i; - break; - } - } - - std::vector optionNames; - optionNames.reserve(options.size()); - for (const auto& option : options) - { - optionNames.push_back(option.c_str()); - } - - if (ImGui::Combo(name, ¤tIndex, optionNames.data(), (int)optionNames.size())) - { - value = (currentIndex > 0 && currentIndex < (int)options.size()) ? options[currentIndex] : std::string(); - changed = true; - } - } - else - { - changed = EditorGUI::Property(name, value); - } - } - else - { - // Fall back to default for other types - changed = EditorGUI::Property(name, value); - } - - if (changed) m_Changed = true; - if (ImGui::IsItemActivated()) m_Started = true; - if (ImGui::IsItemDeactivatedAfterEdit()) m_Finished = true; - return changed; - } - - // Enum with metadata - bool Property(const char* name, int& value, const char** names, int count, const PropertyMeta& meta) - { - bool changed = EditorGUI::Property(name, value, names, count); - if (changed) m_Changed = true; - if (ImGui::IsItemActivated()) m_Started = true; - if (ImGui::IsItemDeactivatedAfterEdit()) m_Finished = true; - return changed; - } - - // File with metadata - bool File(const char* name, std::string& path, const char* extensions, const PropertyMeta& meta) - { - bool changed = EditorGUI::FileProperty(name, path, extensions); - if (changed) m_Changed = true; - if (ImGui::IsItemActivated()) m_Started = true; - if (ImGui::IsItemDeactivatedAfterEdit()) m_Finished = true; - return changed; - } - - bool Handle(const char* name, uint64_t& value) - { - bool changed = EditorGUI::Property(name, value); - if (changed) m_Changed = true; - if (ImGui::IsItemActivated()) m_Started = true; - if (ImGui::IsItemDeactivatedAfterEdit()) m_Finished = true; - return changed; - } - - bool File(const char* name, std::string& path, const char* extensions = nullptr) - { - bool changed = EditorGUI::FileProperty(name, path, extensions); - if (changed) m_Changed = true; - if (ImGui::IsItemActivated()) m_Started = true; - if (ImGui::IsItemDeactivatedAfterEdit()) m_Finished = true; - return changed; - } - - void Action(const char* label, std::function func) - { - if (EditorGUI::ActionButton(nullptr, label)) - { - func(); - } - } - - template bool Sequence(const char* name, std::vector& values) - { - if (ImGui::GetCurrentTable() != nullptr) - { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImGui::AlignTextToFramePadding(); - } - - bool localChanged = false; - ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_Framed | ImGuiTreeNodeFlags_SpanAvailWidth; - if (ImGui::GetCurrentTable() != nullptr) flags |= ImGuiTreeNodeFlags_SpanAllColumns; - - if (ImGui::TreeNodeEx(name, flags)) - { - for (size_t i = 0; i < (int)values.size(); i++) - { - ImGui::PushID((int)i); - - if (ImGui::GetCurrentTable() != nullptr) - { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(1); - } - - if (ImGui::Button(ICON_FA_TRASH)) - { - values.erase(values.begin() + i); - m_Changed = true; - localChanged = true; - ImGui::PopID(); - break; - } - - ImGui::SameLine(); - - if constexpr (requires(T t, Properties& p) { t.Reflect(p); }) - { - char label[32]; - sprintf(label, "Item %d", (int)i); - // Use a nested tree node for complex items - if (ImGui::TreeNodeEx(label, ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_SpanAvailWidth)) - { - Properties itemProps(*this); - values[i].Reflect(itemProps); - ImGui::TreePop(); - } - } - else if constexpr (std::is_same_v) - { - char buf[256]; - strncpy(buf, values[i].c_str(), sizeof(buf) - 1); - ImGui::SetNextItemWidth(-1); - if (ImGui::InputText("##val", buf, sizeof(buf))) - { - values[i] = buf; - m_Changed = true; - localChanged = true; - } - } - else - { - ImGui::Text("Item %d", (int)i); - } - - ImGui::PopID(); - if constexpr (!requires(T t, Properties& p) { t.Reflect(p); }) - ImGui::Separator(); - } - - ImGui::Spacing(); - if (ImGui::GetCurrentTable() != nullptr) - { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(1); - } - - if (ImGui::Button(ICON_FA_PLUS " Add New Item", ImVec2(-1, 0))) - { - values.push_back({}); - m_Changed = true; - localChanged = true; - } - - ImGui::TreePop(); - } - return localChanged; - } - - template bool Nested(const char* name, T& value) - { - bool localChanged = false; - if (ImGui::TreeNodeEx(name, ImGuiTreeNodeFlags_Framed | ImGuiTreeNodeFlags_DefaultOpen)) - { - Properties itemProps(*this); - value.Reflect(itemProps); - ImGui::TreePop(); - } - return localChanged; - } - - void Header(const char* label) - { - if (ImGui::GetCurrentTable() != nullptr) - { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImGui::AlignTextToFramePadding(); - } - ImGui::Spacing(); - ImGui::TextColored({0.2f, 0.7f, 0.9f, 1.0f}, "%s", label); - ImGui::Separator(); - if (ImGui::GetCurrentTable() != nullptr) - { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - } - } - - void Separator() - { - ImGui::Separator(); - } - - bool BeginGroup(const char* label, bool defaultOpen = true) - { - if (ImGui::GetCurrentTable() != nullptr) - { - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImGui::AlignTextToFramePadding(); - } - - ImGuiTreeNodeFlags flags = - ImGuiTreeNodeFlags_Framed | ImGuiTreeNodeFlags_AllowOverlap | ImGuiTreeNodeFlags_FramePadding | - ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_SpanAllColumns; - if (defaultOpen) - { - flags |= ImGuiTreeNodeFlags_DefaultOpen; - } - - bool opened = ImGui::TreeNodeEx(label, flags); - - if (ImGui::GetCurrentTable() != nullptr) - { - // Move to the next column to ensure the header row context is technically "complete" - // although the next property will start a fresh row with TableNextRow(). - ImGui::TableSetColumnIndex(1); - } - - return opened; - } - - void EndGroup() - { - ImGui::TreePop(); - } - - bool HasFinished() const { return m_Started && m_Finished; } - bool HasStarted() const { return m_Started; } - - bool HasChanged() const - { - return m_Changed; - } - void SetChanged(bool changed) - { - m_Changed = changed; - } - ReflectionMode GetReflectionMode() const - { - return ReflectionMode::UI; - } - -private: - bool m_Changed = false; - bool m_Started = false; - bool m_Finished = false; -}; -} // namespace CHEngine + // Implementation of the Archive concept for ImGui UI + class UIProperties : public IPropertyArchive + { + public: + UIProperties() = default; + + virtual ReflectionMode GetReflectionMode() const override + { + return ReflectionMode::UI; + } + virtual bool HasChanged() const override + { + return m_Changed; + } + virtual void SetChanged(bool changed) override + { + m_Changed = changed; + } + + // IPropertyArchive overrides + virtual bool Property(const char* name, int& value, const PropertyMeta& meta = {}) override + { + return PropertyInternal(name, value, meta); + } + virtual bool Property(const char* name, float& value, const PropertyMeta& meta = {}) override + { + return PropertyInternal(name, value, meta); + } + virtual bool Property(const char* name, bool& value, const PropertyMeta& meta = {}) override + { + return PropertyInternal(name, value, meta); + } + virtual bool Property(const char* name, std::string& value, const PropertyMeta& meta = {}) override + { + return PropertyInternal(name, value, meta); + } + virtual bool Property(const char* name, glm::vec2& value, const PropertyMeta& meta = {}) override + { + return PropertyInternal(name, value, meta); + } + virtual bool Property(const char* name, glm::vec3& value, const PropertyMeta& meta = {}) override + { + return PropertyInternal(name, value, meta); + } + virtual bool Property(const char* name, glm::vec4& value, const PropertyMeta& meta = {}) override + { + return PropertyInternal(name, value, meta); + } + virtual bool Property(const char* name, Color& value, const PropertyMeta& meta = {}) override + { + return PropertyInternal(name, value, meta); + } + virtual bool Enum(const char* name, int& value, const char** names, int count, + const PropertyMeta& meta = {}) override + { + return EnumPropertyInternal(name, value, names, count, meta); + } + + virtual bool StringEnum(const char* name, std::string& value, const std::vector& options, + const PropertyMeta& meta = {}) override + { + return StringEnumInternal(name, value, options, meta); + } + + virtual bool Property(const char* name, uint64_t& value, const PropertyMeta& meta = {}) override + { + return Handle(name, value, meta); + } + virtual bool Handle(const char* name, uint64_t& value, const PropertyMeta& meta = {}) override + { + bool changed = EditorGUI::Property(name, value); + UpdateState(changed); + return changed; + } + virtual bool File(const char* name, std::string& value, const char* extensions = nullptr, + const PropertyMeta& meta = {}) override + { + ImGui::BeginDisabled(meta.ReadOnly); + bool changed = EditorGUI::FileProperty(name, value, extensions); + ImGui::EndDisabled(); + if (!meta.Tooltip.empty() && ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", meta.Tooltip.c_str()); + } + UpdateState(changed); + return changed; + } + virtual void Header(const char* label) override + { + HeaderInternal(label); + } + virtual void Separator() override + { + SeparatorInternal(); + } + bool HasFinished() const + { + return m_Started && m_Finished; + } + bool HasStarted() const + { + return m_Started; + } + + // Template methods for non-virtual calls (still used by Properties) + template bool Property(const char* name, T& value) + { + return PropertyInternal(name, value, {}); + } + template bool Property(const char* name, T_Enum& value, const char** names, int count) + { + return EnumPropertyInternal(name, (int&)value, names, count, {}); + } + template bool Property(const char* name, T& value, const PropertyMeta& meta) + { + return PropertyInternal(name, value, meta); + } + template + bool Property(const char* name, T_Enum& value, const char** names, int count, const PropertyMeta& meta) + { + return EnumPropertyInternal(name, (int&)value, names, count, meta); + } + + virtual void BeginSequence(const char* name, size_t& size) override + { + if (ImGui::GetCurrentTable() != nullptr) + { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::AlignTextToFramePadding(); + } + + ImGuiTreeNodeFlags flags = + ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_Framed | ImGuiTreeNodeFlags_SpanAvailWidth; + if (ImGui::GetCurrentTable() != nullptr) + { + flags |= ImGuiTreeNodeFlags_SpanAllColumns; + } + + m_InSequence = ImGui::TreeNodeEx(name, flags); + if (m_InSequence && ImGui::GetCurrentTable() != nullptr) + { + ImGui::TableSetColumnIndex(1); + } + } + + virtual void EndSequence() override + { + if (m_InSequence) + { + ImGui::TreePop(); + m_InSequence = false; + } + } + + virtual bool Nested(const char* name, std::function callback) override + { + if (ImGui::TreeNodeEx(name, ImGuiTreeNodeFlags_Framed | ImGuiTreeNodeFlags_DefaultOpen)) + { + callback(*this); + ImGui::TreePop(); + return true; // Simplified: assume changed if we opened and called callback + } + return false; + } + + virtual void BeginMap(const char* name, size_t& size) override + { + // Not used directly in UI mode — Map() template handles rendering + } + + virtual void EndMap() override + { + } + + virtual bool MapNextKey(std::string& key) override + { + return false; + } + + template bool Map(const char* name, std::unordered_map& map) + { + if (ImGui::GetCurrentTable() != nullptr) + { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::AlignTextToFramePadding(); + } + + bool localChanged = false; + ImGuiTreeNodeFlags flags = + ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_Framed | ImGuiTreeNodeFlags_SpanAvailWidth; + if (ImGui::GetCurrentTable() != nullptr) + { + flags |= ImGuiTreeNodeFlags_SpanAllColumns; + } + + if (ImGui::TreeNodeEx(name, flags)) + { + auto it = map.begin(); + while (it != map.end()) + { + ImGui::PushID(it->first.c_str()); + + if (ImGui::GetCurrentTable() != nullptr) + { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(1); + } + + if (ImGui::Button(ICON_FA_TRASH)) + { + it = map.erase(it); + m_Changed = localChanged = true; + ImGui::PopID(); + continue; + } + + ImGui::SameLine(); + + // Key input + char keyBuf[128]; + strncpy(keyBuf, it->first.c_str(), sizeof(keyBuf)); + keyBuf[sizeof(keyBuf) - 1] = '\0'; + ImGui::SetNextItemWidth(120); + if (ImGui::InputText("##key", keyBuf, sizeof(keyBuf))) + { + std::string newKey = keyBuf; + if (newKey != it->first && map.find(newKey) == map.end()) + { + float val = it->second; + auto next = map.erase(it); + it = map.insert_or_assign(next, newKey, val).first; + m_Changed = localChanged = true; + } + ImGui::PopID(); + continue; + } + + ImGui::SameLine(); + ImGui::Text("="); + ImGui::SameLine(); + + // Value input + float val = it->second; + ImGui::SetNextItemWidth(-1); + if (ImGui::DragFloat("##val", &val, 0.01f)) + { + it->second = val; + m_Changed = localChanged = true; + } + + ++it; + ImGui::PopID(); + ImGui::Separator(); + } + + ImGui::Spacing(); + if (ImGui::GetCurrentTable() != nullptr) + { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(1); + } + + if (EditorGUI::ActionButton(ICON_FA_PLUS, "Add Variable")) + { + std::string newKey = "var_" + std::to_string(map.size()); + map[newKey] = 0.0f; + m_Changed = localChanged = true; + } + ImGui::TreePop(); + } + return localChanged; + } + + private: + bool m_InSequence = false; + + public: + // --- Legacy types or internal helpers if needed --- + // Note: Most templates are now handled by the base class Properties. + + private: + template bool PropertyInternal(const char* name, T& value, const PropertyMeta& meta) + { + bool changed = false; + ImGui::BeginDisabled(meta.ReadOnly); + if constexpr (std::is_same_v) + { + changed = EditorGUI::Property(name, value, meta.Speed, meta.MinValue, meta.MaxValue); + } + else if constexpr (std::is_same_v) + { + changed = EditorGUI::Property(name, value, (int)meta.MinValue, (int)meta.MaxValue); + } + else if constexpr (std::is_same_v) + { + changed = EditorGUI::Property(name, value); + } + else if constexpr (is_variant_v) + { + changed = std::visit([&](auto&& v) { return EditorGUI::Property(name, v); }, value); + } + else if constexpr (std::is_same_v) + { + changed = EditorGUI::Property(name, *(glm::vec4*)&value); + } + else + { + if constexpr (requires { EditorGUI::Property(name, value); }) + { + changed = EditorGUI::Property(name, value); + } + } + ImGui::EndDisabled(); + + if (!meta.Tooltip.empty() && ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", meta.Tooltip.c_str()); + } + UpdateState(changed); + return changed; + } + + bool EnumPropertyInternal(const char* name, int& value, const char** names, int count, + const PropertyMeta& meta); + bool StringEnumInternal(const char* name, std::string& value, const std::vector& options, + const PropertyMeta& meta); + void HeaderInternal(const char* label); + void SeparatorInternal(); + void UpdateState(bool changed); + + bool m_Changed = false; + bool m_Started = false; + bool m_Finished = false; + }; +} // namespace Chained #endif // CH_UI_PROPERTIES_H diff --git a/editor/undo/command.h b/editor/undo/command.h new file mode 100644 index 000000000..9cc36afab --- /dev/null +++ b/editor/undo/command.h @@ -0,0 +1,22 @@ +#ifndef CH_EDITOR_COMMAND_H +#define CH_EDITOR_COMMAND_H + +#include + +namespace Chained +{ + + class IEditorCommand + { + public: + virtual ~IEditorCommand() = default; + + virtual void Execute() = 0; + + virtual void Undo() = 0; + + virtual std::string GetName() const = 0; + }; +} // namespace Chained + +#endif // CH_EDITOR_COMMAND_H diff --git a/editor/undo/command_history.cpp b/editor/undo/command_history.cpp index c441c6767..13c1905dc 100644 --- a/editor/undo/command_history.cpp +++ b/editor/undo/command_history.cpp @@ -1,92 +1,62 @@ #include "command_history.h" #include "engine/core/log.h" -namespace CHEngine +namespace Chained { -CommandHistory::CommandHistory(size_t maxHistory) - : m_MaxHistory(maxHistory) -{ -} - -void CommandHistory::PushCommand(std::unique_ptr command) -{ - if (!command) - { - return; - } - - command->Execute(); - m_RedoStack.clear(); - m_UndoStack.push_back(std::move(command)); - - if (m_UndoStack.size() > m_MaxHistory) - { - m_UndoStack.pop_front(); - } - - CH_CORE_INFO("Command pushed: {} (Undo stack size: {})", m_UndoStack.back()->GetName(), m_UndoStack.size()); - - Notify(); -} + CommandHistory::CommandHistory(size_t maxHistory) + : m_MaxHistory(maxHistory) + { + } -void CommandHistory::Undo() -{ - if (m_UndoStack.empty()) - { - return; - } + void CommandHistory::PushCommand(std::unique_ptr command) + { + if (!command) + { + return; + } - std::unique_ptr command = std::move(m_UndoStack.back()); - m_UndoStack.pop_back(); + command->Execute(); + m_RedoStack.clear(); + m_UndoStack.push_back(std::move(command)); - CH_CORE_INFO("Undoing command: {}", command->GetName()); - command->Undo(); + if (m_UndoStack.size() > m_MaxHistory) + { + m_UndoStack.pop_front(); + } - m_RedoStack.push_back(std::move(command)); + CH_CORE_INFO("Command pushed: {} (Undo stack size: {})", m_UndoStack.back()->GetName(), m_UndoStack.size()); + } - Notify(); -} + void CommandHistory::Undo() + { + if (m_UndoStack.empty()) + { + return; + } -void CommandHistory::Redo() -{ - if (m_RedoStack.empty()) - { - return; - } + std::unique_ptr command = std::move(m_UndoStack.back()); + m_UndoStack.pop_back(); - std::unique_ptr command = std::move(m_RedoStack.back()); - m_RedoStack.pop_back(); + CH_CORE_INFO("Undoing command: {}", command->GetName()); + command->Undo(); - CH_CORE_INFO("Redoing command: {}", command->GetName()); - command->Execute(); + m_RedoStack.push_back(std::move(command)); + } - m_UndoStack.push_back(std::move(command)); + void CommandHistory::Redo() + { + if (m_RedoStack.empty()) + { + return; + } - Notify(); -} + std::unique_ptr command = std::move(m_RedoStack.back()); + m_RedoStack.pop_back(); -void CommandHistory::Clear() -{ - m_UndoStack.clear(); - m_RedoStack.clear(); - Notify(); -} + CH_CORE_INFO("Redoing command: {}", command->GetName()); + command->Execute(); -std::string CommandHistory::GetUndoName() const -{ - return m_UndoStack.empty() ? "" : m_UndoStack.back()->GetName(); -} + m_UndoStack.push_back(std::move(command)); + } -std::string CommandHistory::GetRedoName() const -{ - return m_RedoStack.empty() ? "" : m_RedoStack.back()->GetName(); -} - -void CommandHistory::Notify() -{ - if (m_NotifyCallback) - { - m_NotifyCallback(); - } -} -} // namespace CHEngine +} // namespace Chained diff --git a/editor/undo/command_history.h b/editor/undo/command_history.h index 498d19ea4..28790ec25 100644 --- a/editor/undo/command_history.h +++ b/editor/undo/command_history.h @@ -2,52 +2,28 @@ #define CH_COMMAND_HISTORY_H #include "deque" -#include "editor_command.h" +#include "command.h" #include #include #include -namespace CHEngine +namespace Chained { -class CommandHistory -{ -public: - using CommandEventCallback = std::function; - - CommandHistory(size_t maxHistory = 50); - ~CommandHistory() = default; - - void PushCommand(std::unique_ptr command); - void Undo(); - void Redo(); - void Clear(); - - bool CanUndo() const - { - return !m_UndoStack.empty(); - } - bool CanRedo() const - { - return !m_RedoStack.empty(); - } - - std::string GetUndoName() const; - std::string GetRedoName() const; - - void SetNotifyCallback(CommandEventCallback callback) - { - m_NotifyCallback = callback; - } - -private: - void Notify(); - -private: - size_t m_MaxHistory; - std::deque> m_UndoStack; - std::deque> m_RedoStack; - CommandEventCallback m_NotifyCallback; -}; -} // namespace CHEngine + class CommandHistory + { + public: + CommandHistory(size_t maxHistory = 50); + ~CommandHistory() = default; + + void PushCommand(std::unique_ptr command); + void Undo(); + void Redo(); + + private: + size_t m_MaxHistory; + std::deque> m_UndoStack; + std::deque> m_RedoStack; + }; +} // namespace Chained #endif // CH_COMMAND_HISTORY_H diff --git a/editor/undo/component_commands.h b/editor/undo/component_commands.h index 440efaa8f..53f5a1950 100644 --- a/editor/undo/component_commands.h +++ b/editor/undo/component_commands.h @@ -1,96 +1,82 @@ #ifndef CH_COMPONENT_COMMANDS_H #define CH_COMPONENT_COMMANDS_H -#include "editor_command.h" +#include "command.h" + #include "engine/scene/scene.h" #include -namespace CHEngine -{ - -template -class AddComponentCommand : public IEditorCommand +namespace Chained { -public: - AddComponentCommand(Entity entity) - : m_Entity(entity) - { - } - - void Execute() override - { - if (Validate() && !m_Entity.HasComponent()) - { - m_Entity.AddComponent(); - } - } - - void Undo() override - { - if (Validate() && m_Entity.HasComponent()) - { - m_Entity.RemoveComponent(); - } - } - - std::string GetName() const override - { - return "Add Component"; - } - -private: - bool Validate() - { - if (!m_Entity) return false; - auto* registry = &m_Entity.GetRegistry(); - return registry->valid(static_cast(m_Entity)); - } - - Entity m_Entity; -}; - -template -class RemoveComponentCommand : public IEditorCommand -{ -public: - RemoveComponentCommand(Entity entity) - : m_Entity(entity), m_ComponentState(entity.GetComponent()) - { - } - - void Execute() override - { - if (Validate() && m_Entity.HasComponent()) - { - m_Entity.RemoveComponent(); - } - } - - void Undo() override - { - if (Validate() && !m_Entity.HasComponent()) - { - m_Entity.AddComponent(m_ComponentState); - } - } - - std::string GetName() const override - { - return "Remove Component"; - } - -private: - bool Validate() - { - if (!m_Entity) return false; - auto* registry = &m_Entity.GetRegistry(); - return registry->valid(static_cast(m_Entity)); - } - - Entity m_Entity; - T m_ComponentState; -}; -} // namespace CHEngine + template class AddComponentCommand : public IEditorCommand + { + public: + AddComponentCommand(Entity entity) + : m_Entity(entity) + { + } + + void Execute() override + { + if (m_Entity.IsValid() && !m_Entity.HasComponent()) + { + m_Entity.AddComponent(); + } + } + + void Undo() override + { + if (m_Entity.IsValid() && m_Entity.HasComponent()) + { + m_Entity.RemoveComponent(); + } + } + + std::string GetName() const override + { + return "Add Component"; + } + + private: + Entity m_Entity; + }; + + template class RemoveComponentCommand : public IEditorCommand + { + public: + RemoveComponentCommand(Entity entity) + : m_Entity(entity), + m_ComponentState(entity.GetComponent()) + { + } + + void Execute() override + { + if (m_Entity.IsValid() && m_Entity.HasComponent()) + { + m_Entity.RemoveComponent(); + } + } + + void Undo() override + { + if (m_Entity.IsValid() && !m_Entity.HasComponent()) + { + m_Entity.AddComponent(m_ComponentState); + } + } + + std::string GetName() const override + { + return "Remove Component"; + } + + private: + Entity m_Entity; + T m_ComponentState; + }; + +} // namespace Chained #endif // CH_COMPONENT_COMMANDS_H diff --git a/editor/undo/editor_command.h b/editor/undo/editor_command.h deleted file mode 100644 index 67d4ab516..000000000 --- a/editor/undo/editor_command.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifndef CH_EDITOR_COMMAND_H -#define CH_EDITOR_COMMAND_H - -#include - -namespace CHEngine -{ -class IEditorCommand -{ -public: - virtual ~IEditorCommand() = default; - - virtual void Execute() = 0; - - virtual void Undo() = 0; - - virtual std::string GetName() const = 0; -}; -} // namespace CHEngine - -#endif // CH_EDITOR_COMMAND_H diff --git a/editor/undo/entity_commands.h b/editor/undo/entity_commands.h index a801248ca..8c987c747 100644 --- a/editor/undo/entity_commands.h +++ b/editor/undo/entity_commands.h @@ -1,216 +1,278 @@ #ifndef CH_ENTITY_COMMANDS_H #define CH_ENTITY_COMMANDS_H -#include "editor_command.h" +#include "command.h" +#include "editor/layer.h" #include "engine/scene/components.h" #include "engine/scene/scene.h" #include "engine/scene/component_serializer.h" #include -namespace CHEngine +namespace Chained { -class DestroyEntityCommand : public IEditorCommand -{ -public: - DestroyEntityCommand(Entity entity) - : m_Entity(entity), - m_Scene(entity.GetRegistry().ctx().get()) - { - } - - void Execute() override - { - CH_CORE_INFO("Destroying entity via command: {}", m_Entity.GetComponent().Tag); - - m_UUID = m_Entity.GetUUID(); - - // Serialize the entity before destroying - YAML::Emitter out; - out << YAML::BeginMap; - ComponentSerializer::Get().SerializeID(out, m_Entity); - ComponentSerializer::Get().SerializeAll(out, m_Entity); - out << YAML::EndMap; - m_SerializedData = out.c_str(); - - m_Scene->DestroyEntity(m_Entity); - } - - void Undo() override - { - CH_CORE_INFO("Undoing DestroyEntity, restoring UUID: {}", m_UUID); - YAML::Node node = YAML::Load(m_SerializedData); - - std::string name = "Restored Entity"; - auto tagComponent = node["TagComponent"]; - if (tagComponent && tagComponent["Tag"] && tagComponent["Tag"].IsScalar()) { - name = tagComponent["Tag"].as(); - } - - m_Entity = m_Scene->CreateEntityWithUUID(m_UUID, name); - ComponentSerializer::Get().DeserializeAll(m_Entity, node); - } - - std::string GetName() const override - { - return "Destroy Entity"; - } - -private: - Entity m_Entity; - Scene* m_Scene; - uint64_t m_UUID; - std::string m_SerializedData; -}; - -class CreateEntityCommand : public IEditorCommand -{ -public: - CreateEntityCommand(Scene* scene, const std::string& name, const std::string& modelPath = "") - : m_Scene(scene), - m_Name(name), - m_ModelPath(modelPath) - { - } - - void Execute() override - { - m_Entity = m_Scene->CreateEntity(m_Name); - if (!m_ModelPath.empty()) - { - auto& mc = m_Entity.AddComponent(); - mc.ModelPath = m_ModelPath; - } - } - - void Undo() override - { - if (m_Entity) - { - m_Scene->DestroyEntity(m_Entity); - } - } - - std::string GetName() const override - { - return "Create Entity"; - } - -private: - Scene* m_Scene; - std::string m_Name; - std::string m_ModelPath; - Entity m_Entity; -}; - -class DuplicateEntityCommand : public IEditorCommand -{ -public: - DuplicateEntityCommand(Entity entity) - : m_SourceEntity(entity), - m_Scene(entity.GetRegistry().ctx().get()) - { - } - - void Execute() override - { - m_DuplicateEntity = m_Scene->CopyEntity(m_SourceEntity); - } - - void Undo() override - { - if (m_DuplicateEntity) - { - m_Scene->DestroyEntity(m_DuplicateEntity); - } - } - - std::string GetName() const override - { - return "Duplicate Entity"; - } - -private: - Entity m_SourceEntity; - Entity m_DuplicateEntity; - Scene* m_Scene; -}; - -class ParentEntityCommand : public IEditorCommand -{ -public: - ParentEntityCommand(Entity entity, Entity newParent, Scene* scene) - : m_Entity(entity), m_NewParent(newParent), m_Scene(scene) - { - if (m_Entity && m_Entity.HasComponent()) - { - auto parentID = m_Entity.GetComponent().Parent; - if (parentID != entt::null) - m_OldParent = Entity(parentID, m_Entity.GetRegistryPtr()); - } - } - - void Execute() override - { - SetParent(m_Entity, m_NewParent); - } - - void Undo() override - { - SetParent(m_Entity, m_OldParent); - } - - std::string GetName() const override - { - return "Parent Entity"; - } - -private: - void SetParent(Entity child, Entity parent) - { - if (!child) return; - - if (!child.HasComponent()) - child.AddComponent(); - - auto& hc = child.GetComponent(); - - // Remove from old parent - if (hc.Parent != entt::null && m_Scene->GetRegistryPtr()->valid(hc.Parent)) - { - Entity oldParent(hc.Parent, m_Scene->GetRegistryPtr()); - if (oldParent && oldParent.HasComponent()) - { - auto& oldPhc = oldParent.GetComponent(); - auto it = std::find(oldPhc.Children.begin(), oldPhc.Children.end(), (entt::entity)child); - if (it != oldPhc.Children.end()) - { - oldPhc.Children.erase(it); - } - } - } - - // Set new parent - if (parent) - { - hc.Parent = (entt::entity)parent; - if (!parent.HasComponent()) - { - parent.AddComponent(); - } - parent.GetComponent().Children.push_back((entt::entity)child); - } - else - { - hc.Parent = entt::null; - } - } - - Entity m_Entity; - Entity m_NewParent; - Entity m_OldParent; - Scene* m_Scene; -}; - -} // namespace CHEngine + class DestroyEntityCommand : public IEditorCommand + { + public: + DestroyEntityCommand(Entity entity) + : m_Entity(entity), + m_Scene(entity.GetRegistry().ctx().get()) + { + } + + void Execute() override + { + CH_CORE_INFO("Destroying entity via command: {}", m_Entity.GetComponent().Tag); + + m_UUID = m_Entity.GetUUID(); + + // Serialize the entity before destroying + YAML::Emitter out; + out << YAML::BeginMap; + ComponentSerializer::SerializeID(out, m_Entity); + ComponentSerializer::SerializeAll(out, m_Entity); + out << YAML::EndMap; + m_SerializedData = out.c_str(); + + if (EditorLayer::Get().GetSelectedEntity() == m_Entity) + { + EditorLayer::Get().SetSelectedEntity({}); + } + + m_Scene->DestroyEntity(m_Entity); + } + + void Undo() override + { + CH_CORE_INFO("Undoing DestroyEntity, restoring UUID: {}", m_UUID); + YAML::Node node = YAML::Load(m_SerializedData); + + std::string name = "Restored Entity"; + auto tagComponent = node["TagComponent"]; + if (tagComponent && tagComponent["Tag"] && tagComponent["Tag"].IsScalar()) + { + name = tagComponent["Tag"].as(); + } + + m_Entity = m_Scene->CreateEntityWithUUID(m_UUID, name); + ComponentSerializer::DeserializeAll(m_Entity, node); + } + + std::string GetName() const override + { + return "Destroy Entity"; + } + + private: + Entity m_Entity; + Scene* m_Scene; + uint64_t m_UUID; + std::string m_SerializedData; + }; + + class CreateEntityCommand : public IEditorCommand + { + public: + CreateEntityCommand(Scene* scene, const std::string& name, const std::string& modelPath = "") + : m_Scene(scene), + m_Name(name), + m_ModelPath(modelPath) + { + } + + void Execute() override + { + m_Entity = m_Scene->CreateEntity(m_Name); + if (!m_ModelPath.empty()) + { + // Procedural primitive markers start with ':' — use PrimitiveComponent. + if (m_ModelPath.size() > 1 && m_ModelPath.front() == ':' && m_ModelPath.back() == ':') + { + PrimitiveComponent prim; + if (m_ModelPath == ":cube:") + { + prim.Type = PrimitiveType::Cube; + } + else if (m_ModelPath == ":sphere:") + { + prim.Type = PrimitiveType::Sphere; + } + else if (m_ModelPath == ":plane:") + { + prim.Type = PrimitiveType::Plane; + } + else if (m_ModelPath == ":cylinder:") + { + prim.Type = PrimitiveType::Cylinder; + } + else if (m_ModelPath == ":cone:") + { + prim.Type = PrimitiveType::Cone; + } + else if (m_ModelPath == ":torus:") + { + prim.Type = PrimitiveType::Torus; + } + else if (m_ModelPath == ":knot:") + { + prim.Type = PrimitiveType::Knot; + } + else if (m_ModelPath == ":hemisphere:") + { + prim.Type = PrimitiveType::Hemisphere; + } + else + { + prim.Type = PrimitiveType::Sphere; // fallback + } + m_Entity.AddComponent(prim); + } + else + { + // Real file path — use ModelComponent. + auto& mc = m_Entity.AddComponent(); + mc.ModelPath = m_ModelPath; + } + } + } + + void Undo() override + { + if (m_Entity) + { + m_Scene->DestroyEntity(m_Entity); + } + } + + std::string GetName() const override + { + return "Create Entity"; + } + + private: + Scene* m_Scene; + std::string m_Name; + std::string m_ModelPath; + Entity m_Entity; + }; + + class DuplicateEntityCommand : public IEditorCommand + { + public: + DuplicateEntityCommand(Entity entity) + : m_SourceEntity(entity), + m_Scene(entity.GetRegistry().ctx().get()) + { + } + + void Execute() override + { + m_DuplicateEntity = Entity(m_Scene->CopyEntity(m_SourceEntity), m_Scene->GetRegistryPtr()); + } + + void Undo() override + { + if (m_DuplicateEntity) + { + m_Scene->DestroyEntity(m_DuplicateEntity); + } + } + + std::string GetName() const override + { + return "Duplicate Entity"; + } + + private: + Entity m_SourceEntity; + Entity m_DuplicateEntity; + Scene* m_Scene; + }; + + class ParentEntityCommand : public IEditorCommand + { + public: + ParentEntityCommand(Entity entity, Entity newParent, Scene* scene) + : m_Entity(entity), + m_NewParent(newParent), + m_Scene(scene) + { + if (m_Entity && m_Entity.HasComponent()) + { + auto parentID = m_Entity.GetComponent().Parent; + if (parentID != entt::null) + { + m_OldParent = Entity(parentID, m_Entity.GetRegistryPtr()); + } + } + } + + void Execute() override + { + SetParent(m_Entity, m_NewParent); + } + + void Undo() override + { + SetParent(m_Entity, m_OldParent); + } + + std::string GetName() const override + { + return "Parent Entity"; + } + + private: + void SetParent(Entity child, Entity parent) + { + if (!child) + { + return; + } + + if (!child.HasComponent()) + { + child.AddComponent(); + } + + auto& hc = child.GetComponent(); + + // Remove from old parent + if (hc.Parent != entt::null && m_Scene->GetRegistryPtr()->valid(hc.Parent)) + { + Entity oldParent(hc.Parent, m_Scene->GetRegistryPtr()); + if (oldParent && oldParent.HasComponent()) + { + auto& oldPhc = oldParent.GetComponent(); + auto it = std::find(oldPhc.Children.begin(), oldPhc.Children.end(), (entt::entity)child); + if (it != oldPhc.Children.end()) + { + oldPhc.Children.erase(it); + } + } + } + + // Set new parent + if (parent) + { + hc.Parent = (entt::entity)parent; + if (!parent.HasComponent()) + { + parent.AddComponent(); + } + parent.GetComponent().Children.push_back((entt::entity)child); + } + else + { + hc.Parent = entt::null; + } + } + + Entity m_Entity; + Entity m_NewParent; + Entity m_OldParent; + Scene* m_Scene; + }; + +} // namespace Chained #endif // CH_ENTITY_COMMANDS_H diff --git a/editor/undo/lambda_command.h b/editor/undo/lambda_command.h deleted file mode 100644 index bce935969..000000000 --- a/editor/undo/lambda_command.h +++ /dev/null @@ -1,50 +0,0 @@ -#ifndef CH_LAMBDA_COMMAND_H -#define CH_LAMBDA_COMMAND_H - -#include "editor_command.h" -#include -#include - -namespace CHEngine -{ - -class LambdaCommand : public IEditorCommand -{ -public: - using ActionFn = std::function; - - LambdaCommand(const std::string& name, ActionFn execute, ActionFn undo) - : m_Name(name), - m_Execute(execute), - m_Undo(undo) - { - } - - void Execute() override - { - if (m_Execute) - { - m_Execute(); - } - } - void Undo() override - { - if (m_Undo) - { - m_Undo(); - } - } - std::string GetName() const override - { - return m_Name; - } - -private: - std::string m_Name; - ActionFn m_Execute; - ActionFn m_Undo; -}; - -} // namespace CHEngine - -#endif // CH_LAMBDA_COMMAND_H diff --git a/editor/undo/modify_component_command.h b/editor/undo/modify_component_command.h index 12ada35a9..cebb92a37 100644 --- a/editor/undo/modify_component_command.h +++ b/editor/undo/modify_component_command.h @@ -1,63 +1,53 @@ #ifndef CH_MODIFY_COMPONENT_COMMAND_H #define CH_MODIFY_COMPONENT_COMMAND_H -#include "editor_command.h" +#include "command.h" + #include "engine/scene/scene.h" #include -namespace CHEngine +namespace Chained { -template class ModifyComponentCommand : public IEditorCommand -{ -public: - ModifyComponentCommand(Entity entity, const T& oldState, const T& newState, const std::string& name = "") - : m_Entity(entity), - m_OldState(oldState), - m_NewState(newState), - m_Name(name) - { - } - - void Execute() override - { - if (Validate()) - { - m_Entity.GetComponent() = m_NewState; - } - } - - void Undo() override - { - if (Validate()) - { - m_Entity.GetComponent() = m_OldState; - } - } - - std::string GetName() const override - { - return m_Name.empty() ? "Modify Component" : m_Name; - } - -private: - bool Validate() - { - if (!m_Entity) - { - return false; - } - auto* registry = &m_Entity.GetRegistry(); - return registry->valid(static_cast(m_Entity)) && m_Entity.HasComponent(); - } - -private: - Entity m_Entity; - T m_OldState; - T m_NewState; - std::string m_Name; -}; - -} // namespace CHEngine + template class ModifyComponentCommand : public IEditorCommand + { + public: + ModifyComponentCommand(Entity entity, const T& oldState, const T& newState, const std::string& name = "") + : m_Entity(entity), + m_OldState(oldState), + m_NewState(newState), + m_Name(name) + { + } + + void Execute() override + { + if (m_Entity.IsValid() && m_Entity.HasComponent()) + { + m_Entity.GetComponent() = m_NewState; + } + } + + void Undo() override + { + if (m_Entity.IsValid() && m_Entity.HasComponent()) + { + m_Entity.GetComponent() = m_OldState; + } + } + + std::string GetName() const override + { + return m_Name.empty() ? "Modify Component" : m_Name; + } + + private: + Entity m_Entity; + T m_OldState; + T m_NewState; + std::string m_Name; + }; + +} // namespace Chained #endif // CH_MODIFY_COMPONENT_COMMAND_H diff --git a/editor/viewport/camera.cpp b/editor/viewport/camera.cpp new file mode 100644 index 000000000..a8f20b342 --- /dev/null +++ b/editor/viewport/camera.cpp @@ -0,0 +1,307 @@ +#include "camera.h" +#include "editor/layer.h" +#include "engine/app/application.h" +#include "engine/core/input.h" +#include "engine/scene/components/render/camera_component.h" +#include "engine/scene/systems/transform_system.h" +#include "engine/scene/components/core/transform_component.h" +#include "imgui.h" + +#include +#include + +namespace Chained +{ + static constexpr float kMouseSensitivity = 0.003f; + + // Pan speed: viewport-independent polynomial curve (empirically tuned) + static constexpr float kPanSpeedDivisor = 1000.0f; + static constexpr float kPanSpeedCap = 2.4f; + static constexpr float kPanSpeedA = 0.0366f; + static constexpr float kPanSpeedB = -0.1778f; + static constexpr float kPanSpeedC = 0.3021f; + + // Zoom speed: quadratic distance-based curve + static constexpr float kZoomDistanceScale = 0.2f; + static constexpr float kZoomSpeedMin = 0.1f; + static constexpr float kZoomSpeedMax = 100.0f; + + EditorCameraController::EditorCameraController() + { + SetPerspective(glm::radians(45.0f), 0.1f, 10000.0f); + UpdateView(); + } + + Camera3D EditorCameraController::ToCamera3D() const + { + Camera3D camera; + glm::vec3 pos = CalculatePosition(); + glm::vec3 fp = m_FocalPoint; + glm::vec3 up = GetUpDirection(); + camera.Position = {pos.x, pos.y, pos.z}; + camera.Target = {fp.x, fp.y, fp.z}; + camera.Up = {up.x, up.y, up.z}; + camera.Projection = GetProjectionType(); + camera.FovDegrees = m_FovDegrees; + camera.OrthographicSize = GetOrthographicSize(); + camera.NearClip = m_NearClip; + camera.FarClip = m_FarClip; + camera.ViewMatrix = m_ViewMatrix; + camera.ProjectionMatrix = GetProjection(); + return camera; + } + + void EditorCameraController::Set2DMode(bool enabled) + { + if (m_Is2DMode == enabled) + { + return; + } + + m_Is2DMode = enabled; + + if (m_Is2DMode) + { + m_SavedPitch = m_Pitch; + m_SavedYaw = m_Yaw; + m_SavedProjectionType = GetProjectionType(); + + m_Pitch = 0.0f; + m_Yaw = 0.0f; + SetProjectionType(ProjectionType::Orthographic); + } + else + { + m_Pitch = m_SavedPitch; + m_Yaw = m_SavedYaw; + SetProjectionType(m_SavedProjectionType); + } + UpdateView(); + } + + void EditorCameraController::OnUpdate(Entity cameraEntity, Timestep ts, const glm::vec2& viewportSize) + { + m_ViewportWidth = (uint32_t)viewportSize.x; + m_ViewportHeight = (uint32_t)viewportSize.y; + + float deltaTime = ts; + float moveSpeed = m_MoveSpeed; + float boostMultiplier = m_BoostMultiplier; + + bool hasImGui = ImGui::GetCurrentContext() != nullptr; + + bool rightDown = hasImGui ? ImGui::IsMouseDown(ImGuiMouseButton_Right) + : Core::Input::IsMouseButtonDown(MouseCode::ButtonRight); + bool middleDown = hasImGui ? ImGui::IsMouseDown(ImGuiMouseButton_Middle) + : Core::Input::IsMouseButtonDown(MouseCode::ButtonMiddle); + bool leftDown = hasImGui ? ImGui::IsMouseDown(ImGuiMouseButton_Left) + : Core::Input::IsMouseButtonDown(MouseCode::ButtonLeft); + + bool shiftDown = + hasImGui ? (ImGui::IsKeyDown(ImGuiKey_LeftShift) || ImGui::IsKeyDown(ImGuiKey_RightShift)) + : (Core::Input::IsKeyDown(KeyCode::LeftShift) || Core::Input::IsKeyDown(KeyCode::RightShift)); + bool altDown = hasImGui + ? (ImGui::IsKeyDown(ImGuiKey_LeftAlt) || ImGui::IsKeyDown(ImGuiKey_RightAlt)) + : (Core::Input::IsKeyDown(KeyCode::LeftAlt) || Core::Input::IsKeyDown(KeyCode::RightAlt)); + + auto isKeyDown = [hasImGui](KeyCode coreKey, ImGuiKey imguiKey) -> bool { + if (hasImGui) + { + return ImGui::IsKeyDown(imguiKey); + } + return Core::Input::IsKeyDown(coreKey); + }; + + bool hasEntity = cameraEntity && cameraEntity.HasComponent() && + cameraEntity.HasComponent(); + + // In Play mode: sync editor camera FROM TransformComponent (e.g. scripts or inspector changes). + // In Edit mode: skip — the editor camera is authoritative, TransformComponent write-back happens below. + if (hasEntity && EditorLayer::Get().GetSceneState() == SceneState::Play && !rightDown && !middleDown) + { + auto& tc = cameraEntity.GetComponent(); + if (std::isfinite(tc.Rotation.x) && std::isfinite(tc.Rotation.y)) + { + if (fabsf(tc.Rotation.x - m_Pitch) > 0.01f || fabsf(tc.Rotation.y - m_Yaw) > 0.01f) + { + m_Pitch = tc.Rotation.x; + m_Yaw = tc.Rotation.y; + UpdateView(); + } + } + } + + glm::vec2 delta = {0.0f, 0.0f}; + if (hasImGui) + { + ImVec2 imguiDelta = ImGui::GetIO().MouseDelta; + delta = {imguiDelta.x * kMouseSensitivity, imguiDelta.y * kMouseSensitivity}; + } + else + { + delta = Core::Input::GetMouseDelta() * kMouseSensitivity; + } + + if (rightDown) + { + MouseRotate(delta); + + float speed = moveSpeed * deltaTime; + if (shiftDown) + { + speed *= boostMultiplier; + } + + glm::vec3 fwd = GetForwardDirection(); + glm::vec3 rgt = GetRightDirection(); + glm::vec3 upg = {0, 1, 0}; + + glm::vec3 currentPos = CalculatePosition(); + + if (isKeyDown(KeyCode::W, ImGuiKey_W)) + { + currentPos += (m_Is2DMode ? upg : fwd) * speed; + } + if (isKeyDown(KeyCode::S, ImGuiKey_S)) + { + currentPos -= (m_Is2DMode ? upg : fwd) * speed; + } + if (isKeyDown(KeyCode::D, ImGuiKey_D)) + { + currentPos += rgt * speed; + } + if (isKeyDown(KeyCode::A, ImGuiKey_A)) + { + currentPos -= rgt * speed; + } + if (isKeyDown(KeyCode::E, ImGuiKey_E)) + { + currentPos += upg * speed; + } + if (isKeyDown(KeyCode::Q, ImGuiKey_Q)) + { + currentPos -= upg * speed; + } + + m_FocalPoint = currentPos + (fwd * m_Distance); + UpdateView(); + } + + if (middleDown) + { + if (shiftDown) + { + MousePan(delta); + } + else + { + MouseRotate(delta); + } + } + + if (altDown && leftDown) + { + MouseRotate(delta); + } + + float wheel = hasImGui ? ImGui::GetIO().MouseWheel : Core::Input::GetMouseWheelMove(); + if (wheel != 0.0f && !m_DisableZoom) + { + MouseZoom(wheel); + } + + // Write back rotation and position to the entity's TransformComponent in both + // Play and Edit modes so the inspector stays in sync with the editor camera. + if (hasEntity) + { + auto& tc = cameraEntity.GetComponent(); + TransformSystem::SetRotation(tc, glm::vec3(m_Pitch, m_Yaw, 0.0f)); + TransformSystem::SetTranslation(tc, CalculatePosition()); + } + } + + void EditorCameraController::UpdateView() + { + glm::vec3 position = CalculatePosition(); + glm::quat orientation = GetOrientation(); + m_ViewMatrix = glm::translate(glm::mat4(1.0f), position) * glm::toMat4(orientation); + m_ViewMatrix = glm::inverse(m_ViewMatrix); + } + + void EditorCameraController::MousePan(const glm::vec2& delta) + { + auto [xSpeed, ySpeed] = PanSpeed(); + m_FocalPoint += -GetRightDirection() * delta.x * xSpeed * m_Distance; + m_FocalPoint += GetUpDirection() * delta.y * ySpeed * m_Distance; + UpdateView(); + } + + void EditorCameraController::MouseRotate(const glm::vec2& delta) + { + if (m_Is2DMode) + { + return; + } + + float yawSign = GetUpDirection().y < 0 ? -1.0f : 1.0f; + m_Yaw += yawSign * delta.x * RotationSpeed(); + m_Pitch += delta.y * RotationSpeed(); + UpdateView(); + } + + void EditorCameraController::MouseZoom(float delta) + { + m_Distance -= delta * ZoomSpeed(); + if (m_Distance < 0.1f) + { + m_FocalPoint += GetForwardDirection(); + m_Distance = 0.1f; + } + UpdateView(); + } + + glm::vec3 EditorCameraController::GetUpDirection() const + { + return glm::rotate(GetOrientation(), glm::vec3(0.0f, 1.0f, 0.0f)); + } + glm::vec3 EditorCameraController::GetRightDirection() const + { + return glm::rotate(GetOrientation(), glm::vec3(1.0f, 0.0f, 0.0f)); + } + glm::vec3 EditorCameraController::GetForwardDirection() const + { + return glm::rotate(GetOrientation(), glm::vec3(0.0f, 0.0f, -1.0f)); + } + glm::vec3 EditorCameraController::CalculatePosition() const + { + return m_FocalPoint - GetForwardDirection() * m_Distance; + } + glm::quat EditorCameraController::GetOrientation() const + { + return glm::quat(glm::vec3(-m_Pitch, -m_Yaw, 0.0f)); + } + + std::pair EditorCameraController::PanSpeed() const + { + float x = std::min((float)m_ViewportWidth / kPanSpeedDivisor, kPanSpeedCap); + float xFactor = kPanSpeedA * (x * x) + kPanSpeedB * x + kPanSpeedC; + float y = std::min((float)m_ViewportHeight / kPanSpeedDivisor, kPanSpeedCap); + float yFactor = kPanSpeedA * (y * y) + kPanSpeedB * y + kPanSpeedC; + return {xFactor, yFactor}; + } + + float EditorCameraController::RotationSpeed() const + { + return m_RotationSpeed; + } + + float EditorCameraController::ZoomSpeed() const + { + float distance = m_Distance * kZoomDistanceScale; + distance = std::max(distance, 0.0f); + float speed = distance * distance; + + return std::clamp(speed * m_ZoomSpeedMultiplier, kZoomSpeedMin, kZoomSpeedMax); + } + +} // namespace Chained \ No newline at end of file diff --git a/editor/viewport/camera.h b/editor/viewport/camera.h new file mode 100644 index 000000000..b3808265d --- /dev/null +++ b/editor/viewport/camera.h @@ -0,0 +1,194 @@ +#ifndef CH_EDITOR_CAMERA_H +#define CH_EDITOR_CAMERA_H + +#include "engine/common/timestep.h" +#include "engine/scene/camera.h" +#include "engine/scene/entity.h" +#include +#include + +namespace Chained +{ + + class EditorCameraController : public Camera + { + public: + EditorCameraController(); + ~EditorCameraController() = default; + + void OnUpdate(Entity cameraEntity, Timestep ts, const glm::vec2& viewportSize); + + // Native Projection is provided by Camera parent class. + + const glm::mat4& GetViewMatrix() const + { + return m_ViewMatrix; + } + glm::mat4 GetViewProjection() const + { + return GetProjection() * m_ViewMatrix; + } + + void SetViewportSize(uint32_t width, uint32_t height) + { + m_ViewportWidth = width; + m_ViewportHeight = height; + Camera::SetViewportSize(width, height); + UpdateView(); + } + + glm::vec3 GetUpDirection() const; + glm::vec3 GetRightDirection() const; + glm::vec3 GetForwardDirection() const; + glm::vec3 CalculatePosition() const; + glm::quat GetOrientation() const; + + float GetPitch() const + { + return m_Pitch; + } + float GetYaw() const + { + return m_Yaw; + } + void SetPitch(float pitch) + { + m_Pitch = pitch; + UpdateView(); + } + void SetYaw(float yaw) + { + m_Yaw = yaw; + UpdateView(); + } + + glm::vec3 GetFocalPoint() const + { + return m_FocalPoint; + } + void SetFocalPoint(const glm::vec3& focalPoint) + { + m_FocalPoint = focalPoint; + UpdateView(); + } + + float GetDistance() const + { + return m_Distance; + } + void SetDistance(float distance) + { + m_Distance = distance; + UpdateView(); + } + + void SetMoveSpeed(float speed) + { + m_MoveSpeed = speed; + } + void SetBoostMultiplier(float multiplier) + { + m_BoostMultiplier = multiplier; + } + void SetDisableZoom(bool disable) + { + m_DisableZoom = disable; + } + void SetRotationSpeed(float speed) + { + m_RotationSpeed = speed; + } + void SetZoomSpeedMultiplier(float multiplier) + { + m_ZoomSpeedMultiplier = multiplier; + } + void SetFovDegrees(float fov) + { + m_FovDegrees = fov; + } + void SetNearClip(float near) + { + m_NearClip = near; + } + void SetFarClip(float far) + { + m_FarClip = far; + } + + float GetBoostMultiplier() const + { + return m_BoostMultiplier; + } + float GetMoveSpeed() const + { + return m_MoveSpeed; + } + bool GetDisableZoom() const + { + return m_DisableZoom; + } + float GetRotationSpeed() const + { + return m_RotationSpeed; + } + float GetZoomSpeedMultiplier() const + { + return m_ZoomSpeedMultiplier; + } + float GetFovDegrees() const + { + return m_FovDegrees; + } + float GetNearClip() const + { + return m_NearClip; + } + float GetFarClip() const + { + return m_FarClip; + } + + Camera3D ToCamera3D() const; + + void Set2DMode(bool enabled); + bool Is2DMode() const + { + return m_Is2DMode; + } + + void MousePan(const glm::vec2& delta); + void MouseRotate(const glm::vec2& delta); + void MouseZoom(float delta); + + private: + void UpdateView(); + + std::pair PanSpeed() const; + float RotationSpeed() const; + float ZoomSpeed() const; + + private: + glm::mat4 m_ViewMatrix = glm::mat4(1.0f); + glm::vec3 m_FocalPoint = {0.0f, 0.0f, 0.0f}; + float m_Distance = 10.0f; + float m_Pitch = 0.0f, m_Yaw = 0.0f; + + uint32_t m_ViewportWidth = 1280, m_ViewportHeight = 720; + float m_MoveSpeed = 10.0f; + float m_BoostMultiplier = 5.0f; + bool m_DisableZoom = false; + float m_RotationSpeed = 1.0f; + float m_ZoomSpeedMultiplier = 1.0f; + float m_FovDegrees = 45.0f; + float m_NearClip = 0.1f; + float m_FarClip = 10000.0f; + + bool m_Is2DMode = false; + float m_SavedPitch = 0.0f; + float m_SavedYaw = 0.0f; + ProjectionType m_SavedProjectionType = ProjectionType::Perspective; + }; + +} // namespace Chained + +#endif // CH_EDITOR_CAMERA_H diff --git a/editor/viewport/editor_camera.cpp b/editor/viewport/editor_camera.cpp deleted file mode 100644 index 97e588b9b..000000000 --- a/editor/viewport/editor_camera.cpp +++ /dev/null @@ -1,128 +0,0 @@ -#include "editor_camera.h" -#include "editor/editor_layer.h" -#include "engine/core/input.h" -#include "engine/scene/components.h" -#include "engine/scene/project.h" - -namespace CHEngine -{ - -EditorCameraController::EditorCameraController() -{ -} - -void EditorCameraController::OnUpdate(Entity cameraEntity, Timestep ts) -{ - float deltaTime = ts; - - // Viewport dimensions for calculations - m_Camera.SetViewportSize((uint32_t)EditorLayer::Get().GetViewportSize().x, - (uint32_t)EditorLayer::Get().GetViewportSize().y); - - // Load settings from project - float moveSpeed = m_MoveSpeed; - float boostMultiplier = m_BoostMultiplier; - float sensitivity = 1.0f; - - if (auto project = Project::GetActive()) - { - const auto& editorSettings = project->GetConfig().Editor; - moveSpeed = editorSettings.CameraMoveSpeed; - boostMultiplier = editorSettings.CameraBoostMultiplier; - sensitivity = editorSettings.CameraRotationSpeed; - } - - bool hasEntity = - cameraEntity && cameraEntity.HasComponent() && cameraEntity.HasComponent(); - - // 1. Sync from entity transform if changed externally (e.g. Inspector) - if (hasEntity) - { - auto& tc = cameraEntity.GetComponent(); - if (fabsf(tc.Rotation.x - m_Camera.GetPitch()) > 0.01f || fabsf(tc.Rotation.y - m_Camera.GetYaw()) > 0.01f) - { - m_Camera.SetPitch(tc.Rotation.x); - m_Camera.SetYaw(tc.Rotation.y); - } - glm::vec3 tcTranslation = *reinterpret_cast(&tc.Translation); - m_Camera.SetFocalPoint(tcTranslation + (m_Camera.GetForwardDirection() * m_Camera.GetDistance())); - } - - const glm::vec2& mouse = Input::GetMousePosition(); - glm::vec2 delta = Input::GetMouseDelta(); - m_InitialMousePosition = mouse; - - // === Right Mouse Button: Rotate + Fly (FPS-style) === - if (Input::IsMouseButtonDown(Mouse::ButtonRight)) - { - m_Camera.MouseRotate({delta.x * sensitivity, delta.y * sensitivity}); - - float speed = moveSpeed * deltaTime; - if (Input::IsKeyDown(Key::LeftShift)) - { - speed *= boostMultiplier; - } - - glm::vec3 fwd = m_Camera.GetForwardDirection(); - glm::vec3 rgt = m_Camera.GetRightDirection(); - glm::vec3 upg = {0, 1, 0}; - - glm::vec3 currentPos = - hasEntity ? cameraEntity.GetComponent().Translation : m_Camera.CalculatePosition(); - - if (Input::IsKeyDown(Key::W)) currentPos += fwd * speed; - if (Input::IsKeyDown(Key::S)) currentPos -= fwd * speed; - if (Input::IsKeyDown(Key::D)) currentPos += rgt * speed; - if (Input::IsKeyDown(Key::A)) currentPos -= rgt * speed; - if (Input::IsKeyDown(Key::E)) currentPos += upg * speed; - if (Input::IsKeyDown(Key::Q)) currentPos -= upg * speed; - - if (hasEntity) - { - cameraEntity.GetComponent().SetTranslation(currentPos); - } - - m_Camera.SetFocalPoint(currentPos + (fwd * m_Camera.GetDistance())); - } - - // === Middle Mouse: Orbit or Alt+Middle Pan === - if (Input::IsMouseButtonDown(Mouse::ButtonMiddle)) - { - if (Input::IsKeyDown(Key::LeftShift)) - { - m_Camera.MousePan(delta); - } - else - { - m_Camera.MouseRotate({delta.x * sensitivity, delta.y * sensitivity}); - } - } - - // === Alt + Left Mouse: Classic orbit (Maya/Unity style) === - if (Input::IsKeyDown(Key::LeftAlt) && Input::IsMouseButtonDown(Mouse::ButtonLeft)) - { - m_Camera.MouseRotate({delta.x * sensitivity, delta.y * sensitivity}); - } - - // === Scroll wheel: Zoom === - float wheel = Input::GetMouseWheelMove(); - if (wheel != 0) - { - m_Camera.MouseZoom(wheel); - } - - // 2. Sync camera state back to entity transform - if (hasEntity) - { - auto& tc = cameraEntity.GetComponent(); - tc.SetRotation(glm::vec3(m_Camera.GetPitch(), m_Camera.GetYaw(), 0.0f)); - - if (!Input::IsMouseButtonDown(Mouse::ButtonRight)) - { - glm::vec3 pos = m_Camera.CalculatePosition(); - tc.SetTranslation(pos); - } - } -} - -} // namespace CHEngine diff --git a/editor/viewport/editor_camera.h b/editor/viewport/editor_camera.h deleted file mode 100644 index e611248a1..000000000 --- a/editor/viewport/editor_camera.h +++ /dev/null @@ -1,35 +0,0 @@ -#ifndef CH_EDITOR_CAMERA_H -#define CH_EDITOR_CAMERA_H - -#include "engine/core/timestep.h" -#include "engine/scene/entity.h" -#include "engine/scene/editor_camera.h" - -namespace CHEngine -{ - -class EditorCameraController -{ -public: - EditorCameraController(); - ~EditorCameraController() = default; - - // Drives the transform and camera component of the given entity - void OnUpdate(Entity cameraEntity, Timestep ts); - - EditorCamera& GetCamera() { return m_Camera; } - float GetYaw() const { return m_Camera.GetYaw(); } - float GetPitch() const { return m_Camera.GetPitch(); } - -private: - float m_MoveSpeed = 10.0f; - float m_BoostMultiplier = 5.0f; - - EditorCamera m_Camera; - glm::vec2 m_InitialMousePosition = {0.0f, 0.0f}; -}; - -} // namespace CHEngine - -#endif // CH_EDITOR_CAMERA_H - diff --git a/editor/viewport/editor_gizmo.cpp b/editor/viewport/editor_gizmo.cpp deleted file mode 100644 index 950d22121..000000000 --- a/editor/viewport/editor_gizmo.cpp +++ /dev/null @@ -1,130 +0,0 @@ -#include "editor_gizmo.h" - -#include "editor_gui.h" -#include "editor_layer.h" -#include "engine/scene/components.h" -#include "undo/modify_component_command.h" -#include -#include -#include - -namespace CHEngine -{ - -bool EditorGizmo::RenderAndHandle(GizmoType type, ImVec2 viewportPos, ImVec2 viewportSize, const CHEngine::Camera3D& camera) -{ - auto& layer = EditorLayer::Get(); - Scene* scene = layer.GetActiveScene().get(); - Entity entity = layer.GetSelectedEntity(); - - if (!scene || !entity || !entity.HasComponent() || type == GizmoType::NONE || - layer.GetSceneState() == SceneState::Play) - { - return false; - } - - if (viewportSize.x <= 1.0f || viewportSize.y <= 1.0f) - { - return false; - } - - auto& transform = entity.GetComponent(); - - // 1. Setup ImGuizmo - ImGuizmo::SetOrthographic(camera.Projection != 0); - ImGuizmo::SetDrawlist(); - ImGuizmo::SetRect(viewportPos.x, viewportPos.y, viewportSize.x, viewportSize.y); - - // 2. Prepare View/Projection matrices - glm::vec3 up = camera.Up; - if (glm::dot(up, up) <= 0.000001f) - { - up = {0.0f, 1.0f, 0.0f}; - } - - glm::vec3 forward = camera.Target - camera.Position; - if (glm::dot(forward, forward) <= 0.000001f) - { - forward = {0.0f, 0.0f, -1.0f}; - } - else - { - forward = glm::normalize(forward); - } - - glm::mat4 view = glm::lookAt(camera.Position, camera.Position + forward, up); - glm::mat4 projection; - - const float aspect = viewportSize.x / viewportSize.y; - constexpr float kNearClip = 0.01f; - constexpr float kFarClip = 100000.0f; - if (camera.Projection == 0) // Perspective - { - projection = glm::perspective(glm::radians(camera.Fovy), aspect, kNearClip, kFarClip); - } - else // Orthographic - { - float top = camera.Fovy * 0.5f; - float right = top * aspect; - projection = glm::ortho(-right, right, -top, top, kNearClip, kFarClip); - } - - // 3. Prepare Model matrix - glm::mat4 modelMat = transform.GetTransform(); - - // 4. Handle Snapping - float* snap = m_SnappingEnabled ? m_SnapValues : nullptr; - - // 5. Manipulation - ImGuizmo::MODE mode = m_IsLocalSpace ? ImGuizmo::LOCAL : ImGuizmo::WORLD; - - const bool wasUsing = m_WasUsing; - const bool manipulated = ImGuizmo::Manipulate( - glm::value_ptr(view), - glm::value_ptr(projection), - static_cast(type), - mode, - glm::value_ptr(modelMat), - nullptr, - snap); - - const bool isUsingNow = ImGuizmo::IsUsing(); - if (isUsingNow && !wasUsing) - { - m_WasUsing = true; - m_OldTransform = transform; - } - - if (manipulated || isUsingNow) - { - glm::vec3 translation, rotation, scale; - ImGuizmo::DecomposeMatrixToComponents( - glm::value_ptr(modelMat), - glm::value_ptr(translation), - glm::value_ptr(rotation), - glm::value_ptr(scale)); - - transform.SetTranslation(translation); - transform.SetRotation(glm::radians(rotation)); - transform.SetScale(scale); - } - else if (m_WasUsing && !isUsingNow) - { - m_WasUsing = false; - - const bool changed = - glm::length(transform.Translation - m_OldTransform.Translation) > 0.0001f || - glm::length(transform.Rotation - m_OldTransform.Rotation) > 0.0001f || - glm::length(transform.Scale - m_OldTransform.Scale) > 0.0001f; - - if (changed) - { - EditorLayer::GetCommandHistory().PushCommand(std::make_unique>( - entity, m_OldTransform, transform, "Transform Entity")); - } - } - - return ImGuizmo::IsOver() || isUsingNow; -} - -} // namespace CHEngine diff --git a/editor/viewport/editor_gizmo.h b/editor/viewport/editor_gizmo.h deleted file mode 100644 index 7611b00f2..000000000 --- a/editor/viewport/editor_gizmo.h +++ /dev/null @@ -1,85 +0,0 @@ -#ifndef CH_EDITOR_GIZMO_H -#define CH_EDITOR_GIZMO_H - -#include "engine/scene/scene.h" -#define IMGUI_DEFINE_MATH_OPERATORS -#include "imgui.h" -#include "ImGuizmo.h" - -namespace CHEngine -{ - -enum class GizmoType -{ - NONE = -1, - TRANSLATE = ImGuizmo::OPERATION::TRANSLATE, - ROTATE = ImGuizmo::OPERATION::ROTATE, - SCALE = ImGuizmo::OPERATION::SCALE, - BOUNDS = ImGuizmo::OPERATION::BOUNDS -}; - -class EditorGizmo -{ -public: - EditorGizmo() = default; - ~EditorGizmo() = default; - - // Render and handle gizmo interaction - // true if the gizmo is being used (captured mouse) - bool RenderAndHandle(GizmoType type, ImVec2 viewportPos, ImVec2 viewportSize, const CHEngine::Camera3D& camera); - - bool IsHovered() const - { - return ImGuizmo::IsOver(); - } - bool IsDragging() const - { - return ImGuizmo::IsUsing(); - } - - // Snapping - void SetSnapping(bool enabled) - { - m_SnappingEnabled = enabled; - } - void SetGridSize(float size) - { - m_SnapValues[0] = m_SnapValues[1] = m_SnapValues[2] = size; - } - void SetRotationStep(float step) - { - m_SnapValues[0] = m_SnapValues[1] = m_SnapValues[2] = step; - } - - bool IsSnappingEnabled() const - { - return m_SnappingEnabled; - } - float GetGridSize() const - { - return m_SnapValues[0]; - } - - void SetLocalSpace(bool local) - { - m_IsLocalSpace = local; - } - bool IsLocalSpace() const - { - return m_IsLocalSpace; - } - -private: - // Snapping - bool m_SnappingEnabled = false; - float m_SnapValues[3] = {1.0f, 1.0f, 1.0f}; - bool m_IsLocalSpace = false; - - // Undo state - TransformComponent m_OldTransform; - bool m_WasUsing = false; -}; - -} // namespace CHEngine - -#endif // CH_EDITOR_GIZMO_H diff --git a/editor/viewport/gizmo.cpp b/editor/viewport/gizmo.cpp new file mode 100644 index 000000000..9d7c9078c --- /dev/null +++ b/editor/viewport/gizmo.cpp @@ -0,0 +1,155 @@ +#include "editor/viewport/gizmo.h" +#include "engine/scene/systems/transform_system.h" +#include "engine/scene/components/core/hierarchy_component.h" +#include "gui.h" +#include "imgui_internal.h" +#include "editor/scene_manager.h" +#include "layer.h" +#include "undo/modify_component_command.h" +#include +#include + +namespace Chained +{ + + bool EditorGizmo::RenderAndHandle(GizmoType type, ImVec2 viewportPos, ImVec2 viewportSize, + const Chained::Camera3D& camera) + { + auto& layer = EditorLayer::Get(); + Scene* scene = layer.GetActiveScene().get(); + Entity entity = layer.GetSelectedEntity(); + + if (!scene || !entity || !entity.HasComponent() || type == GizmoType::NONE || + layer.GetSceneState() == SceneState::Play || layer.GetSceneManager().IsTransitioning()) + { + return false; + } + + // Disable gizmo in UI scenes with 2D (orthographic) camera + if (camera.Projection == ProjectionType::Orthographic && scene->GetSettings().Type == SceneType::UI) + { + return false; + } + + if (viewportSize.x <= 1.0f || viewportSize.y <= 1.0f) + { + return false; + } + + auto& transform = entity.GetComponent(); + glm::mat4 modelMat = transform.WorldTransform; + + // Setup ImGuizmo + ImGuizmo::SetOrthographic(camera.Projection != ProjectionType::Perspective); + ImGuizmo::SetDrawlist(ImGui::GetForegroundDrawList()); + ImGuizmo::SetAlternativeWindow(ImGui::GetCurrentWindow()); + + // Ensure we are using absolute screen coordinates for SetRect + ImGuizmo::SetRect(viewportPos.x, viewportPos.y, viewportSize.x, viewportSize.y); + + // 2. Use the pre-built View/Projection matrices from Camera3D so that the gizmo + // is pixel-perfectly aligned with the renderer's own matrices. + const glm::mat4& view = camera.ViewMatrix; + const glm::mat4& projection = camera.ProjectionMatrix; + + // Read snap value from scene grid settings (always in sync with visual grid) + float currentSnapValues[3] = {0.0f, 0.0f, 0.0f}; + if (m_SnappingEnabled) + { + const float snapValue = scene->GetSettings().Grid.Spacing; + if (type == GizmoType::TRANSLATE) + { + currentSnapValues[0] = snapValue; + currentSnapValues[1] = snapValue; + currentSnapValues[2] = snapValue; + } + else if (type == GizmoType::ROTATE) + { + + currentSnapValues[0] = m_RotationSnap; + } + else if (type == GizmoType::SCALE) + { + currentSnapValues[0] = m_ScaleSnap; + currentSnapValues[1] = m_ScaleSnap; + currentSnapValues[2] = m_ScaleSnap; + } + } + + float* snap = m_SnappingEnabled ? currentSnapValues : nullptr; + + // 4. Manipulation + ImGuizmo::MODE mode = m_IsLocalSpace ? ImGuizmo::LOCAL : ImGuizmo::WORLD; + + ImGuizmo::OPERATION op = static_cast(type); + if (m_Is2DMode) + { + if (op == ImGuizmo::TRANSLATE) + { + op = (ImGuizmo::OPERATION)(ImGuizmo::TRANSLATE_X | ImGuizmo::TRANSLATE_Y); + } + else if (op == ImGuizmo::SCALE) + { + op = (ImGuizmo::OPERATION)(ImGuizmo::SCALE_X | ImGuizmo::SCALE_Y); + } + else if (op == ImGuizmo::ROTATE) + { + op = ImGuizmo::ROTATE_Z; + } + } + + const bool wasUsing = m_WasUsing; + const bool manipulated = ImGuizmo::Manipulate(glm::value_ptr(view), glm::value_ptr(projection), op, mode, + glm::value_ptr(modelMat), nullptr, snap); + + const bool isUsingNow = ImGuizmo::IsUsing(); + if (isUsingNow && !wasUsing) + { + m_WasUsing = true; + m_OldTransform = transform; + } + + if (manipulated || isUsingNow) + { + glm::mat4 localMat = modelMat; + if (entity.HasComponent()) + { + auto& hierarchy = entity.GetComponent(); + if (hierarchy.Parent != entt::null && scene->GetRegistry().valid(hierarchy.Parent) && + scene->GetRegistry().all_of(hierarchy.Parent)) + { + const auto& parentTransform = scene->GetRegistry().get(hierarchy.Parent); + localMat = glm::inverse(parentTransform.WorldTransform) * modelMat; + } + } + + glm::vec3 translation, rotation, scale; + ImGuizmo::DecomposeMatrixToComponents(glm::value_ptr(localMat), glm::value_ptr(translation), + glm::value_ptr(rotation), glm::value_ptr(scale)); + + TransformSystem::SetTranslation(transform, translation); + TransformSystem::SetRotation(transform, glm::radians(rotation)); + TransformSystem::SetScale(transform, scale); + transform.WorldTransform = modelMat; + transform.InverseWorldTransform = glm::inverse(modelMat); + } + else if (m_WasUsing && !isUsingNow) + { + m_WasUsing = false; + + const bool changed = glm::length(transform.Translation - m_OldTransform.Translation) > 0.0001f || + glm::length(transform.Rotation - m_OldTransform.Rotation) > 0.0001f || + glm::length(transform.Scale - m_OldTransform.Scale) > 0.0001f; + + if (changed) + { + EditorLayer::Get().GetCommandHistory().PushCommand( + std::make_unique>(entity, m_OldTransform, transform, + "Transform Entity")); + } + } + + return ImGuizmo::IsOver() || isUsingNow; + } + +} // namespace Chained \ No newline at end of file diff --git a/editor/viewport/gizmo.h b/editor/viewport/gizmo.h new file mode 100644 index 000000000..e96467041 --- /dev/null +++ b/editor/viewport/gizmo.h @@ -0,0 +1,103 @@ +#ifndef CH_EDITOR_GIZMO_H +#define CH_EDITOR_GIZMO_H + +#include "engine/graphics/camera_types.h" +#include "engine/scene/components.h" +#include "engine/scene/scene.h" +#include +#include + +namespace Chained +{ + + enum class GizmoType + { + NONE = -1, + TRANSLATE = ImGuizmo::OPERATION::TRANSLATE, + ROTATE = ImGuizmo::OPERATION::ROTATE, + SCALE = ImGuizmo::OPERATION::SCALE, + BOUNDS = ImGuizmo::OPERATION::BOUNDS + }; + + class EditorGizmo + { + public: + EditorGizmo() = default; + ~EditorGizmo() = default; + + // Render and handle gizmo interaction + // true if the gizmo is being used (captured mouse) + bool RenderAndHandle(GizmoType type, ImVec2 viewportPos, ImVec2 viewportSize, const Camera3D& camera); + + void Set2DMode(bool enabled) + { + m_Is2DMode = enabled; + } + bool Is2DMode() const + { + return m_Is2DMode; + } + + bool IsHovered() const + { + return ImGuizmo::IsOver(); + } + bool IsDragging() const + { + return ImGuizmo::IsUsing(); + } + + // Snapping + void SetSnapping(bool enabled) + { + m_SnappingEnabled = enabled; + } + bool IsSnappingEnabled() const + { + return m_SnappingEnabled; + } + + void SetRotationStep(float step) + { + m_RotationSnap = step; + } + void SetScaleStep(float step) + { + m_ScaleSnap = step; + } + + float GetRotationStep() const + { + return m_RotationSnap; + } + float GetScaleStep() const + { + return m_ScaleSnap; + } + + void SetLocalSpace(bool local) + { + m_IsLocalSpace = local; + } + bool IsLocalSpace() const + { + return m_IsLocalSpace; + } + + private: + bool m_SnappingEnabled = false; + + float m_RotationSnap = 45.0f; + float m_ScaleSnap = 0.1f; + + bool m_IsLocalSpace = false; + bool m_Is2DMode = false; + + // Undo state + TransformComponent m_OldTransform; + bool m_WasUsing = false; + }; + +} // namespace Chained + +#endif // CH_EDITOR_GIZMO_H \ No newline at end of file diff --git a/editor/viewport/ui_manipulator.cpp b/editor/viewport/ui_manipulator.cpp index 916353297..ade0869b5 100644 --- a/editor/viewport/ui_manipulator.cpp +++ b/editor/viewport/ui_manipulator.cpp @@ -1,164 +1,176 @@ #include "ui_manipulator.h" #include "engine/core/log.h" -#include "engine/graphics/pipeline/ui_renderer.h" -#include "engine/scene/components/control_component.h" +#include "engine/ui/widget_renderer.h" +#include "engine/graphics/pipeline/renderer.h" +#include "engine/scene/components/ui/control_component.h" #include "engine/scene/scene.h" +#include "engine/core/service_locator.h" -namespace CHEngine +namespace Chained { -static const float HANDLE_SIZE = 8.0f; -static const ImU32 HANDLE_COLOR = IM_COL32(255, 255, 255, 255); -static const ImU32 HANDLE_HOVERED_COLOR = IM_COL32(255, 255, 0, 255); -static const ImU32 ACTIVE_COLOR = IM_COL32(0, 255, 0, 255); - -bool EditorUIManipulator::OnImGuiRender(Entity selectedEntity, ImVec2 viewportPos, ImVec2 viewportSize) -{ - if (!selectedEntity || !selectedEntity.HasComponent()) - { - m_Dragging = false; - m_Resizing = false; - m_ActiveHandle = UIHandleType::None; - return false; - } - - auto& cc = selectedEntity.GetComponent(); - UIRect rect = UIRenderer::Get().GetEntityRect(selectedEntity, viewportSize, viewportPos); - - float scaleFactor = 1.0f; - auto* sceneCtx = selectedEntity.GetRegistry().ctx().find(); - if (sceneCtx && *sceneCtx) - { - const CanvasSettings& canvas = (*sceneCtx)->GetSettings().Canvas; - if (canvas.ScaleMode == CanvasScaleMode::ScaleWithScreenSize && canvas.ReferenceResolution.x > 0.0f && - canvas.ReferenceResolution.y > 0.0f) - { - const float scaleX = viewportSize.x / canvas.ReferenceResolution.x; - const float scaleY = viewportSize.y / canvas.ReferenceResolution.y; - scaleFactor = scaleX * (1.0f - canvas.MatchWidthOrHeight) + scaleY * canvas.MatchWidthOrHeight; - if (scaleFactor <= 0.0001f) - { - scaleFactor = 1.0f; - } - } - } - - const float toVirtual = 1.0f / scaleFactor; - - ImDrawList* drawList = ImGui::GetWindowDrawList(); - ImVec2 p1 = {rect.x, rect.y}; - ImVec2 p2 = {p1.x + rect.width, p1.y + rect.height}; - ImVec2 center = {p1.x + rect.width * 0.5f, p1.y + rect.height * 0.5f}; - - // Draw main frame - drawList->AddRect(p1, p2, ACTIVE_COLOR, 0, 0, 1.0f); - - ImVec2 mousePos = ImGui::GetMousePos(); - UIHandleType hoveredHandle = UIHandleType::None; - - auto ProcessHandle = [&](UIHandleType type, ImVec2 pos, UIHandleType& hovered) { - bool hoveredItem = (mousePos.x >= pos.x - HANDLE_SIZE && mousePos.x <= pos.x + HANDLE_SIZE && - mousePos.y >= pos.y - HANDLE_SIZE && mousePos.y <= pos.y + HANDLE_SIZE); - DrawHandle(drawList, pos, type, hoveredItem || (m_ActiveHandle == type)); - if (hoveredItem && !IsActive()) - { - hovered = type; - } - }; - - ProcessHandle(UIHandleType::TopLeft, p1, hoveredHandle); - ProcessHandle(UIHandleType::TopRight, {p2.x, p1.y}, hoveredHandle); - ProcessHandle(UIHandleType::BottomLeft, {p1.x, p2.y}, hoveredHandle); - ProcessHandle(UIHandleType::BottomRight, p2, hoveredHandle); - ProcessHandle(UIHandleType::Top, {center.x, p1.y}, hoveredHandle); - ProcessHandle(UIHandleType::Bottom, {center.x, p2.y}, hoveredHandle); - ProcessHandle(UIHandleType::Left, {p1.x, center.y}, hoveredHandle); - ProcessHandle(UIHandleType::Right, {p2.x, center.y}, hoveredHandle); - - // Processing interaction - if (ImGui::IsMouseClicked(0)) - { - if (hoveredHandle != UIHandleType::None) - { - m_Resizing = true; - m_ActiveHandle = hoveredHandle; - m_StartMousePos = mousePos; - m_StartOffsetMin = cc.Transform.OffsetMin; - m_StartOffsetMax = cc.Transform.OffsetMax; - } - else if (ImGui::IsMouseHoveringRect(p1, p2)) - { - m_Dragging = true; - m_StartMousePos = mousePos; - m_StartOffsetMin = cc.Transform.OffsetMin; - m_StartOffsetMax = cc.Transform.OffsetMax; - } - } - - if (IsActive()) - { - if (ImGui::IsMouseDown(0)) - { - ImVec2 delta = {mousePos.x - m_StartMousePos.x, mousePos.y - m_StartMousePos.y}; - ImVec2 virtualDelta = {delta.x * toVirtual, delta.y * toVirtual}; - - if (m_Dragging) - { - cc.Transform.OffsetMin = {m_StartOffsetMin.x + virtualDelta.x, m_StartOffsetMin.y + virtualDelta.y}; - cc.Transform.OffsetMax = {m_StartOffsetMax.x + virtualDelta.x, m_StartOffsetMax.y + virtualDelta.y}; - } - else if (m_Resizing) - { - switch (m_ActiveHandle) - { - case UIHandleType::TopLeft: - cc.Transform.OffsetMin = {m_StartOffsetMin.x + virtualDelta.x, m_StartOffsetMin.y + virtualDelta.y}; - break; - case UIHandleType::TopRight: - cc.Transform.OffsetMin.y = m_StartOffsetMin.y + virtualDelta.y; - cc.Transform.OffsetMax.x = m_StartOffsetMax.x + virtualDelta.x; - break; - case UIHandleType::BottomLeft: - cc.Transform.OffsetMin.x = m_StartOffsetMin.x + virtualDelta.x; - cc.Transform.OffsetMax.y = m_StartOffsetMax.y + virtualDelta.y; - break; - case UIHandleType::BottomRight: - cc.Transform.OffsetMax = {m_StartOffsetMax.x + virtualDelta.x, m_StartOffsetMax.y + virtualDelta.y}; - break; - case UIHandleType::Top: - cc.Transform.OffsetMin.y = m_StartOffsetMin.y + virtualDelta.y; - break; - case UIHandleType::Bottom: - cc.Transform.OffsetMax.y = m_StartOffsetMax.y + virtualDelta.y; - break; - case UIHandleType::Left: - cc.Transform.OffsetMin.x = m_StartOffsetMin.x + virtualDelta.x; - break; - case UIHandleType::Right: - cc.Transform.OffsetMax.x = m_StartOffsetMax.x + virtualDelta.x; - break; - } - } - } - else - { - m_Dragging = false; - m_Resizing = false; - m_ActiveHandle = UIHandleType::None; - } - return true; - } - - return hoveredHandle != UIHandleType::None; -} - -void EditorUIManipulator::DrawHandle(ImDrawList* drawList, ImVec2 pos, UIHandleType type, bool hovered) -{ - ImU32 color = hovered ? HANDLE_HOVERED_COLOR : HANDLE_COLOR; - drawList->AddRectFilled({pos.x - HANDLE_SIZE * 0.5f, pos.y - HANDLE_SIZE * 0.5f}, - {pos.x + HANDLE_SIZE * 0.5f, pos.y + HANDLE_SIZE * 0.5f}, color); - drawList->AddRect({pos.x - HANDLE_SIZE * 0.5f, pos.y - HANDLE_SIZE * 0.5f}, - {pos.x + HANDLE_SIZE * 0.5f, pos.y + HANDLE_SIZE * 0.5f}, IM_COL32(0, 0, 0, 255)); -} - -} // namespace CHEngine + static const float HANDLE_SIZE = 8.0f; + static const ImU32 HANDLE_COLOR = IM_COL32(255, 255, 255, 255); + static const ImU32 HANDLE_HOVERED_COLOR = IM_COL32(255, 255, 0, 255); + static const ImU32 ACTIVE_COLOR = IM_COL32(0, 255, 0, 255); + + bool EditorUIManipulator::OnImGuiRender(Entity selectedEntity, ImVec2 viewportPos, ImVec2 viewportSize) + { + if (!selectedEntity || !selectedEntity.HasComponent()) + { + m_Dragging = false; + m_Resizing = false; + m_ActiveHandle = UIHandleType::None; + return false; + } + + auto& cc = selectedEntity.GetComponent(); + auto* sceneCtx = selectedEntity.GetRegistry().ctx().find(); + Scene* scene = (sceneCtx && *sceneCtx) ? *sceneCtx : nullptr; + auto* uiRenderer = ServiceLocator::TryGet(); + UIRect rect = (scene && uiRenderer) ? uiRenderer->GetEntityRect(selectedEntity) : UIRect{}; + + float scaleFactor = 1.0f; + if (sceneCtx && *sceneCtx) + { + const CanvasSettings& canvas = (*sceneCtx)->GetSettings().Canvas; + if (canvas.ScaleMode == CanvasScaleMode::ScaleWithScreenSize && canvas.ReferenceResolution.x > 0.0f && + canvas.ReferenceResolution.y > 0.0f) + { + const float scaleX = viewportSize.x / canvas.ReferenceResolution.x; + const float scaleY = viewportSize.y / canvas.ReferenceResolution.y; + scaleFactor = scaleX * (1.0f - canvas.MatchWidthOrHeight) + scaleY * canvas.MatchWidthOrHeight; + if (scaleFactor <= 0.0001f) + { + scaleFactor = 1.0f; + } + } + } + + const float toVirtual = 1.0f / scaleFactor; + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + ImVec2 p1 = {rect.x, rect.y}; + ImVec2 p2 = {p1.x + rect.width, p1.y + rect.height}; + ImVec2 center = {p1.x + rect.width * 0.5f, p1.y + rect.height * 0.5f}; + + // Draw main frame + drawList->AddRect(p1, p2, ACTIVE_COLOR, 0, 0, 1.0f); + + ImVec2 mousePos = ImGui::GetMousePos(); + UIHandleType hoveredHandle = UIHandleType::None; + + bool isHoveredWindow = + ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem | + ImGuiHoveredFlags_AllowWhenBlockedByPopup); + + auto ProcessHandle = [&](UIHandleType type, ImVec2 pos, UIHandleType& hovered) { + bool hoveredItem = + (isHoveredWindow && mousePos.x >= pos.x - HANDLE_SIZE && mousePos.x <= pos.x + HANDLE_SIZE && + mousePos.y >= pos.y - HANDLE_SIZE && mousePos.y <= pos.y + HANDLE_SIZE); + DrawHandle(drawList, pos, type, hoveredItem || (m_ActiveHandle == type)); + if (hoveredItem && !IsActive()) + { + hovered = type; + } + }; + + ProcessHandle(UIHandleType::TopLeft, p1, hoveredHandle); + ProcessHandle(UIHandleType::TopRight, {p2.x, p1.y}, hoveredHandle); + ProcessHandle(UIHandleType::BottomLeft, {p1.x, p2.y}, hoveredHandle); + ProcessHandle(UIHandleType::BottomRight, p2, hoveredHandle); + ProcessHandle(UIHandleType::Top, {center.x, p1.y}, hoveredHandle); + ProcessHandle(UIHandleType::Bottom, {center.x, p2.y}, hoveredHandle); + ProcessHandle(UIHandleType::Left, {p1.x, center.y}, hoveredHandle); + ProcessHandle(UIHandleType::Right, {p2.x, center.y}, hoveredHandle); + + // Processing interaction + if (ImGui::IsMouseClicked(0)) + { + if (hoveredHandle != UIHandleType::None) + { + m_Resizing = true; + m_ActiveHandle = hoveredHandle; + m_StartMousePos = mousePos; + m_StartOffsetMin = cc.Transform.OffsetMin; + m_StartOffsetMax = cc.Transform.OffsetMax; + } + else if (isHoveredWindow && mousePos.x >= p1.x && mousePos.x <= p2.x && mousePos.y >= p1.y && + mousePos.y <= p2.y) + { + m_Dragging = true; + m_StartMousePos = mousePos; + m_StartOffsetMin = cc.Transform.OffsetMin; + m_StartOffsetMax = cc.Transform.OffsetMax; + } + } + + if (IsActive()) + { + if (ImGui::IsMouseDown(0)) + { + ImVec2 delta = {mousePos.x - m_StartMousePos.x, mousePos.y - m_StartMousePos.y}; + ImVec2 virtualDelta = {delta.x * toVirtual, delta.y * toVirtual}; + + if (m_Dragging) + { + cc.Transform.OffsetMin = {m_StartOffsetMin.x + virtualDelta.x, m_StartOffsetMin.y + virtualDelta.y}; + cc.Transform.OffsetMax = {m_StartOffsetMax.x + virtualDelta.x, m_StartOffsetMax.y + virtualDelta.y}; + } + else if (m_Resizing) + { + switch (m_ActiveHandle) + { + case UIHandleType::TopLeft: + cc.Transform.OffsetMin = {m_StartOffsetMin.x + virtualDelta.x, + m_StartOffsetMin.y + virtualDelta.y}; + break; + case UIHandleType::TopRight: + cc.Transform.OffsetMin.y = m_StartOffsetMin.y + virtualDelta.y; + cc.Transform.OffsetMax.x = m_StartOffsetMax.x + virtualDelta.x; + break; + case UIHandleType::BottomLeft: + cc.Transform.OffsetMin.x = m_StartOffsetMin.x + virtualDelta.x; + cc.Transform.OffsetMax.y = m_StartOffsetMax.y + virtualDelta.y; + break; + case UIHandleType::BottomRight: + cc.Transform.OffsetMax = {m_StartOffsetMax.x + virtualDelta.x, + m_StartOffsetMax.y + virtualDelta.y}; + break; + case UIHandleType::Top: + cc.Transform.OffsetMin.y = m_StartOffsetMin.y + virtualDelta.y; + break; + case UIHandleType::Bottom: + cc.Transform.OffsetMax.y = m_StartOffsetMax.y + virtualDelta.y; + break; + case UIHandleType::Left: + cc.Transform.OffsetMin.x = m_StartOffsetMin.x + virtualDelta.x; + break; + case UIHandleType::Right: + cc.Transform.OffsetMax.x = m_StartOffsetMax.x + virtualDelta.x; + break; + } + } + } + else + { + m_Dragging = false; + m_Resizing = false; + m_ActiveHandle = UIHandleType::None; + } + return true; + } + + return hoveredHandle != UIHandleType::None; + } + + void EditorUIManipulator::DrawHandle(ImDrawList* drawList, ImVec2 pos, UIHandleType type, bool hovered) + { + ImU32 color = hovered ? HANDLE_HOVERED_COLOR : HANDLE_COLOR; + drawList->AddRectFilled({pos.x - HANDLE_SIZE * 0.5f, pos.y - HANDLE_SIZE * 0.5f}, + {pos.x + HANDLE_SIZE * 0.5f, pos.y + HANDLE_SIZE * 0.5f}, color); + drawList->AddRect({pos.x - HANDLE_SIZE * 0.5f, pos.y - HANDLE_SIZE * 0.5f}, + {pos.x + HANDLE_SIZE * 0.5f, pos.y + HANDLE_SIZE * 0.5f}, IM_COL32(0, 0, 0, 255)); + } + +} // namespace Chained diff --git a/editor/viewport/ui_manipulator.h b/editor/viewport/ui_manipulator.h index 3c9380414..96b0a6e07 100644 --- a/editor/viewport/ui_manipulator.h +++ b/editor/viewport/ui_manipulator.h @@ -4,52 +4,51 @@ #include "engine/scene/scene.h" #include "imgui.h" - -namespace CHEngine -{ - -enum class UIHandleType +namespace Chained { - None = 0, - Center, - TopLeft, - TopRight, - BottomLeft, - BottomRight, - Top, - Bottom, - Left, - Right -}; - -class EditorUIManipulator -{ -public: - EditorUIManipulator() = default; - - // Returns true if interaction is happening - bool OnImGuiRender(Entity selectedEntity, ImVec2 viewportPos, ImVec2 viewportSize); - - bool IsActive() const - { - return m_Dragging || m_Resizing; - } - -private: - void DrawHandle(ImDrawList* drawList, ImVec2 pos, UIHandleType type, bool hovered); - bool HandleInteraction(Entity entity, ImVec2 viewportSize); - -private: - bool m_Dragging = false; - bool m_Resizing = false; - UIHandleType m_ActiveHandle = UIHandleType::None; - - // Interaction cache - ImVec2 m_StartMousePos; - glm::vec2 m_StartOffsetMin; - glm::vec2 m_StartOffsetMax; -}; -} // namespace CHEngine + enum class UIHandleType + { + None = 0, + Center, + TopLeft, + TopRight, + BottomLeft, + BottomRight, + Top, + Bottom, + Left, + Right + }; + + class EditorUIManipulator + { + public: + EditorUIManipulator() = default; + + // Returns true if interaction is happening + bool OnImGuiRender(Entity selectedEntity, ImVec2 viewportPos, ImVec2 viewportSize); + + bool IsActive() const + { + return m_Dragging || m_Resizing; + } + + private: + void DrawHandle(ImDrawList* drawList, ImVec2 pos, UIHandleType type, bool hovered); + bool HandleInteraction(Entity entity, ImVec2 viewportSize); + + private: + bool m_Dragging = false; + bool m_Resizing = false; + UIHandleType m_ActiveHandle = UIHandleType::None; + + // Interaction cache + ImVec2 m_StartMousePos; + glm::vec2 m_StartOffsetMin; + glm::vec2 m_StartOffsetMax; + }; + +} // namespace Chained #endif // CH_UI_MANIPULATOR_H diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 843f1f3eb..8a2ecd1f3 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -1,16 +1,4 @@ -# Common include directories for all engine targets -set(ENGINE_INCLUDE_DIRS - ${CMAKE_SOURCE_DIR} - . - ${glm_SOURCE_DIR} - ${yaml-cpp_SOURCE_DIR}/include - ${CMAKE_SOURCE_DIR}/include/entt/src - ${CMAKE_SOURCE_DIR}/include/coral/Coral.Native/Include - ${CMAKE_SOURCE_DIR}/include/miniaudio - ${CMAKE_SOURCE_DIR}/include/glfw/include -) - -# SHARED for local development (hot-reload), STATIC for CI/distribution +# SHARED for local development (hot-reload), STATIC for CI/distribution/stability option(CH_ENGINE_SHARED "Build engine as shared library (DLL)" OFF) if(CH_ENGINE_SHARED) set(ENGINE_LIB_TYPE SHARED) @@ -18,66 +6,77 @@ else() set(ENGINE_LIB_TYPE STATIC) endif() -# We use OBJECT libraries for sub-modules to handle circular dependencies (e.g. Core <-> Scene) -# Unified through Unity Build and PCH for maximum compilation speed. -set(SUBMODULE_LIB_TYPE OBJECT) +# If building as a shared library, all internal static sub-modules need to be aware +# they are part of a DLL build so they can dllexport their symbols. +if(CH_ENGINE_SHARED) + add_compile_definitions(CH_DYNAMIC_LINK CH_ENGINE_BUILD) +endif() +add_subdirectory(common) +add_subdirectory(reflection) +add_subdirectory(imgui) add_subdirectory(core) +add_subdirectory(assets) add_subdirectory(graphics) +add_subdirectory(ui) add_subdirectory(physics) add_subdirectory(audio) add_subdirectory(scene) +add_subdirectory(project) +include(${CMAKE_SOURCE_DIR}/cmake/external/enet.cmake) +include(${CMAKE_SOURCE_DIR}/cmake/external/sodium.cmake) +add_subdirectory(networking) +add_subdirectory(platform) +add_subdirectory(scripting) +add_subdirectory(app) +add_subdirectory(runtime) -# Create the final engine facade library that links all sub-modules together -add_library(engine ${ENGINE_LIB_TYPE} - $ - $ - $ - $ - $ -) - -target_compile_definitions(engine PRIVATE CH_ENGINE_BUILD) +# ── Engine facade library ──────────────────────────────────────────────────── +# INTERFACE when static (header/dependency re-export only, no source needed), +# SHARED when building as DLL (needs a translation unit for MSVC archiver). +if(CH_ENGINE_SHARED) + add_library(engine SHARED engine_dummy.cpp) + target_compile_definitions(engine PRIVATE CH_ENGINE_BUILD) + target_compile_definitions(engine PUBLIC CH_DYNAMIC_LINK) + if(WIN32) + set_target_properties(engine PROPERTIES PREFIX "") + endif() + install(TARGETS engine + EXPORT ChainedEngineTargets + ARCHIVE DESTINATION lib COMPONENT Runtime + LIBRARY DESTINATION lib COMPONENT Runtime + RUNTIME DESTINATION bin COMPONENT Runtime + ) +else() + add_library(engine INTERFACE) +endif() -# MSVC needs exported symbols to generate the .lib import library -set_target_properties(engine PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) -if(WIN32) - set_target_properties(engine PROPERTIES PREFIX "") +if(CH_ENGINE_SHARED) + target_link_libraries(engine PUBLIC engine_app) +else() + target_link_libraries(engine INTERFACE engine_app) endif() -target_include_directories(engine PUBLIC - ${ENGINE_INCLUDE_DIRS} - ${assimp_SOURCE_DIR}/include - ${assimp_BINARY_DIR}/include - ${glm_SOURCE_DIR} - ${yaml-cpp_SOURCE_DIR}/include -INTERFACE - ${CMAKE_SOURCE_DIR}/include/imgui - ${CMAKE_SOURCE_DIR}/include/imgui/backends - ${imguizmo_SOURCE_DIR} +target_include_directories(engine INTERFACE + ${CMAKE_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR} ) -target_link_libraries(engine -PUBLIC - EnTT::EnTT -PRIVATE - imguilib - yaml-cpp - assimp::assimp - nfd -) +# ── Unified Framework Interface ────────────────────────────────────────────── +# This provides a single target for games to link against, encapsulating all +# engine headers and dependencies in a clean way. +add_library(ChainedEngineFramework INTERFACE) +add_library(ChainedEngine::Framework ALIAS ChainedEngineFramework) +add_library(ChainedEngine::API ALIAS ChainedEngineFramework) -# (Redundant compiler flags removed - now in CompilerSettings.cmake) +file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" PROJECT_ROOT_DIR_ESCAPED) -if(COMMAND apply_engine_optimizations) - apply_engine_optimizations(engine) -endif() +target_link_libraries(ChainedEngineFramework INTERFACE + engine +) -install(TARGETS engine - EXPORT ChainedEngineTargets - ARCHIVE DESTINATION lib COMPONENT Runtime - LIBRARY DESTINATION lib COMPONENT Runtime - RUNTIME DESTINATION bin COMPONENT Runtime +target_compile_definitions(ChainedEngineFramework INTERFACE + PROJECT_ROOT_DIR="${PROJECT_ROOT_DIR_ESCAPED}" ) install(DIRECTORY "${CMAKE_SOURCE_DIR}/resources" @@ -85,4 +84,3 @@ install(DIRECTORY "${CMAKE_SOURCE_DIR}/resources" USE_SOURCE_PERMISSIONS COMPONENT Runtime ) - diff --git a/engine/app/CMakeLists.txt b/engine/app/CMakeLists.txt new file mode 100644 index 000000000..50e810686 --- /dev/null +++ b/engine/app/CMakeLists.txt @@ -0,0 +1,33 @@ +# Create a high-level runtime module that owns the Application logic and initialization +# This module depends on all other engine modules, but modules don't depend on it. +add_library(engine_app STATIC + application.h + application.cpp + application_types.h + entry_point.h +) + +target_include_directories(engine_app PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR} +) + +target_link_libraries(engine_app PUBLIC + engine_scripting + engine_scene + engine_project + engine_graphics + engine_physics + engine_audio + engine_assets + engine_core + engine_imgui + engine_platform + engine_network +) + +target_include_directories(engine_app PRIVATE ${CMAKE_SOURCE_DIR}) + +if(TARGET engine_pch) + target_link_libraries(engine_app PRIVATE engine_pch) +endif() diff --git a/engine/app/application.cpp b/engine/app/application.cpp new file mode 100644 index 000000000..d4edd0092 --- /dev/null +++ b/engine/app/application.cpp @@ -0,0 +1,268 @@ +#include "engine/app/application.h" +#include "engine/graphics/api/graphics_device.h" +#include "engine/core/profiler.h" +#include "engine/core/platform.h" +#include "engine/imgui/imgui_layer.h" +#include "engine/core/events/window_events.h" +#include "engine/core/service_locator.h" +#include "engine/scene/component_registry.h" +#include "engine/project/project.h" +#include "engine/common/thread_pool.h" +#include "engine/assets/asset_manager.h" +#include "engine/audio/audio.h" +#include "engine/graphics/pipeline/renderer.h" +#include "engine/ui/widget_renderer.h" +#include "engine/ui/ui_font_registry.h" +#include "engine/graphics/pipeline/debug_renderer.h" +#include "engine/physics/physics.h" +#include "engine/core/input.h" +#include "engine/scripting/scriptengine.h" +#include "engine/networking/network_service.h" + +namespace Chained +{ + std::filesystem::path Application::GetExecutableDirectory() + { + return Platform::GetExecutableDirectory(); + } + + Application::Application(const ApplicationSpecification& spec) + : m_Specification(spec) + { + CH_ASSERT(!s_Instance); + s_Instance = this; + + Log::Init(); + ComponentRegistry::RegisterEngineComponents(); + + if (!m_Specification.WorkingDirectory.empty()) + { + std::filesystem::current_path(m_Specification.WorkingDirectory); + } + + InitializePlatform(); + RegisterCoreServices(); + RegisterRuntimeServices(); + RegisterGameplayServices(); + + // Freeze the locator, then initialize all modules. + ServiceLocator::Lock(); + ServiceLocator::InitializeModule(); + + if (m_Window) + { + if (auto* renderer = ServiceLocator::TryGet()) + { + renderer->SetViewportSize(m_Window->GetWidth(), m_Window->GetHeight()); + } + } + + m_LayerStack = std::make_unique(); + m_Timer.LastFrameTime = Platform::GetTime(); + m_Running = true; + + if (!m_Specification.Headless) + { + auto imguiLayer = std::make_unique(); + m_ImGuiLayer = imguiLayer.get(); + PushOverlay(std::move(imguiLayer)); + } + } + + void Application::InitializePlatform() + { + const bool isHeadless = m_Specification.Headless; + if (!isHeadless) + { + m_Window = Window::Create(m_Specification.Window); + m_Window->SetEventCallback(CH_BIND_EVENT_FN(Application::OnEvent)); + } + else + { + GraphicsDevice::SetAPI(GraphicsDevice::API::None); + } + } + + void Application::RegisterCoreServices() + { + unsigned int threads = std::thread::hardware_concurrency(); + if (threads == 0) + { + threads = 1; + } + unsigned int workerCount = (threads > 1) ? (threads - 1) : 1; + + std::filesystem::path resourcesDir; + if (!m_Specification.ResourcesDir.empty() && std::filesystem::exists(m_Specification.ResourcesDir)) + { + resourcesDir = m_Specification.ResourcesDir; + } + + // 1. Input — must be first (window callbacks fire during creation) + ServiceLocator::Provide([] { return std::make_unique(); }); + + // 2. ThreadPool + ServiceLocator::Provide([=] { return std::make_unique(workerCount); }); + + // 3. AssetManager — override asset directory only when explicitly provided + ServiceLocator::Provide([&, resourcesDir] { + auto am = std::make_unique(); + am->SetEngineRoot(m_Specification.EngineRoot); + if (!resourcesDir.empty()) + { + am->SetAssetDirectory(resourcesDir); + } + return am; + }); + } + + void Application::RegisterRuntimeServices() + { + const bool isHeadless = m_Specification.Headless; + if (!isHeadless) + { + ServiceLocator::Provide([] { return std::make_unique(); }); + ServiceLocator::Provide([] { return std::make_unique(); }); + ServiceLocator::Provide([] { return std::make_unique(); }); + ServiceLocator::Provide([] { return std::make_unique(); }); + } + } + + void Application::RegisterGameplayServices() + { + ServiceLocator::Provide