diff --git a/.github/modules.json b/.github/modules.json new file mode 100644 index 00000000..6fd5f94e --- /dev/null +++ b/.github/modules.json @@ -0,0 +1,21 @@ +[ + { "module": "codec", "needs": [] }, + { "module": "collision", "needs": [] }, + { "module": "config", "needs": [] }, + { "module": "integration", "needs": [] }, + { "module": "moveable-entity-block", "needs": [] }, + { "module": "network", "needs": [] }, + { "module": "font", "needs": [] }, + { "module": "yukkuri", "needs": [] }, + + { "module": "explosion", "needs": ["config"] }, + { "module": "rpc", "needs": ["network"] }, + { "module": "space-select", "needs": ["network"] }, + { "module": "util", "needs": ["codec"] }, + + { "module": "multiblock", "needs": ["codec", "config", "network", "util"] }, + { "module": "recipe", "needs": ["codec", "config", "util"] }, + { "module": "registrum", "needs": ["util"] }, + { "module": "sync", "needs": ["codec", "network", "util"] }, + { "module": "wheel", "needs": ["util"] } +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b71d1f27..6d4b3131 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,12 @@ on: - dev/** # - releases/** paths: + - .github/modules.json + - .github/workflows/build_and_test.yml + - .github/workflows/publish_maven_central.yml + - .github/workflows/ci.yml + - .github/workflows/generate-matrix.js + - gradle/** - module.*/src/** - module.*/build.gradle - module.*/gradle.properties @@ -14,309 +20,118 @@ on: - settings.gradle jobs: - config: - uses: ./.github/workflows/build_and_test.yml - with: - module: config - module_id: anvillib-config - mod_id: anvillib_config - ci_build: true - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - config-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - config - with: - module: config - module_id: anvillib-config - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - codec: - uses: ./.github/workflows/build_and_test.yml - with: - module: codec - module_id: anvillib-codec - mod_id: anvillib_codec - ci_build: true - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - codec-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - codec - with: - module: codec - module_id: anvillib-codec - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - multiblock: - uses: ./.github/workflows/build_and_test.yml - needs: - - config - - codec - - network - - util - with: - module: multiblock - module_id: anvillib-multiblock - mod_id: anvillib_multiblock - ci_build: true - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - multiblock-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - multiblock - with: - module: multiblock - module_id: anvillib-multiblock - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - integration: - uses: ./.github/workflows/build_and_test.yml - with: - module: integration - module_id: anvillib-integration - mod_id: anvillib_integration - ci_build: true - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - integration-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - integration - with: - module: integration - module_id: anvillib-integration - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - moveable-entity-block: - uses: ./.github/workflows/build_and_test.yml - with: - module: moveable-entity-block - module_id: anvillib-moveable-entity-block - mod_id: anvillib_moveable_entity_block - ci_build: true - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - moveable-entity-block-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - moveable-entity-block - with: - module: moveable-entity-block - module_id: anvillib-moveable-entity-block - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - network: - uses: ./.github/workflows/build_and_test.yml - with: - module: network - module_id: anvillib-network - mod_id: anvillib_network - ci_build: true - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - network-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - network - with: - module: network - module_id: anvillib-network - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - recipe: - needs: - - config - - codec - - util + prepare: + runs-on: ubuntu-latest + outputs: + level_0: ${{ steps.matrix.outputs.level_0 }} + level_1: ${{ steps.matrix.outputs.level_1 }} + level_2: ${{ steps.matrix.outputs.level_2 }} + steps: + - uses: actions/checkout@v6 + - id: matrix + run: node .github/workflows/generate-matrix.js + + # ── Build (topological levels) ────────────────────────────────── + + build-l0: + needs: [prepare] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_0) }} uses: ./.github/workflows/build_and_test.yml with: - module: recipe - module_id: anvillib-recipe - mod_id: anvillib_recipe + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} + mod_id: ${{ matrix.mod_id }} ci_build: true pr_build: false secrets: maven_url: ${{ secrets.MAVEN_URL }} maven_user: ${{ secrets.MAVEN_USER }} maven_pass: ${{ secrets.MAVEN_PASS }} - recipe-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - recipe - with: - module: recipe - module_id: anvillib-recipe - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - registrum: + + build-l1: + needs: [prepare, build-l0] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_1) }} uses: ./.github/workflows/build_and_test.yml - needs: - - util with: - module: registrum - module_id: anvillib-registrum - mod_id: anvillib_registrum + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} + mod_id: ${{ matrix.mod_id }} ci_build: true pr_build: false secrets: maven_url: ${{ secrets.MAVEN_URL }} maven_user: ${{ secrets.MAVEN_USER }} maven_pass: ${{ secrets.MAVEN_PASS }} - registrum-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - registrum - with: - module: registrum - module_id: anvillib-registrum - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - util: + + build-l2: + needs: [prepare, build-l1] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_2) }} uses: ./.github/workflows/build_and_test.yml - needs: - - codec with: - module: util - module_id: anvillib-util - mod_id: anvillib_util + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} + mod_id: ${{ matrix.mod_id }} ci_build: true pr_build: false secrets: maven_url: ${{ secrets.MAVEN_URL }} maven_user: ${{ secrets.MAVEN_USER }} maven_pass: ${{ secrets.MAVEN_PASS }} - util-maven-central-deploy: + + # ── Maven Central deploy ──────────────────────────────────────── + + deploy-l0: + needs: [prepare, build-l0] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_0) }} uses: ./.github/workflows/publish_maven_central.yml - needs: - - util with: - module: util - module_id: anvillib-util + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} secrets: maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} signing_key: ${{ secrets.SIGNING_KEY }} signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} signing_password: ${{ secrets.SIGNING_PASSWORD }} - wheel: - uses: ./.github/workflows/build_and_test.yml - needs: - - util - with: - module: wheel - module_id: anvillib-wheel - mod_id: anvillib_wheel - ci_build: true - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - wheel-maven-central-deploy: + + deploy-l1: + needs: [prepare, build-l1] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_1) }} uses: ./.github/workflows/publish_maven_central.yml - needs: - - wheel with: - module: wheel - module_id: anvillib-wheel + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} secrets: maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} signing_key: ${{ secrets.SIGNING_KEY }} signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} signing_password: ${{ secrets.SIGNING_PASSWORD }} - yukkuri: - uses: ./.github/workflows/build_and_test.yml - with: - module: yukkuri - module_id: anvillib-yukkuri - mod_id: anvillib_yukkuri - ci_build: true - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - yukkuri-maven-central-deploy: + + deploy-l2: + needs: [prepare, build-l2] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_2) }} uses: ./.github/workflows/publish_maven_central.yml - needs: - - yukkuri with: - module: yukkuri - module_id: anvillib-yukkuri + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} secrets: maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} signing_key: ${{ secrets.SIGNING_KEY }} signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} signing_password: ${{ secrets.SIGNING_PASSWORD }} + + # ── Main (aggregator) ─────────────────────────────────────────── + main: - needs: - - config - - codec - - integration - - moveable-entity-block - - multiblock - - network - - recipe - - registrum - - util - - wheel - - yukkuri + needs: [build-l2] uses: ./.github/workflows/build_and_test.yml with: module: main @@ -331,10 +146,10 @@ jobs: maven_pass: ${{ secrets.MAVEN_PASS }} curseforge_token: ${{ secrets.CURSEFORGE_TOKEN }} modrinth_token: ${{ secrets.MODRINTH_TOKEN }} + main-maven-central-deploy: + needs: [main] uses: ./.github/workflows/publish_maven_central.yml - needs: - - main with: module: main module_id: anvillib diff --git a/.github/workflows/generate-matrix.js b/.github/workflows/generate-matrix.js new file mode 100644 index 00000000..603ba88b --- /dev/null +++ b/.github/workflows/generate-matrix.js @@ -0,0 +1,57 @@ +const fs = require('fs'); +const path = require('path'); + +const modulesPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'modules.json'); +const modules = JSON.parse(fs.readFileSync(modulesPath, 'utf8')); + +// Derive IDs from module name +const all = modules.map(m => ({ + module: m.module, + module_id: `anvillib-${m.module}`, + mod_id: `anvillib_${m.module.replace(/-/g, '_')}`, + needs: m.needs || [] +})); + +// ── Topological sort (Kahn's algorithm) ────────────────────────── +const deps = new Map(all.map(m => [m.module, m.needs])); +const inDegree = new Map(all.map(m => [m.module, m.needs.length])); +const queue = all.filter(m => inDegree.get(m.module) === 0).map(m => m.module); +const levels = []; + +while (queue.length > 0) { + const size = queue.length; + const current = []; + for (let i = 0; i < size; i++) { + const name = queue.shift(); + current.push(name); + for (const m of all) { + if (m.needs.includes(name)) { + const deg = inDegree.get(m.module) - 1; + inDegree.set(m.module, deg); + if (deg === 0) queue.push(m.module); + } + } + } + levels.push(current); +} + +// ── Outputs ────────────────────────────────────────────────────── +const out = process.env.GITHUB_OUTPUT; + +levels.forEach((names, i) => { + const entries = all.filter(m => names.includes(m.module)); + const matrix = JSON.stringify({ + include: entries.map(m => ({ + module: m.module, + module_id: m.module_id, + mod_id: m.mod_id + })) + }); + fs.appendFileSync(out, `level_${i}=${matrix}\n`); +}); + +fs.appendFileSync(out, `level_count=${levels.length}\n`); + +// Flat list for roseau_comment.yml +const names = JSON.stringify(all.map(m => m.module)); +fs.appendFileSync(out, `module_names=${names}\n`); diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index cc611f7a..34389044 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -6,6 +6,12 @@ on: - dev/** # - releases/** paths: + - .github/modules.json + - .github/workflows/build_and_test.yml + - .github/workflows/pull_request.yml + - .github/workflows/roseau_check.yml + - .github/workflows/generate-matrix.js + - gradle/** - module.*/src/** - module.*/build.gradle - module.*/gradle.properties @@ -13,114 +19,95 @@ on: - gradle.properties - settings.gradle +permissions: + contents: read + actions: read + packages: read + pull-requests: write + jobs: - config: - uses: ./.github/workflows/build_and_test.yml - with: - module: config - module_id: anvillib-config - mod_id: anvillib_config - ci_build: true - pr_build: true - codec: - uses: ./.github/workflows/build_and_test.yml - with: - module: codec - module_id: anvillib-codec - mod_id: anvillib_codec - ci_build: true - pr_build: true - integration: - uses: ./.github/workflows/build_and_test.yml - with: - module: integration - module_id: anvillib-integration - mod_id: anvillib_integration - ci_build: true - pr_build: true - moveable-entity-block: - uses: ./.github/workflows/build_and_test.yml - with: - module: moveable-entity-block - module_id: anvillib-moveable-entity-block - mod_id: anvillib_moveable_entity_block - ci_build: true - pr_build: true - multiblock: + prepare: + runs-on: ubuntu-latest + outputs: + level_0: ${{ steps.matrix.outputs.level_0 }} + level_1: ${{ steps.matrix.outputs.level_1 }} + level_2: ${{ steps.matrix.outputs.level_2 }} + steps: + - uses: actions/checkout@v6 + - id: matrix + run: node .github/workflows/generate-matrix.js + + # ── Build (topological levels) ────────────────────────────────── + + build-l0: + needs: [prepare] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_0) }} uses: ./.github/workflows/build_and_test.yml - needs: - - config - - codec - - network - - util with: - module: multiblock - module_id: anvillib-multiblock - mod_id: anvillib_multiblock + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} + mod_id: ${{ matrix.mod_id }} ci_build: true pr_build: true - network: + + build-l1: + needs: [prepare, build-l0] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_1) }} uses: ./.github/workflows/build_and_test.yml with: - module: network - module_id: anvillib-network - mod_id: anvillib_network + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} + mod_id: ${{ matrix.mod_id }} ci_build: true pr_build: true - recipe: - needs: - - config - - codec - - util + + build-l2: + needs: [prepare, build-l1] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_2) }} uses: ./.github/workflows/build_and_test.yml with: - module: recipe - module_id: anvillib-recipe - mod_id: anvillib_recipe + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} + mod_id: ${{ matrix.mod_id }} ci_build: true pr_build: true - registrum: - uses: ./.github/workflows/build_and_test.yml - needs: - - util + + # ── Roseau API checks ─────────────────────────────────────────── + + roseau-l0: + needs: [prepare, build-l0] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_0) }} + uses: ./.github/workflows/roseau_check.yml with: - module: registrum - module_id: anvillib-registrum - mod_id: anvillib_registrum - ci_build: true - pr_build: true - util: - uses: ./.github/workflows/build_and_test.yml - needs: - - codec + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} + + roseau-l1: + needs: [prepare, build-l1] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_1) }} + uses: ./.github/workflows/roseau_check.yml with: - module: util - module_id: anvillib-util - mod_id: anvillib_util - ci_build: true - pr_build: true - wheel: - uses: ./.github/workflows/build_and_test.yml - needs: - - util + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} + + roseau-l2: + needs: [prepare, build-l2] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_2) }} + uses: ./.github/workflows/roseau_check.yml with: - module: wheel - module_id: anvillib-wheel - mod_id: anvillib_wheel - ci_build: true - pr_build: true + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} + + # ── Main (aggregator) ─────────────────────────────────────────── + main: - needs: - - config - - codec - - integration - - moveable-entity-block - - multiblock - - network - - recipe - - registrum - - util - - wheel + needs: [build-l2] uses: ./.github/workflows/build_and_test.yml with: module: main @@ -129,3 +116,10 @@ jobs: ci_build: true pr_build: true gametest: true + + main-roseau: + needs: [main] + uses: ./.github/workflows/roseau_check.yml + with: + module: main + module_id: anvillib diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc8b4d03..be9ff32f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,295 +2,125 @@ name: Release for Mod on: workflow_dispatch: release: + types: + - published branches: - dev/** # - releases/** - paths: - - module.*/src/** - - module.*/build.gradle - - module.*/gradle.properties - - build.gradle - - gradle.properties - - settings.gradle jobs: - config: - uses: ./.github/workflows/build_and_test.yml - with: - module: config - module_id: anvillib-config - mod_id: anvillib_config - ci_build: false - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - config-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - config - with: - module: config - module_id: anvillib-config - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - codec: - uses: ./.github/workflows/build_and_test.yml - with: - module: codec - module_id: anvillib-codec - mod_id: anvillib_codec - ci_build: false - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - codec-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - codec - with: - module: codec - module_id: anvillib-codec - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - integration: - uses: ./.github/workflows/build_and_test.yml - with: - module: integration - module_id: anvillib-integration - mod_id: anvillib_integration - ci_build: false - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - integration-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - integration - with: - module: integration - module_id: anvillib-integration - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - moveable-entity-block: - uses: ./.github/workflows/build_and_test.yml - with: - module: moveable-entity-block - module_id: anvillib-moveable-entity-block - mod_id: anvillib_moveable_entity_block - ci_build: false - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - moveable-entity-block-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - moveable-entity-block - with: - module: moveable-entity-block - module_id: anvillib-moveable-entity-block - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - multiblock: - uses: ./.github/workflows/build_and_test.yml - needs: - - config - - codec - - network - - util - with: - module: multiblock - module_id: anvillib-multiblock - mod_id: anvillib_multiblock - ci_build: false - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - multiblock-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - multiblock - with: - module: multiblock - module_id: anvillib-multiblock - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - network: + prepare: + runs-on: ubuntu-latest + outputs: + level_0: ${{ steps.matrix.outputs.level_0 }} + level_1: ${{ steps.matrix.outputs.level_1 }} + level_2: ${{ steps.matrix.outputs.level_2 }} + steps: + - uses: actions/checkout@v6 + - id: matrix + run: node .github/workflows/generate-matrix.js + + # ── Build (topological levels) ────────────────────────────────── + + build-l0: + needs: [prepare] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_0) }} uses: ./.github/workflows/build_and_test.yml with: - module: network - module_id: anvillib-network - mod_id: anvillib_network + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} + mod_id: ${{ matrix.mod_id }} ci_build: false pr_build: false secrets: maven_url: ${{ secrets.MAVEN_URL }} maven_user: ${{ secrets.MAVEN_USER }} maven_pass: ${{ secrets.MAVEN_PASS }} - network-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - network - with: - module: network - module_id: anvillib-network - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - recipe: - needs: - - config - - codec - - util + + build-l1: + needs: [prepare, build-l0] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_1) }} uses: ./.github/workflows/build_and_test.yml with: - module: recipe - module_id: anvillib-recipe - mod_id: anvillib_recipe + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} + mod_id: ${{ matrix.mod_id }} ci_build: false pr_build: false secrets: maven_url: ${{ secrets.MAVEN_URL }} maven_user: ${{ secrets.MAVEN_USER }} maven_pass: ${{ secrets.MAVEN_PASS }} - recipe-maven-central-deploy: - uses: ./.github/workflows/publish_maven_central.yml - needs: - - recipe - with: - module: recipe - module_id: anvillib-recipe - secrets: - maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - signing_key: ${{ secrets.SIGNING_KEY }} - signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} - signing_password: ${{ secrets.SIGNING_PASSWORD }} - registrum: + + build-l2: + needs: [prepare, build-l1] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_2) }} uses: ./.github/workflows/build_and_test.yml - needs: - - util with: - module: registrum - module_id: anvillib-registrum - mod_id: anvillib_registrum + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} + mod_id: ${{ matrix.mod_id }} ci_build: false pr_build: false secrets: maven_url: ${{ secrets.MAVEN_URL }} maven_user: ${{ secrets.MAVEN_USER }} maven_pass: ${{ secrets.MAVEN_PASS }} - registrum-maven-central-deploy: + + # ── Maven Central deploy ──────────────────────────────────────── + + deploy-l0: + needs: [prepare, build-l0] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_0) }} uses: ./.github/workflows/publish_maven_central.yml - needs: - - registrum with: - module: registrum - module_id: anvillib-registrum + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} secrets: maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} signing_key: ${{ secrets.SIGNING_KEY }} signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} signing_password: ${{ secrets.SIGNING_PASSWORD }} - util: - uses: ./.github/workflows/build_and_test.yml - needs: - - codec - with: - module: util - module_id: anvillib-util - mod_id: anvillib_util - ci_build: false - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - util-maven-central-deploy: + + deploy-l1: + needs: [prepare, build-l1] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_1) }} uses: ./.github/workflows/publish_maven_central.yml - needs: - - util with: - module: util - module_id: anvillib-util + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} secrets: maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} signing_key: ${{ secrets.SIGNING_KEY }} signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} signing_password: ${{ secrets.SIGNING_PASSWORD }} - wheel: - uses: ./.github/workflows/build_and_test.yml - needs: - - util - with: - module: wheel - module_id: anvillib-wheel - mod_id: anvillib_wheel - ci_build: false - pr_build: false - secrets: - maven_url: ${{ secrets.MAVEN_URL }} - maven_user: ${{ secrets.MAVEN_USER }} - maven_pass: ${{ secrets.MAVEN_PASS }} - wheel-maven-central-deploy: + + deploy-l2: + needs: [prepare, build-l2] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.level_2) }} uses: ./.github/workflows/publish_maven_central.yml - needs: - - wheel with: - module: wheel - module_id: anvillib-wheel + module: ${{ matrix.module }} + module_id: ${{ matrix.module_id }} secrets: maven_central_username: ${{ secrets.MAVEN_CENTRAL_USERNAME }} maven_central_password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} signing_key: ${{ secrets.SIGNING_KEY }} signing_public_key: ${{ secrets.SIGNING_PUBLIC_KEY }} signing_password: ${{ secrets.SIGNING_PASSWORD }} + + # ── Main (aggregator) ─────────────────────────────────────────── + main: - needs: - - config - - codec - - integration - - moveable-entity-block - - multiblock - - network - - recipe - - registrum - - util - - wheel + needs: [build-l2] uses: ./.github/workflows/build_and_test.yml with: module: main @@ -302,10 +132,10 @@ jobs: maven_url: ${{ secrets.MAVEN_URL }} maven_user: ${{ secrets.MAVEN_USER }} maven_pass: ${{ secrets.MAVEN_PASS }} + main-maven-central-deploy: + needs: [main] uses: ./.github/workflows/publish_maven_central.yml - needs: - - main with: module: main module_id: anvillib diff --git a/.github/workflows/roseau_check.yml b/.github/workflows/roseau_check.yml new file mode 100644 index 00000000..a48a5e92 --- /dev/null +++ b/.github/workflows/roseau_check.yml @@ -0,0 +1,77 @@ +name: Roseau Check + +on: + workflow_call: + inputs: + module: + description: 'Module directory name (e.g. codec, config)' + required: true + type: string + module_id: + description: 'Module short ID (e.g. anvillib-codec)' + required: true + type: string + outputs: + has_bc: + description: 'Whether breaking changes were detected' + value: ${{ jobs.check.outputs.has_bc }} + bc_count: + description: 'Number of breaking changes' + value: ${{ jobs.check.outputs.bc_count }} + +jobs: + check: + runs-on: ubuntu-latest + outputs: + has_bc: ${{ steps.result.outputs.has_bc }} + bc_count: ${{ steps.result.outputs.bc_count }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Read Java version + id: properties + uses: christian-draeger/read-properties@1.1.1 + with: + path: gradle.properties + properties: java_version + + - name: Setup Java ${{ steps.properties.outputs.java_version }} + uses: actions/setup-java@v5.2.0 + with: + distribution: zulu + java-version: ${{ steps.properties.outputs.java_version }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Run roseauCheck + id: roseau + continue-on-error: true + run: | + chmod +x ./gradlew + ./gradlew :${{ inputs.module_id }}-neoforge-1.21.1:roseauCheck + + - name: Collect result + id: result + if: always() + run: | + CSV="module.${{ inputs.module }}/build/reports/roseau/report.csv" + if [ -f "$CSV" ] && [ "$(wc -l < "$CSV")" -gt 1 ]; then + COUNT=$(($(wc -l < "$CSV") - 1)) + echo "has_bc=true" >> $GITHUB_OUTPUT + echo "bc_count=${COUNT}" >> $GITHUB_OUTPUT + echo "Roseau: ${COUNT} breaking change(s) in ${{ inputs.module }}" + else + echo "has_bc=false" >> $GITHUB_OUTPUT + echo "bc_count=0" >> $GITHUB_OUTPUT + echo "Roseau: no breaking changes in ${{ inputs.module }}" + fi + + - name: Upload report + if: always() + uses: actions/upload-artifact@v7 + with: + name: roseau-${{ inputs.module }} + path: module.${{ inputs.module }}/build/reports/roseau/ + retention-days: 7 diff --git a/.github/workflows/roseau_comment.yml b/.github/workflows/roseau_comment.yml new file mode 100644 index 00000000..6cc1eb35 --- /dev/null +++ b/.github/workflows/roseau_comment.yml @@ -0,0 +1,133 @@ +name: Roseau PR Comment + +on: + workflow_run: + workflows: + - Pull Request Check + types: + - completed + +permissions: + contents: read + actions: read + pull-requests: write + +jobs: + comment: + if: ${{ github.event.workflow_run.conclusion != 'skipped' }} + runs-on: ubuntu-latest + steps: + - name: Download Roseau reports + uses: actions/download-artifact@v4 + with: + pattern: roseau-* + path: roseau-reports + run-id: ${{ github.event.workflow_run.id }} + + - name: Checkout repo (needed for gh CLI) + uses: actions/checkout@v4 + + - name: Find PR number + id: pr + run: | + HEAD_BRANCH="${{ github.event.workflow_run.head_branch }}" + HEAD_SHA="${{ github.event.workflow_run.head_sha }}" + PR_NUMBER=$(gh pr list \ + --head "$HEAD_BRANCH" \ + --state open \ + --json number \ + --jq '.[0].number') + if [ -z "$PR_NUMBER" ]; then + PR_NUMBER=$(gh pr list \ + --search "$HEAD_SHA" \ + --state open \ + --json number \ + --jq '.[0].number') + fi + if [ -z "$PR_NUMBER" ]; then + echo "::warning::Could not find PR number for branch=$HEAD_BRANCH sha=$HEAD_SHA" + fi + echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.PAT_TOKEN }} + + - name: Generate report body + run: | + MODULES=( + codec collision config explosion font integration + moveable-entity-block multiblock network recipe registrum + rpc space-select sync util wheel yukkuri main + ) + + row() { + local name=$1 dir=$2 + local csv="${dir}/report.csv" + if [ -f "$csv" ] && [ "$(wc -l < "$csv")" -gt 1 ]; then + local count=$(($(wc -l < "$csv") - 1)) + echo "| ${name} | 🔴 BC detected | ${count} |" + elif [ -f "$csv" ]; then + echo "| ${name} | ✅ Compatible | 0 |" + else + echo "| ${name} | ⚪ Skipped | — |" + fi + } + + append_details() { + local name=$1 dir=$2 + local csv="${dir}/report.csv" + if [ ! -f "$csv" ]; then return; fi + local count=$(($(wc -l < "$csv") - 1)) + if [ "$count" -le 0 ]; then return; fi + { + echo "### 🔴 ${name} — ${count} breaking change(s)" + echo "" + while IFS=';' read -r type symbol kind nature location newSymbol binaryBreaking sourceBreaking; do + echo "#### \`${location}\`" + echo '```' + echo "${type}" + echo "${symbol}" + echo "${kind}" + [ "$binaryBreaking" = "true" ] && echo "✗ binary-breaking" || echo "✓ binary-compatible" + [ "$sourceBreaking" = "true" ] && echo "✗ source-breaking" || echo "✓ source-compatible" + echo '```' + echo "" + done < <(tail -n +2 "$csv") + } >> /tmp/roseau-body.md + } + + { + echo "## 🌿 Roseau API Breaking Change Report" + echo "" + echo "| Module | Status | Breaking Changes |" + echo "|--------|--------|-----------------|" + for mod in "${MODULES[@]}"; do + row "$mod" "roseau-reports/roseau-${mod}" + done + echo "" + } > /tmp/roseau-body.md + + for mod in "${MODULES[@]}"; do + append_details "$mod" "roseau-reports/roseau-${mod}" + done + + echo "> Full CSVs: see the *Artifacts* section of [this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})." >> /tmp/roseau-body.md + + - name: Find existing Roseau comment + id: find-comment + if: steps.pr.outputs.number != '' + uses: peter-evans/find-comment@v3 + with: + token: ${{ secrets.PAT_TOKEN }} + issue-number: ${{ steps.pr.outputs.number }} + comment-author: anvil-craft + body-includes: Roseau API Breaking Change Report + + - name: Create or update PR comment + if: steps.pr.outputs.number != '' + uses: peter-evans/create-or-update-comment@v4 + with: + token: ${{ secrets.PAT_TOKEN }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ steps.pr.outputs.number }} + body-path: /tmp/roseau-body.md + edit-mode: replace diff --git a/.gitignore b/.gitignore index 0a5cf974..2f8c2370 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,4 @@ run/ !gradle-wrapper.jar reference/ +ref/ diff --git a/build.gradle b/build.gradle index 9b94d9bf..d4c18b52 100644 --- a/build.gradle +++ b/build.gradle @@ -15,3 +15,9 @@ dependencies { test { useJUnitPlatform() } + +gradle.projectsEvaluated { + subprojects { + apply from: rootProject.file("gradle/scripts/roseau.gradle") + } +} diff --git a/gradle/scripts/roseau.gradle b/gradle/scripts/roseau.gradle new file mode 100644 index 00000000..e64497bd --- /dev/null +++ b/gradle/scripts/roseau.gradle @@ -0,0 +1,96 @@ +configurations { + roseau +} + +dependencies { + roseau 'io.github.alien-tools:roseau-cli:0.4.0' +} + +// 基线构件统一由根工程解析:子工程在设置 group/version 后解析自身坐标会被 +// Gradle 解析成本地工程组件('default' 配置缺失),故借根工程仓库规避自引用。 +def ensureRootRepositories = { + if (!rootProject.repositories.any { it instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository && it.url.toString().contains('server.cjsah.net') }) { + rootProject.repositories.maven { it.url = 'https://server.cjsah.net:1002/maven/' } + } + if (!rootProject.repositories.any { it instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository && it.url.toString().startsWith('file:') }) { + rootProject.repositories.mavenLocal() + } +} +ensureRootRepositories() + +def roseauGroup = project.group.toString() +def roseauArtifact = project.name +def roseauBaselineVersion = project.findProperty('roseauBaselineVersion') ?: { + def groupPath = project.group.toString().replace('.', '/') + def artifact = project.name + def repos = [ + 'https://repo1.maven.org/maven2', + 'https://server.cjsah.net:1002/maven', + ] + for (repoUrl in repos) { + try { + def metadataUrl = new URL("${repoUrl}/${groupPath}/${artifact}/maven-metadata.xml") + def conn = metadataUrl.openConnection() + conn.connectTimeout = 5000 + conn.readTimeout = 5000 + def xmlText = conn.inputStream.getText('UTF-8') + def matcher = xmlText =~ /([^<]+)<\/latest>/ + if (matcher.find()) { + def latest = matcher.group(1) + logger.lifecycle("Roseau: resolved latest version ${latest} from ${repoUrl}") + return latest + } + } catch (Exception ignored) { + // try next repo + } + } + logger.warn("Roseau: could not resolve latest version from Maven, falling back to ${version}") + return version.toString() +}() +def roseauMvnCoord = "${roseauGroup}:${roseauArtifact}:${roseauBaselineVersion}" +def roseauConfig = rootProject.file("roseau.yaml").absolutePath +def roseauLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) +} + +tasks.register('roseauCheck') { + group = 'verification' + description = 'Detects API breaking changes against a baseline version using Roseau' + + dependsOn tasks.named('jar', Jar) + + notCompatibleWithConfigurationCache('roseauCheck 基线解析依赖 detached configuration,无法参与配置缓存') + + doFirst { + def currentJar = tasks.named('jar', Jar).get().archiveFile.get().asFile + def baselineDep = dependencies.create(group: roseauGroup, name: roseauArtifact, version: roseauBaselineVersion) + def baselineConfig = rootProject.configurations.detachedConfiguration(baselineDep) + baselineConfig.transitive = false + def baselineJar = baselineConfig.singleFile + def reportsDir = layout.buildDirectory.dir('reports/roseau').get().asFile + reportsDir.mkdirs() + + logger.lifecycle("Roseau: comparing baseline ${roseauMvnCoord} against ${currentJar.name}") + + // roseau-cli 0.4.0(Java 21 兼容)每次 --report 仅支持单一格式,故分别产出 CSV 与 HTML + def runRoseau = { String format, String reportFile -> + javaexec { + executable = roseauLauncher.get().executablePath.asFile.absolutePath + classpath = configurations.roseau + mainClass = 'io.github.alien.roseau.cli.RoseauCLI' + args( + '--diff', + '--v1', baselineJar.absolutePath, + '--v2', currentJar.absolutePath, + '--classpath', sourceSets.main.compileClasspath.asPath, + '--fail-on-bc', + '--report', reportFile, + '--format', format, + '--config', roseauConfig + ) + } + } + runRoseau('CSV', new File(reportsDir, 'report.csv').absolutePath) + runRoseau('HTML', new File(reportsDir, 'report.html').absolutePath) + } +} diff --git a/module.codec/src/main/java/dev/anvilcraft/lib/v2/codec/CodecUtil.java b/module.codec/src/main/java/dev/anvilcraft/lib/v2/codec/CodecUtil.java index 740a1474..d554da40 100644 --- a/module.codec/src/main/java/dev/anvilcraft/lib/v2/codec/CodecUtil.java +++ b/module.codec/src/main/java/dev/anvilcraft/lib/v2/codec/CodecUtil.java @@ -1,6 +1,21 @@ package dev.anvilcraft.lib.v2.codec; +import com.mojang.datafixers.kinds.App; import com.mojang.datafixers.util.Either; +import com.mojang.datafixers.util.Function10; +import com.mojang.datafixers.util.Function11; +import com.mojang.datafixers.util.Function12; +import com.mojang.datafixers.util.Function13; +import com.mojang.datafixers.util.Function14; +import com.mojang.datafixers.util.Function15; +import com.mojang.datafixers.util.Function16; +import com.mojang.datafixers.util.Function3; +import com.mojang.datafixers.util.Function4; +import com.mojang.datafixers.util.Function5; +import com.mojang.datafixers.util.Function6; +import com.mojang.datafixers.util.Function7; +import com.mojang.datafixers.util.Function8; +import com.mojang.datafixers.util.Function9; import com.mojang.datafixers.util.Pair; import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult; @@ -13,6 +28,7 @@ import lombok.NoArgsConstructor; import net.minecraft.core.NonNullList; import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.codec.StreamCodec; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.entity.EntityType; import net.minecraft.world.item.Item; @@ -373,4 +389,1300 @@ public static MapCodec> zomListMap(Codec codec, String name) { public static DataResult encodeStart(MapCodec codec, DynamicOps ops, T input) { return codec.encode(input, ops, codec.compressedBuilder(ops)).build(ops.emptyMap()); } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + Function factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + BiFunction factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + Function3 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + Function4 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3, t4).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + Function5 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3, t4, t5).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + Function6 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3, t4, t5, t6).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + Function7 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + Function8 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + Function9 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + Function10 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param t11 第十一个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// @param 第十一个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + App, T11> t11, + Function11 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param t11 第十一个参数 + /// @param t12 第十二个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// @param 第十一个参数的类型 + /// @param 第十二个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + App, T11> t11, + App, T12> t12, + Function12 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param t11 第十一个参数 + /// @param t12 第十二个参数 + /// @param t13 第十三个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// @param 第十一个参数的类型 + /// @param 第十二个参数的类型 + /// @param 第十三个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + App, T11> t11, + App, T12> t12, + App, T13> t13, + Function13 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param t11 第十一个参数 + /// @param t12 第十二个参数 + /// @param t13 第十三个参数 + /// @param t14 第十四个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// @param 第十一个参数的类型 + /// @param 第十二个参数的类型 + /// @param 第十三个参数的类型 + /// @param 第十四个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + App, T11> t11, + App, T12> t12, + App, T13> t13, + App, T14> t14, + Function14 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13, t14).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param t11 第十一个参数 + /// @param t12 第十二个参数 + /// @param t13 第十三个参数 + /// @param t14 第十四个参数 + /// @param t15 第十五个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// @param 第十一个参数的类型 + /// @param 第十二个参数的类型 + /// @param 第十三个参数的类型 + /// @param 第十四个参数的类型 + /// @param 第十五个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + App, T11> t11, + App, T12> t12, + App, T13> t13, + App, T14> t14, + App, T15> t15, + Function15 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13, t14, t15).apply(inst, factory)); + } + + /// 创建 {@link Codec} 的便携方法,对 {@link RecordCodecBuilder#create(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param t11 第十一个参数 + /// @param t12 第十二个参数 + /// @param t13 第十三个参数 + /// @param t14 第十四个参数 + /// @param t15 第十五个参数 + /// @param t16 第十六个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// @param 第十一个参数的类型 + /// @param 第十二个参数的类型 + /// @param 第十三个参数的类型 + /// @param 第十四个参数的类型 + /// @param 第十五个参数的类型 + /// @param 第十六个参数的类型 + /// + /// @return 结果的 {@link Codec} + public static Codec create( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + App, T11> t11, + App, T12> t12, + App, T13> t13, + App, T14> t14, + App, T15> t15, + App, T16> t16, + Function16 factory + ) { + return RecordCodecBuilder.create(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13, t14, t15, t16).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + Function factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + BiFunction factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + Function3 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + Function4 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3, t4).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + Function5 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3, t4, t5).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + Function6 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3, t4, t5, t6).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + Function7 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + Function8 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + Function9 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + Function10 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param t11 第十一个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// @param 第十一个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + App, T11> t11, + Function11 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param t11 第十一个参数 + /// @param t12 第十二个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// @param 第十一个参数的类型 + /// @param 第十二个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + App, T11> t11, + App, T12> t12, + Function12 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param t11 第十一个参数 + /// @param t12 第十二个参数 + /// @param t13 第十三个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// @param 第十一个参数的类型 + /// @param 第十二个参数的类型 + /// @param 第十三个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + App, T11> t11, + App, T12> t12, + App, T13> t13, + Function13 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param t11 第十一个参数 + /// @param t12 第十二个参数 + /// @param t13 第十三个参数 + /// @param t14 第十四个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// @param 第十一个参数的类型 + /// @param 第十二个参数的类型 + /// @param 第十三个参数的类型 + /// @param 第十四个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + App, T11> t11, + App, T12> t12, + App, T13> t13, + App, T14> t14, + Function14 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13, t14).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param t11 第十一个参数 + /// @param t12 第十二个参数 + /// @param t13 第十三个参数 + /// @param t14 第十四个参数 + /// @param t15 第十五个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// @param 第十一个参数的类型 + /// @param 第十二个参数的类型 + /// @param 第十三个参数的类型 + /// @param 第十四个参数的类型 + /// @param 第十五个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + App, T11> t11, + App, T12> t12, + App, T13> t13, + App, T14> t14, + App, T15> t15, + Function15 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13, t14, t15).apply(inst, factory)); + } + + /// 创建 {@link MapCodec} 的便携方法,对 {@link RecordCodecBuilder#mapCodec(Function)} 的代理 + /// + ///

参考了 {@link net.minecraft.network.codec.StreamCodec#composite(StreamCodec, Function, Function)} 的设计

+ /// + /// @param t1 第一个参数 + /// @param t2 第二个参数 + /// @param t3 第三个参数 + /// @param t4 第四个参数 + /// @param t5 第五个参数 + /// @param t6 第六个参数 + /// @param t7 第七个参数 + /// @param t8 第八个参数 + /// @param t9 第九个参数 + /// @param t10 第十个参数 + /// @param t11 第十一个参数 + /// @param t12 第十二个参数 + /// @param t13 第十三个参数 + /// @param t14 第十四个参数 + /// @param t15 第十五个参数 + /// @param t16 第十六个参数 + /// @param factory 使用所有参数创建结果的工厂 + /// + /// @param 结果的类型 + /// @param 第一个参数的类型 + /// @param 第二个参数的类型 + /// @param 第三个参数的类型 + /// @param 第四个参数的类型 + /// @param 第五个参数的类型 + /// @param 第六个参数的类型 + /// @param 第七个参数的类型 + /// @param 第八个参数的类型 + /// @param 第九个参数的类型 + /// @param 第十个参数的类型 + /// @param 第十一个参数的类型 + /// @param 第十二个参数的类型 + /// @param 第十三个参数的类型 + /// @param 第十四个参数的类型 + /// @param 第十五个参数的类型 + /// @param 第十六个参数的类型 + /// + /// @return 结果的 {@link MapCodec} + public static MapCodec mapCodec( + App, T1> t1, + App, T2> t2, + App, T3> t3, + App, T4> t4, + App, T5> t5, + App, T6> t6, + App, T7> t7, + App, T8> t8, + App, T9> t9, + App, T10> t10, + App, T11> t11, + App, T12> t12, + App, T13> t13, + App, T14> t14, + App, T15> t15, + App, T16> t16, + Function16 factory + ) { + return RecordCodecBuilder.mapCodec(inst -> inst.group(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13, t14, t15, t16).apply(inst, factory)); + } } diff --git a/module.codec/src/main/java/dev/anvilcraft/lib/v2/codec/package-info.java b/module.codec/src/main/java/dev/anvilcraft/lib/v2/codec/package-info.java index 3da08152..aca1fab5 100644 --- a/module.codec/src/main/java/dev/anvilcraft/lib/v2/codec/package-info.java +++ b/module.codec/src/main/java/dev/anvilcraft/lib/v2/codec/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.codec; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.collision/build.gradle b/module.collision/build.gradle new file mode 100644 index 00000000..07308738 --- /dev/null +++ b/module.collision/build.gradle @@ -0,0 +1,266 @@ +plugins { + id 'java-library' + id 'eclipse' + id 'idea' + id 'maven-publish' + alias libs.plugins.modDevGradle + alias libs.plugins.lombok + alias libs.plugins.machete + id 'org.jreleaser' version '1.23.0' + id "me.modmuss50.mod-publish-plugin" version "1.1.0" +} + +String buildType = 'snapshot' +String buildNumber +if (System.getenv("CI_BUILD") == 'false') buildNumber = null +else { + if (System.getenv("PR_BUILD") != 'false') buildType = 'pr' + buildNumber = System.getenv("GITHUB_RUN_NUMBER") +} +version = "${mod_version}" + (buildNumber != null ? "+${buildType}.${buildNumber}" : "") +group = mod_group_id + +base { + archivesName = "${project.mod_id}-neoforge-${libs.versions.minecraft.get()}" +} + +repositories { + maven { url = "https://server.cjsah.net:1002/maven/" } + mavenLocal() + mavenCentral() +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +neoForge { + // Specify the version of NeoForge to use. + version = libs.versions.neoForge.get() + + parchment { + mappingsVersion = project.parchment_mappings_version + minecraftVersion = project.parchment_minecraft_version + } + + // This line is optional. Access Transformers are automatically detected + // accessTransformers.add('src/main/resources/META-INF/accesstransformer.cfg') + + interfaceInjectionData { + // from 'src/main/resources/interface_injections.json' + // publish file('src/main/resources/interface_injections.json') + } + + // Default run configurations. + // These can be tweaked, removed, or duplicated as needed. + runs { + client { + client() + + // Comma-separated list of namespaces to load gametests from. Empty = all namespaces. + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + server { + server() + programArgument '--nogui' + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + // This run config launches GameTestServer and runs all registered gametests, then exits. + // By default, the server will crash when no gametests are provided. + // The gametest system is also enabled by default for other run configs under the /test command. + gameTestServer { + type = "gameTestServer" + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + data { + data() + + // example of overriding the workingDirectory set in configureEach above, uncomment if you want to use it + // gameDirectory = project.file('run-data') + + // Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources. + programArguments.addAll '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/').getAbsolutePath(), '--existing', file('src/main/resources/').getAbsolutePath() + } + + // applies to all the run configs above + configureEach { + // Recommended logging data for a userdev environment + // The markers can be added/remove as needed separated by commas. + // "SCAN": For mods scan. + // "REGISTRIES": For firing of registry events. + // "REGISTRYDUMP": For getting the contents of all registries. + systemProperty 'forge.logging.markers', 'REGISTRIES' + + // Recommended logging level for the console + // You can set various levels here. + // Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels + logLevel = org.slf4j.event.Level.DEBUG + } + } + + mods { + // define mod <-> source bindings + // these are used to tell the game which sources are for which mod + // mostly optional in a single mod project + // but multi mod projects should define one per mod + "${mod_id}" { + sourceSet(sourceSets.main) + } + } +} + +// Include resources generated by data generators. +sourceSets.main.resources { srcDir 'src/generated/resources' } + +// This block of code expands all declared replace properties in the specified resource targets. +// A missing property will result in an error. Properties are expanded using ${} Groovy notation. +// When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments. +// See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html +tasks.withType(ProcessResources).configureEach { + var replaceProperties = [minecraft_version : libs.versions.minecraft.get(), + minecraft_version_range: minecraft_version_range, + neo_version : libs.versions.neoForge.get(), + neo_version_range : neo_version_range, + loader_version_range : loader_version_range, + mod_id : mod_id, + mod_name : mod_name, + mod_license : mod_license, + mod_version : version, + mod_authors : mod_authors, + mod_description : mod_description] + inputs.properties replaceProperties + + filesMatching(['META-INF/neoforge.mods.toml']) { + expand replaceProperties + } +} + +tasks.register('sourcesJar', Jar) { + from delombok.outputs.files + dependsOn delombok + archiveClassifier = "sources" +} + +tasks.register('javadocJar', Jar) { + from javadoc.destinationDir + dependsOn javadoc + archiveClassifier = "javadoc" +} + +javadoc { + options.encoding = 'UTF-8' + options.addStringOption('Xdoclint:none', '-quiet') +} + +jar { + from(rootProject.file("LICENSE")) + from(rootProject.file("ASSETS_LICENSE")) + from(rootProject.file("README.md")) + from(rootProject.file("README.en.md")) +} + +artifacts { + archives tasks.jar + archives tasks.sourcesJar + archives tasks.javadocJar +} + +def this_name = base.archivesName.get() +def this_description = mod_description + +publishing { + publications { + register('mavenJava', MavenPublication) { + from components.java + + artifact sourcesJar + artifact javadocJar + + versionMapping { + allVariants { + fromResolutionResult() + } + } + + pom { + name = this_name + description = this_description + url = 'https://github.com/Anvil-Dev/AnvilLib' + + licenses { + license { + name = 'MIT License' + url = 'https://opensource.org/license/mit' + } + } + developers { + developer { + id = 'Anvil-Dev' + name = 'AnvilCraft Dev' + email = 'admin@anvilcraft.dev' + } + } + scm { + url = 'https://github.com/Anvil-Dev/AnvilLib' + connection = 'https://github.com/Anvil-Dev/AnvilLib.git' + developerConnection = 'https://github.com/Anvil-Dev/AnvilLib.git' + } + } + } + } + repositories { + // Add repositories to publish to here. + def MAVEN_URL = System.getenv("MAVEN_URL") + if (MAVEN_URL != null) { + maven { + url MAVEN_URL + credentials { + username System.getenv("MAVEN_USERNAME") + password System.getenv("MAVEN_PASSWORD") + } + } + } + mavenLocal() + maven { + name = "staging" + url = layout.buildDirectory.dir("staging-deploy") + } + } +} + +jreleaser { + signing { + active = 'ALWAYS' + armored = true + } + + deploy { + maven { + mavenCentral { + sonatype { + active = 'ALWAYS' + url = 'https://central.sonatype.com/api/v1/publisher' + stagingRepository('build/staging-deploy') + } + } + } + } +} + +// IDEA no longer automatically downloads sources/javadoc jars for dependencies, so we need to explicitly enable the behavior. +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} + +machete { + // disable machete locally for faster builds + enabled = false +} + +lombok { + version = "1.18.34" +} diff --git a/module.collision/gradle.properties b/module.collision/gradle.properties new file mode 100644 index 00000000..ca29d5c4 --- /dev/null +++ b/module.collision/gradle.properties @@ -0,0 +1,4 @@ +## Mod Properties +mod_id=anvillib_collision +mod_name=AnvilLib-Collision +mod_description=A simple library for calculating collisions diff --git a/module.collision/src/main/java/dev/anvilcraft/lib/v2/collision/AnvilLibCollision.java b/module.collision/src/main/java/dev/anvilcraft/lib/v2/collision/AnvilLibCollision.java new file mode 100644 index 00000000..2763b87f --- /dev/null +++ b/module.collision/src/main/java/dev/anvilcraft/lib/v2/collision/AnvilLibCollision.java @@ -0,0 +1,505 @@ +package dev.anvilcraft.lib.v2.collision; + +import org.joml.Vector3d; +import org.joml.Vector3dc; +import org.joml.Vector3fc; + +public class AnvilLibCollision { + /** + * 沿三轴独立扫掠:AABB 分别沿 X、Y、Z 轴运动,逐轴计算最大安全位移。 + *

+ * 每个轴的处理是独立的——沿 X 扫掠时 Y、Z 坐标保持不变,沿 Y 扫掠时 X、Z + * 不变。因此当对角方向有障碍物时可能偏保守。 + *

+ * 三轴独立的好处是与 Minecraft 原版碰撞一致:各分量被各自的障碍物"推开", + * 避免了沿斜面滑动的问题。 + * + * @param min AABB 最小角(世界坐标) + * @param max AABB 最大角(世界坐标) + * @param motion 各轴最大位移(分量可正可负) + * @param triangles 三角形数组,每 3 个顶点构成一个三角形 + * @param epsilon 碰撞容差——间距小于此值即视为碰撞 + * @return 实际可移动位移,各分量符号与 {@code motion} 一致, + * 绝对值 ≤ {@code |motion|}。已重叠 → 0,无碰撞 → 原始分量 + */ + public static Vector3dc intersectsAABBTriangle(Vector3dc min, Vector3dc max, Vector3dc motion, Vector3fc[] triangles, double epsilon) { + Vector3d center = min.add(max, new Vector3d()).mul(0.5); + Vector3d halfExtents = max.sub(min, new Vector3d()).mul(0.5); + Vector3d result = new Vector3d(); + + for (int axisIndex = 0; axisIndex < 3; axisIndex++) { + double d = motion.get(axisIndex); + if (Math.abs(d) < 1e-15) { + result.setComponent(axisIndex, 0.0); + continue; + } + + Vector3d axisVec = new Vector3d(); + axisVec.setComponent(axisIndex, 1.0); + + double safeDist = d; + + int triCount = triangles.length / 3; + for (int i = 0; i < triCount; i++) { + Vector3fc V0 = triangles[i * 3]; + Vector3fc V1 = triangles[i * 3 + 1]; + Vector3fc V2 = triangles[i * 3 + 2]; + + double tHit = collisionDistanceOnAxis(V0, V1, V2, center, halfExtents, axisVec, d, epsilon); + + if (d > 0) { + if (tHit < safeDist && tHit >= 0) { + safeDist = tHit; + } + } else { + if (tHit > safeDist && tHit <= 0) { + safeDist = tHit; + } + } + + if ((d > 0 && safeDist <= 0) || (d < 0 && safeDist >= 0)) { + break; + } + } + result.setComponent(axisIndex, safeDist); + } + return result; + } + + /** + * 真扫掠碰撞:AABB 沿 {@code motion} 方向平移,求首次碰到任意三角形时的位移。 + *

+ * 与 {@link #intersectsAABBTriangle(Vector3dc, Vector3dc, Vector3dc, Vector3fc[], double)} + * 的三轴独立不同,此方法沿 motion 的合成方向扫掠,适用于需要精确斜角碰撞判定的场景。 + *

+ * 返回值为 {@code motion * t},其中 t ∈ [0,1] 为安全比例: + *

    + *
  • t = 1 → 全程无碰撞
  • + *
  • t = 0 → 起始位置已重叠
  • + *
  • {@code 0 < t < 1} → 碰撞发生在运动途中
  • + *
+ *

+ * 对每个三角形使用 SAT(分离轴定理)计算碰撞时间区间,取所有三角形中的最小值。 + * + * @param min AABB 最小角(世界坐标) + * @param max AABB 最大角(世界坐标) + * @param motion 位移向量(方向 + 大小) + * @param triangles 三角形数组,每 3 个顶点构成一个三角形 + * @param epsilon 碰撞容差——间距小于此值即视为碰撞 + * @return 沿 {@code motion} 方向的最大安全位移向量。 + * 无碰撞时等于 {@code motion},初始重叠时为零向量 + */ + public static Vector3dc sweptCollisionAABBTriangle(Vector3dc min, Vector3dc max, Vector3dc motion, Vector3fc[] triangles, double epsilon) { + double dist = motion.length(); + if (dist < 1e-15) { + return new Vector3d(); + } + + Vector3d dir = motion.div(dist, new Vector3d()); + Vector3d center = min.add(max, new Vector3d()).mul(0.5); + Vector3d halfExtents = max.sub(min, new Vector3d()).mul(0.5); + + double minT = dist; + int triCount = triangles.length / 3; + for (int i = 0; i < triCount; i++) { + Vector3fc V0 = triangles[i * 3]; + Vector3fc V1 = triangles[i * 3 + 1]; + Vector3fc V2 = triangles[i * 3 + 2]; + + double tHit = collisionDistanceOnAxis(V0, V1, V2, center, halfExtents, dir, dist, epsilon); + if (tHit < minT) { + minT = tHit; + if (minT <= 0) break; + } + } + + return dir.mul(minT, new Vector3d()); + } + + /** + * 计算 AABB 沿 {@code axisVec} 方向运动时,与单个三角形的首次碰撞位移。 + *

+ * 三角形顶点被平移到以 {@code center} 为原点的局部坐标系, + * 使用 SAT(分离轴定理)扫描 13 条轴(3 坐标轴 + 1 面法线 + 9 边叉积轴), + * 由 {@link #updateInterval} 逐轴收窄碰撞时间区间,最后取与运动方向一致的边界。 + * + * @param V0,V1,V2 三角形顶点(世界坐标) + * @param center AABB 中心点 + * @param halfExtents AABB 半边长 + * @param axisVec 运动方向(无需单位化) + * @param d 最大位移量(沿 {@code axisVec} 方向,可正可负) + * @param epsilon 碰撞容差 + * @return 首次碰撞位移(有符号)。0 = 初始已重叠,d = 全程无碰撞 + */ + private static double collisionDistanceOnAxis( + Vector3fc V0, + Vector3fc V1, + Vector3fc V2, + Vector3dc center, + Vector3dc halfExtents, + Vector3dc axisVec, + double d, + double epsilon + ) { + + // 将三角形平移到 AABB 中心为原点的局部坐标 + Vector3d v0 = new Vector3d(V0).sub(center); + Vector3d v1 = new Vector3d(V1).sub(center); + Vector3d v2 = new Vector3d(V2).sub(center); + + // 初始就已经碰撞 → 安全距离为 0 + if (initialOverlap(v0, v1, v2, halfExtents, epsilon)) { + return 0.0; + } + + // 三角形边向量 + Vector3d f0 = v1.sub(v0, new Vector3d()); + Vector3d f1 = v2.sub(v1, new Vector3d()); + Vector3d f2 = v0.sub(v2, new Vector3d()); + + // 面法线 + Vector3d normal = f0.cross(f1, new Vector3d()); + boolean hasNormal = normal.lengthSquared() > 1e-15; + if (hasNormal) normal.normalize(); + + // AABB 三个面法线 + Vector3d[] aabbAxes = { + new Vector3d(1, 0, 0), + new Vector3d(0, 1, 0), + new Vector3d(0, 0, 1) + }; + Vector3d[] triEdges = { + f0, + f1, + f2 + }; + + // 交集区间 [tLow, tHigh] 表示所有分离轴都重叠的 t 范围 + double[] interval = { + Double.NEGATIVE_INFINITY, + Double.POSITIVE_INFINITY + }; + + // 测试 3 个坐标轴 + for (Vector3d axis : aabbAxes) { + if (!updateInterval(axis, v0, v1, v2, halfExtents, axisVec, epsilon, interval)) { + return d; // 无碰撞 + } + } + + // 测试三角形面法线 + if (hasNormal) { + if (!updateInterval(normal, v0, v1, v2, halfExtents, axisVec, epsilon, interval)) { + return d; + } + } + + // 测试 9 条边叉积轴 + for (Vector3d edge : triEdges) { + for (Vector3d aabbAxis : aabbAxes) { + Vector3d axis = edge.cross(aabbAxis, new Vector3d()); + if (axis.lengthSquared() > 1e-15) { + axis.normalize(); + if (!updateInterval(axis, v0, v1, v2, halfExtents, axisVec, epsilon, interval)) { + return d; + } + } + } + } + + double tLow = interval[0]; + double tHigh = interval[1]; + + // 若交集为空或退化,则无碰撞 + if (tLow > tHigh + 1e-12) { + return d; + } + + // 根据运动方向取第一个(最接近 0 的)碰撞位移 + if (d > 0) { + if (tHigh < 0) return d; // 区间全在负半轴 + if (tLow > 0) return tLow; + return d; + } else { + if (tLow > 0) return d; // 区间全在正半轴 + if (tHigh < 0) return tHigh; + return d; + } + } + + /** + * 在给定 SAT 分离轴上计算 AABB 与三角形重叠的 t 区间,并与当前区间求交。 + *

+ * AABB 中心沿 {@code axisVec} 运动,在参数 t 时刻的位置为 {@code center + t * axisVec}。 + * 三角形在轴上的投影为 [tMin, tMax],AABB 的投影半径在运动中不变(仅平移,不旋转)。 + *

+ * 若该轴与运动方向垂直({@code dot ≈ 0}),则检查静态分离;否则求解线性不等式 + * 得到重叠的 t 范围 [low, high],与当前区间取交集。 + * + * @param axis SAT 分离轴(无需单位化) + * @param v0,v1,v2 局部坐标系下的三角形顶点 + * @param halfExtents AABB 半边长 + * @param axisVec 运动方向 + * @param epsilon 碰撞容差 + * @param interval [0]=tLow, [1]=tHigh,会被就地更新 + * @return false 表示该轴永久分离,运动全程不可能碰撞 + */ + private static boolean updateInterval( + Vector3d axis, + Vector3d v0, + Vector3d v1, + Vector3d v2, + Vector3dc halfExtents, + Vector3dc axisVec, + double epsilon, + double[] interval + ) { + + // 三角形投影 + double p0 = axis.dot(v0); + double p1 = axis.dot(v1); + double p2 = axis.dot(v2); + double tMin = Math.min(Math.min(p0, p1), p2); + double tMax = Math.max(Math.max(p0, p1), p2); + + // AABB 投影半径 + double r = Math.abs(axis.x()) * halfExtents.x() + Math.abs(axis.y()) * halfExtents.y() + Math.abs(axis.z()) * halfExtents.z(); + + double dot = axis.dot(axisVec); // 移动方向在分离轴上的投影 + + if (Math.abs(dot) < 1e-15) { + // 轴与运动方向垂直 → 投影不随 t 改变 + // 若此时该轴是分离轴,则运动全程分离 → 无碰撞可能 + if (-r > tMax + epsilon || tMin - epsilon > r) { + return false; // 永远分离 + } + // 否则该轴始终重叠,不提供约束 + return true; + } + + double low, high; + if (dot > 0) { + low = (tMin - epsilon - r) / dot; + high = (tMax + epsilon + r) / dot; + } else { + low = (tMax + epsilon + r) / dot; // dot 为负,low 是较小值 + high = (tMin - epsilon - r) / dot; + } + + interval[0] = Math.max(interval[0], low); + interval[1] = Math.min(interval[1], high); + + return interval[0] <= interval[1] + 1e-12; + } + + /** + * 静态 SAT 检查:AABB 与三角形在初始位置(t=0,局部坐标)是否重叠。 + *

+ * 测试全部 13 条分离轴(3 坐标轴 + 1 面法线 + 9 边叉积轴), + * 若任意轴分离则返回 false。 + * + * @param v0,v1,v2 局部坐标系下的三角形顶点(已减去 AABB 中心) + * @param halfExtents AABB 半边长 + * @param epsilon 碰撞容差 + * @return true 表示重叠(或 gap ≤ epsilon) + */ + private static boolean initialOverlap(Vector3d v0, Vector3d v1, Vector3d v2, Vector3dc halfExtents, double epsilon) { + + // 三个坐标轴 + if (!overlapOnAxis(new Vector3d(1, 0, 0), v0, v1, v2, halfExtents, epsilon)) return false; + if (!overlapOnAxis(new Vector3d(0, 1, 0), v0, v1, v2, halfExtents, epsilon)) return false; + if (!overlapOnAxis(new Vector3d(0, 0, 1), v0, v1, v2, halfExtents, epsilon)) return false; + + Vector3d f0 = v1.sub(v0, new Vector3d()); + Vector3d f1 = v2.sub(v1, new Vector3d()); + Vector3d normal = f0.cross(f1, new Vector3d()); + if (normal.lengthSquared() > 1e-15) { + normal.normalize(); + if (!overlapOnAxis(normal, v0, v1, v2, halfExtents, epsilon)) return false; + } + + Vector3d f2 = v0.sub(v2, new Vector3d()); + Vector3d[] edges = { + f0, + f1, + f2 + }; + Vector3d[] axes = { + new Vector3d(1, 0, 0), + new Vector3d(0, 1, 0), + new Vector3d(0, 0, 1) + }; + for (Vector3d edge : edges) { + for (Vector3d a : axes) { + Vector3d axis = edge.cross(a, new Vector3d()); + if (axis.lengthSquared() > 1e-15) { + axis.normalize(); + if (!overlapOnAxis(axis, v0, v1, v2, halfExtents, epsilon)) return false; + } + } + } + return true; // 所有轴都重叠 → 碰撞 + } + + /** + * 静态碰撞检测:判断 AABB 与任意三角形是否重叠。 + *

+ * 对每个三角形先做包围盒粗筛(AABB-AABB 快速剔除), + * 再以完整的 SAT(13 条分离轴)进行精确判定。 + * + * @param min AABB 最小角 + * @param max AABB 最大角 + * @param triangles 三角形数组,每 3 个顶点构成一个三角形 + * @param epsilon 碰撞容差——间距小于此值即视为重叠 + * @return true 表示存在至少一个三角形与 AABB 碰撞 + */ + public static boolean intersectsAABBTriangle(Vector3dc min, Vector3dc max, Vector3fc[] triangles, double epsilon) { + Vector3dc center = min.add(max, new Vector3d()).mul(0.5); + Vector3dc halfExtents = max.sub(min, new Vector3d()).mul(0.5); + + int triangleCount = triangles.length / 3; + for (int i = 0; i < triangleCount; i++) { + Vector3fc V0 = triangles[i * 3]; + Vector3fc V1 = triangles[i * 3 + 1]; + Vector3fc V2 = triangles[i * 3 + 2]; + + if (triangleIntersectsAABB(V0, V1, V2, center, halfExtents, epsilon)) { + return true; + } + } + return false; + } + + /** + * 对单个三角形执行完整 SAT 碰撞检测(13 条轴)。 + *

+ * 先做三角形包围盒粗筛(AABB-AABB),再将顶点平移到以 AABB 中心为原点的 + * 局部坐标系,依次测试 3 坐标轴 → 面法线 → 9 条边叉积轴。 + * + * @param V0,V1,V2 三角形顶点(世界坐标) + * @param center AABB 中心点 + * @param halfExtents AABB 半边长 + * @param epsilon 碰撞容差 + * @return true 表示三角形与 AABB 重叠(或 gap ≤ epsilon) + */ + private static boolean triangleIntersectsAABB( + Vector3fc V0, + Vector3fc V1, + Vector3fc V2, + Vector3dc center, + Vector3dc halfExtents, + double epsilon + ) { + + // 1. 三角形包围盒粗筛(快速剔除) + double triMinX = Math.min(Math.min(V0.x(), V1.x()), V2.x()); + double triMinY = Math.min(Math.min(V0.y(), V1.y()), V2.y()); + double triMinZ = Math.min(Math.min(V0.z(), V1.z()), V2.z()); + double triMaxX = Math.max(Math.max(V0.x(), V1.x()), V2.x()); + double triMaxY = Math.max(Math.max(V0.y(), V1.y()), V2.y()); + double triMaxZ = Math.max(Math.max(V0.z(), V1.z()), V2.z()); + + double boxMinX = center.x() - halfExtents.x(); + double boxMinY = center.y() - halfExtents.y(); + double boxMinZ = center.z() - halfExtents.z(); + double boxMaxX = center.x() + halfExtents.x(); + double boxMaxY = center.y() + halfExtents.y(); + double boxMaxZ = center.z() + halfExtents.z(); + + if (triMinX > boxMaxX + epsilon || triMaxX < boxMinX - epsilon || triMinY > boxMaxY + epsilon || triMaxY < boxMinY - epsilon || triMinZ > boxMaxZ + epsilon || triMaxZ < boxMinZ - epsilon) { + return false; + } + + // 2. 将三角形顶点平移到 AABB 中心为原点的局部坐标(使用 double 保证精度) + Vector3d v0 = new Vector3d(V0).sub(center); + Vector3d v1 = new Vector3d(V1).sub(center); + Vector3d v2 = new Vector3d(V2).sub(center); + + // 3. 三角形边向量 + Vector3d f0 = v1.sub(v0, new Vector3d()); + Vector3d f1 = v2.sub(v1, new Vector3d()); + Vector3d f2 = v0.sub(v2, new Vector3d()); + + Vector3d aabbAxisX = new Vector3d(1, 0, 0); + Vector3d aabbAxisY = new Vector3d(0, 1, 0); + Vector3d aabbAxisZ = new Vector3d(0, 0, 1); + + // ---- 测试三个坐标轴 ---- + if (!overlapOnAxis(aabbAxisX, v0, v1, v2, halfExtents, epsilon)) return false; + if (!overlapOnAxis(aabbAxisY, v0, v1, v2, halfExtents, epsilon)) return false; + if (!overlapOnAxis(aabbAxisZ, v0, v1, v2, halfExtents, epsilon)) return false; + + // ---- 测试三角形面法线轴 ---- + Vector3d normal = f0.cross(f1, new Vector3d()); + if (normal.lengthSquared() > 1e-15) { + normal.normalize(); + if (!overlapOnAxis(normal, v0, v1, v2, halfExtents, epsilon)) return false; + } + // 如果面积极小(退化为线段或点),跳过此轴(后续边叉积轴可能仍有效) + + // ---- 测试 9 条边叉积轴 ---- + Vector3d[] aabbAxes = { + aabbAxisX, + aabbAxisY, + aabbAxisZ + }; + Vector3d[] triEdges = { + f0, + f1, + f2 + }; + + for (Vector3d edge : triEdges) { + for (Vector3d aabbAxis : aabbAxes) { + Vector3d axis = edge.cross(aabbAxis, new Vector3d()); + if (axis.lengthSquared() > 1e-15) { + axis.normalize(); + if (!overlapOnAxis(axis, v0, v1, v2, halfExtents, epsilon)) { + return false; + } + } + // 平行或退化轴忽略,不会提供有效分离 + } + } + + // 所有轴都重叠 ⇒ 碰撞 + return true; + } + + /** + * 测试三角形与中心在原点的 AABB 在给定轴上的投影是否重叠。 + *

+ * 三角形投影区间 [tMin, tMax],AABB 投影区间 [−r, r],其中 + * r = Σ|axis[i] · halfExtents[i]|。若两区间带容差后仍分离则返回 false。 + *

+ * 分离条件:tMin > r + epsilon 或 −r > tMax + epsilon。 + * + * @param axis 投影轴(无需单位化) + * @param v0,v1,v2 局部坐标系下的三角形顶点 + * @param boxHalfExtents AABB 半边长 + * @param epsilon 碰撞容差——扩大 AABB 投影半径使"接近"也视为重叠 + * @return true 表示两投影区间有交集 + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public static boolean overlapOnAxis( + Vector3dc axis, + Vector3dc v0, + Vector3dc v1, + Vector3dc v2, + Vector3dc boxHalfExtents, + double epsilon + ) { + double p0 = axis.x() * v0.x() + axis.y() * v0.y() + axis.z() * v0.z(); + double p1 = axis.x() * v1.x() + axis.y() * v1.y() + axis.z() * v1.z(); + double p2 = axis.x() * v2.x() + axis.y() * v2.y() + axis.z() * v2.z(); + + double tMin = Math.min(Math.min(p0, p1), p2); + double tMax = Math.max(Math.max(p0, p1), p2); + + double r = Math.abs(axis.x() * boxHalfExtents.x()) + + Math.abs(axis.y() * boxHalfExtents.y()) + + Math.abs(axis.z() * boxHalfExtents.z()); + + return !(tMin > r + epsilon) && !(-r > tMax + epsilon); + } +} diff --git a/module.collision/src/main/java/dev/anvilcraft/lib/v2/collision/CollisionTest.java b/module.collision/src/main/java/dev/anvilcraft/lib/v2/collision/CollisionTest.java new file mode 100644 index 00000000..40d1ec36 --- /dev/null +++ b/module.collision/src/main/java/dev/anvilcraft/lib/v2/collision/CollisionTest.java @@ -0,0 +1,575 @@ +package dev.anvilcraft.lib.v2.collision; + +import org.joml.Vector3d; +import org.joml.Vector3dc; +import org.joml.Vector3f; +import org.joml.Vector3fc; +import org.jetbrains.annotations.ApiStatus; + +import static dev.anvilcraft.lib.v2.collision.AnvilLibCollision.intersectsAABBTriangle; +import static dev.anvilcraft.lib.v2.collision.AnvilLibCollision.overlapOnAxis; +import static dev.anvilcraft.lib.v2.collision.AnvilLibCollision.sweptCollisionAABBTriangle; + +@ApiStatus.Internal +public class CollisionTest { + + static void main() { + int[] counts = { + 0, + 0 + }; // [passed, failed] + + // ============================================================ + // PART A -- intersectsAABBTriangle(min, max, triangles, epsilon) + // AABB: (0,0,0) -> (2,2,2), 2x2x2 cube + // ============================================================ + Vector3dc boxMin = new Vector3d(0, 0, 0); + Vector3dc boxMax = new Vector3d(2, 2, 2); + + // A1: triangle fully inside -> true + { + Vector3fc[] tris = { + vf(0.5f, 0.5f, 0.5f), + vf(1.5f, 0.5f, 0.5f), + vf(0.5f, 1.5f, 0.5f) + }; + check(counts, "A1: fully inside", true, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A2: outside +X -> false + { + Vector3fc[] tris = { + vf(3, 0, 0), + vf(4, 1, 0), + vf(3, 1, 1) + }; + check(counts, "A2: outside +X", false, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A3: outside -X -> false + { + Vector3fc[] tris = { + vf(-3, 0, 0), + vf(-1, 1, 0), + vf(-2, 1, 1) + }; + check(counts, "A3: outside -X", false, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A4: outside +Y -> false + { + Vector3fc[] tris = { + vf(0, 3, 0), + vf(2, 4, 0), + vf(1, 3, 2) + }; + check(counts, "A4: outside +Y", false, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A5: outside -Z -> false + { + Vector3fc[] tris = { + vf(0, 0, -2), + vf(2, 1, -1), + vf(1, 2, -3) + }; + check(counts, "A5: outside -Z", false, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A6: piercing through -> true + { + Vector3fc[] tris = { + vf(-1, 1, 1), + vf(3, 1, 1), + vf(1, -1, 3) + }; + check(counts, "A6: piercing through", true, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A7: touching right face (x=2), eps=0 -> true + { + Vector3fc[] tris = { + vf(2, 0, 0), + vf(2, 2, 0), + vf(2, 1, 2) + }; + check(counts, "A7: touching face, eps=0", true, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A8: gap=0.0001, eps=0 -> false; eps=0.001 -> true + { + Vector3fc[] tris = { + vf(2.0001f, 0, 0), + vf(2.0001f, 2, 0), + vf(2.0001f, 1, 2) + }; + check(counts, "A8a: gap 0.0001 eps=0", false, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + check(counts, "A8b: gap 0.0001 eps=0.001", true, intersectsAABBTriangle(boxMin, boxMax, tris, 0.001)); + } + + // A9: degenerate point inside -> true + { + Vector3fc[] tris = { + vf(1, 1, 1), + vf(1, 1, 1), + vf(1, 1, 1) + }; + check(counts, "A9: point inside", true, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A10: degenerate point outside -> false + { + Vector3fc[] tris = { + vf(5, 5, 5), + vf(5, 5, 5), + vf(5, 5, 5) + }; + check(counts, "A10: point outside", false, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A11: degenerate line intersecting -> true + { + Vector3fc[] tris = { + vf(-1, 1, 1), + vf(3, 1, 1), + vf(1, 1, 1) + }; + check(counts, "A11: line intersecting", true, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A12: degenerate line outside -> false + { + Vector3fc[] tris = { + vf(5, 1, 1), + vf(7, 1, 1), + vf(6, 1, 1) + }; + check(counts, "A12: line outside", false, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A13: large triangle containing AABB (coplanar z=1) -> true + { + Vector3fc[] tris = { + vf(-5, -5, 1), + vf(7, -5, 1), + vf(1, 7, 1) + }; + check(counts, "A13: triangle contains AABB", true, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A14: batch -- only middle triangle intersects -> true + { + Vector3fc[] tris = { + vf(5, 0, 0), + vf(6, 1, 0), + vf(5, 1, 1), + vf(0.5f, 0.5f, 0.5f), + vf(1.5f, 0.5f, 0.5f), + vf(0.5f, 1.5f, 1.5f), + vf(-3, 0, 0), + vf(-2, 1, 0), + vf(-3, 1, 1), + }; + check(counts, "A14: batch mid intersects", true, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A15: batch -- none intersect -> false + { + Vector3fc[] tris = { + vf(5, 0, 0), + vf(6, 1, 0), + vf(5, 1, 1), + vf(-3, 0, 0), + vf(-2, 1, 0), + vf(-3, 1, 1), + }; + check(counts, "A15: batch none intersect", false, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A16: tilted triangle, cross-axis intersection -> true + { + Vector3fc[] tris = { + vf(-0.5f, 2.5f, -0.5f), + vf(2.5f, 2.5f, -0.5f), + vf(-0.5f, -1f, 2.5f) + }; + check(counts, "A16: cross-axis intersection", true, intersectsAABBTriangle(boxMin, boxMax, tris, 0.0)); + } + + // A17: negative-extent AABB -> false + { + Vector3dc badMin = new Vector3d(3, 3, 3); + Vector3dc badMax = new Vector3d(1, 1, 1); + Vector3fc[] tris = { + vf(2, 2, 2), + vf(2, 2, 2), + vf(2, 2, 2) + }; + check(counts, "A17: negative-extent AABB", false, intersectsAABBTriangle(badMin, badMax, tris, 0.0)); + } + + // ============================================================ + // PART B -- overlapOnAxis direct unit tests + // ============================================================ + { + Vector3d axisX = new Vector3d(1, 0, 0); + Vector3d halfExt = new Vector3d(1, 1, 1); + + // B1: tri=[0.5,1.5] overlaps b=[-1,1] -> true + check( + counts, "B1: overlapOnAxis overlap", true, + overlapOnAxis(axisX, new Vector3d(0.5, 0, 0), new Vector3d(1.5, 0, 0), new Vector3d(1.0, 1, 0), halfExt, 0.0) + ); + + // B2: tri=[3,5] vs b=[-1,1], separated -> false + check( + counts, "B2: overlapOnAxis separated", false, + overlapOnAxis(axisX, new Vector3d(3, 0, 0), new Vector3d(5, 0, 0), new Vector3d(4, 1, 0), halfExt, 0.0) + ); + + // B3: tri=[1.001,2] vs b=[-1,1], gap=0.001 eps=0 -> false + check( + counts, "B3: overlapOnAxis gap eps=0", false, + overlapOnAxis(axisX, new Vector3d(1.001, 0, 0), new Vector3d(2.0, 0, 0), new Vector3d(1.5, 1, 0), halfExt, 0.0) + ); + + // B4: same gap with eps=0.01 -> true + check( + counts, "B4: overlapOnAxis gap eps=0.01", true, + overlapOnAxis(axisX, new Vector3d(1.001, 0, 0), new Vector3d(2.0, 0, 0), new Vector3d(1.5, 1, 0), halfExt, 0.01) + ); + } + + // ============================================================ + // PART C -- swept: intersectsAABBTriangle(min,max,motion,tris,eps) + // AABB: (0,0,0)->(2,2,2), center=(1,1,1), half=(1,1,1) + // ============================================================ + + // C1: no obstacle -> full motion + { + Vector3dc motion = new Vector3d(5, 0, 0); + Vector3fc[] tris = { + vf(10, 0, 0), + vf(12, 2, 0), + vf(10, 2, 2) + }; + Vector3dc result = intersectsAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "C1: no obstacle +X", new Vector3d(5, 0, 0), result); + } + + // C2: wall at x=5 blocks +X motion, AABB.maxX reaches 5 at t=3 + { + Vector3dc motion = new Vector3d(5, 0, 0); + Vector3fc[] tris = { + vf(5, 0, 0), + vf(5, 2, 0), + vf(5, 1, 2) + }; + Vector3dc result = intersectsAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "C2: blocked +X at x=5", new Vector3d(3, 0, 0), result); + } + + // C3: wall at x=-2 blocks -X motion, AABB.minX reaches -2 at t=-2 + { + Vector3dc motion = new Vector3d(-5, 0, 0); + Vector3fc[] tris = { + vf(-2, 0, 0), + vf(-2, 2, 0), + vf(-2, 1, 2) + }; + Vector3dc result = intersectsAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "C3: blocked -X at x=-2", new Vector3d(-2, 0, 0), result); + } + + // C4: initial overlap -> 0 for that axis + { + Vector3dc motion = new Vector3d(5, 0, 0); + Vector3fc[] tris = { + vf(0.5f, 0.5f, 0.5f), + vf(1.5f, 0.5f, 0.5f), + vf(0.5f, 1.5f, 0.5f) + }; + Vector3dc result = intersectsAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "C4: initial overlap -> 0", new Vector3d(0, 0, 0), result); + } + + // C5: wall at y=5 blocks +Y, AABB.maxY=2, reaches at t=3 + { + Vector3dc motion = new Vector3d(0, 5, 0); + Vector3fc[] tris = { + vf(0, 5, 0), + vf(2, 5, 0), + vf(1, 5, 2) + }; + Vector3dc result = intersectsAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "C5: blocked +Y at y=5", new Vector3d(0, 3, 0), result); + } + + // C6: wall at z=5 blocks +Z + { + Vector3dc motion = new Vector3d(0, 0, 5); + Vector3fc[] tris = { + vf(0, 0, 5), + vf(2, 0, 5), + vf(1, 2, 5) + }; + Vector3dc result = intersectsAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "C6: blocked +Z at z=5", new Vector3d(0, 0, 3), result); + } + + // C7: diag motion, X blocked, Y free + { + Vector3dc motion = new Vector3d(5, 5, 0); + Vector3fc[] tris = { + vf(5, 0, 0), + vf(5, 2, 0), + vf(5, 1, 10) + }; + Vector3dc result = intersectsAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "C7: +X blocked, +Y free", new Vector3d(3, 5, 0), result); + } + + // C8: epsilon bridges small gap -> full motion returned + { + Vector3dc motion = new Vector3d(5, 0, 0); + Vector3fc[] tris = { + vf(2.5f, 0, 0), + vf(2.5f, 2, 0), + vf(2.5f, 1, 2) + }; + Vector3dc r0 = intersectsAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + Vector3dc r1 = intersectsAABBTriangle(boxMin, boxMax, motion, tris, 1.0); + check3(counts, "C8a: gap=0.5 eps=0 blocked at 0.5", new Vector3d(0.5, 0, 0), r0); + check3(counts, "C8b: gap=0.5 eps=1 initial overlap", new Vector3d(0, 0, 0), r1); + } + + // C9: moving away from obstacle -> full motion + { + Vector3dc motion = new Vector3d(-5, 0, 0); + Vector3fc[] tris = { + vf(5, 0, 0), + vf(5, 2, 0), + vf(5, 1, 2) + }; + Vector3dc result = intersectsAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "C9: moving away full motion", new Vector3d(-5, 0, 0), result); + } + + // C10: X wall at x=5 + Y wall at y=5 — each blocks its axis + { + Vector3dc motion = new Vector3d(5, 5, 0); + Vector3fc[] tris = { + vf(5, 0, 0), vf(5, 2, 0), vf(5, 1, 10), // wall at x=5, blocks X + vf(0, 5, 0), vf(2, 5, 0), vf(1, 5, 10), // wall at y=5, blocks Y + }; + Vector3dc result = intersectsAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "C10: diag blocked X and Y", new Vector3d(3, 3, 0), result); + } + + // ============================================================ + // PART D -- true swept: sweptCollisionAABBTriangle(min,max,motion,tris,eps) + // AABB: (0,0,0)->(2,2,2), center=(1,1,1) + // Key difference from per-axis: sweeps along the motion direction + // vector, not independently per axis + // ============================================================ + + // D1: axis-aligned motion, wall at x=5 → same result as per-axis C2 + { + Vector3dc motion = new Vector3d(5, 0, 0); + Vector3fc[] tris = { vf(5, 0, 0), vf(5, 2, 0), vf(5, 1, 2) }; + Vector3dc result = sweptCollisionAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "D1: axis-aligned blocked +X", new Vector3d(3, 0, 0), result); + } + + // D2: diagonal slides past narrow wall → not blocked (per-axis would give (3,5,0)) + { + Vector3dc motion = new Vector3d(5, 5, 0); + Vector3fc[] tris = { vf(5, 0, 0), vf(5, 2, 0), vf(5, 1, 2) }; + Vector3dc result = sweptCollisionAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "D2: diag slides past narrow wall", new Vector3d(5, 5, 0), result); + } + + // D3: initial overlap → zero + { + Vector3dc motion = new Vector3d(5, 0, 0); + Vector3fc[] tris = { vf(0.5f, 0.5f, 0.5f), vf(1.5f, 0.5f, 0.5f), vf(0.5f, 1.5f, 0.5f) }; + Vector3dc result = sweptCollisionAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "D3: initial overlap", new Vector3d(0, 0, 0), result); + } + + // D4: no obstacle → full diagonal motion + { + Vector3dc motion = new Vector3d(5, 5, 0); + Vector3fc[] tris = { vf(20, 0, 0), vf(22, 2, 0), vf(20, 2, 2) }; + Vector3dc result = sweptCollisionAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "D4: no obstacle diag", new Vector3d(5, 5, 0), result); + } + + // D5: diagonal blocked by large wall → blocked at t=0.6 → motion * 0.6 + { + Vector3dc motion = new Vector3d(5, 5, 0); + Vector3fc[] tris = { vf(5, 0, 0), vf(5, 10, 0), vf(5, 5, 10) }; + Vector3dc result = sweptCollisionAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "D5: diag blocked by large wall", new Vector3d(3, 3, 0), result); + } + + // D6: zero-length motion → zero vector + { + Vector3dc motion = new Vector3d(0, 0, 0); + Vector3fc[] tris = { vf(5, 0, 0), vf(5, 2, 0), vf(5, 1, 2) }; + Vector3dc result = sweptCollisionAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + check3(counts, "D6: zero motion", new Vector3d(0, 0, 0), result); + } + + // D7: epsilon bridges gap → initial overlap → zero + { + Vector3dc motion = new Vector3d(5, 0, 0); + Vector3fc[] tris = { vf(2.5f, 0, 0), vf(2.5f, 2, 0), vf(2.5f, 1, 2) }; + Vector3dc r = sweptCollisionAABBTriangle(boxMin, boxMax, motion, tris, 1.0); + check3(counts, "D7: gap=0.5 eps=1 initial overlap", new Vector3d(0, 0, 0), r); + } + + // ============================================================ + // Report + // ============================================================ + System.out.println("========================================"); + System.out.println("Passed: " + counts[0] + " / " + (counts[0] + counts[1])); + if (counts[1] > 0) { + System.out.println("FAILED: " + counts[1]); + } else { + System.out.println("ALL TESTS PASSED"); + } + System.out.println("========================================"); + + bench(); + } + + // ================================================================ + // Performance benchmark + // ================================================================ + private static void bench() { + Vector3dc boxMin = new Vector3d(0, 0, 0); + Vector3dc boxMax = new Vector3d(2, 2, 2); + Vector3dc motion = new Vector3d(0.5, 0.3, 0.2); + int[] triCounts = {1, 10, 100, 1000, 10000}; + int warmupIters = 2000; + + System.out.println(); + System.out.println("==================== BENCHMARK ===================="); + System.out.printf("%-8s | %-16s | %-16s | %-16s%n", + "tris", "static SAT", "per-axis swept", "true swept"); + System.out.println("---------+------------------+------------------+------------------"); + + for (int n : triCounts) { + Vector3fc[] tris = generateTriangles(n, 42 + n); + + // Determine iteration count per scenario + int iters = Math.max(200, 100000 / Math.max(1, n)); + + // Warmup + for (int i = 0; i < warmupIters; i++) { + intersectsAABBTriangle(boxMin, boxMax, tris, 0.0); + intersectsAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + sweptCollisionAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + } + + // Static SAT + long t0 = System.nanoTime(); + for (int i = 0; i < iters; i++) { + intersectsAABBTriangle(boxMin, boxMax, tris, 0.0); + } + long t1 = System.nanoTime(); + double staticNs = (double) (t1 - t0) / iters; + + // Per-axis swept + long t2 = System.nanoTime(); + for (int i = 0; i < iters; i++) { + intersectsAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + } + long t3 = System.nanoTime(); + double perAxisNs = (double) (t3 - t2) / iters; + + // True swept + long t4 = System.nanoTime(); + for (int i = 0; i < iters; i++) { + sweptCollisionAABBTriangle(boxMin, boxMax, motion, tris, 0.0); + } + long t5 = System.nanoTime(); + double trueSweptNs = (double) (t5 - t4) / iters; + + System.out.printf("%-8d | %8.1f ns/op | %8.1f ns/op | %8.1f ns/op%n", + n, staticNs, perAxisNs, trueSweptNs); + } + System.out.println("===================================================="); + System.out.println("AABB: (0,0,0)->(2,2,2) motion: (0.5,0.3,0.2) eps: 0"); + System.out.println("Triangles: random mix in [-10,12] cube, ~50% intersect"); + System.out.println(); + } + + /** + * Generates {@code count} triangles in a pseudo-random but deterministic + * pattern. Roughly half will intersect the AABB (0,0,0)→(2,2,2) so both + * the early-out and full-SAT paths are exercised. + */ + private static Vector3fc[] generateTriangles(int count, long seed) { + Vector3fc[] tris = new Vector3fc[count * 3]; + long state = seed; + for (int i = 0; i < count; i++) { + // LCG: state = state * 6364136223846793005L + 1442695040888963407L + state = state * 0x5851F42D4C957F2DL + 0x14057B7EF767814FL; + + // Pick a center in [-10, 12] for each axis + double cx = -10.0 + ((state >>> 16) & 0x7FFFFF) / 524287.0 * 22.0; + state = state * 0x5851F42D4C957F2DL + 0x14057B7EF767814FL; + double cy = -10.0 + ((state >>> 16) & 0x7FFFFF) / 524287.0 * 22.0; + state = state * 0x5851F42D4C957F2DL + 0x14057B7EF767814FL; + double cz = -10.0 + ((state >>> 16) & 0x7FFFFF) / 524287.0 * 22.0; + + // Random offsets for 3 vertices within a 3-unit radius + double[][] offsets = new double[3][3]; + for (int v = 0; v < 3; v++) { + for (int a = 0; a < 3; a++) { + state = state * 0x5851F42D4C957F2DL + 0x14057B7EF767814FL; + offsets[v][a] = -1.5 + ((state >>> 16) & 0x7FFFFF) / 524287.0 * 3.0; + } + } + + tris[i * 3] = new Vector3f((float)(cx + offsets[0][0]), (float)(cy + offsets[0][1]), (float)(cz + offsets[0][2])); + tris[i * 3 + 1] = new Vector3f((float)(cx + offsets[1][0]), (float)(cy + offsets[1][1]), (float)(cz + offsets[1][2])); + tris[i * 3 + 2] = new Vector3f((float)(cx + offsets[2][0]), (float)(cy + offsets[2][1]), (float)(cz + offsets[2][2])); + } + return tris; + } + + private static void check(int[] c, String name, boolean expected, boolean actual) { + if (expected == actual) { + System.out.println("[PASS] " + name); + c[0]++; + } else { + System.out.println("[FAIL] " + name + " -- expected " + expected + ", got " + actual); + c[1]++; + } + } + + private static void check3(int[] c, String name, Vector3dc expected, Vector3dc actual) { + double tol = 1e-9; + boolean ok = Math.abs(expected.x() - actual.x()) < tol + && Math.abs(expected.y() - actual.y()) < tol + && Math.abs(expected.z() - actual.z()) < tol; + if (ok) { + System.out.println("[PASS] " + name); + c[0]++; + } else { + System.out.printf( + "[FAIL] %s -- expected (%f,%f,%f), got (%f,%f,%f)%n", + name, expected.x(), expected.y(), expected.z(), + actual.x(), actual.y(), actual.z() + ); + c[1]++; + } + } + + private static Vector3f vf(float x, float y, float z) { + return new Vector3f(x, y, z); + } +} diff --git a/module.collision/src/main/java/dev/anvilcraft/lib/v2/collision/package-info.java b/module.collision/src/main/java/dev/anvilcraft/lib/v2/collision/package-info.java new file mode 100644 index 00000000..0409ca51 --- /dev/null +++ b/module.collision/src/main/java/dev/anvilcraft/lib/v2/collision/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.collision; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.collision/src/main/resources/META-INF/neoforge.mods.toml b/module.collision/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 00000000..27f7ba75 --- /dev/null +++ b/module.collision/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,86 @@ +# This is an example mods.toml file. It contains the data relating to the loading mods. +# There are several mandatory fields (#mandatory), and many more that are optional (#optional). +# The overall format is standard TOML format, v0.5.0. +# Note that there are a couple of TOML lists in this file. +# Find more information on toml format here: https://github.com/toml-lang/toml +# The name of the mod loader type to load - for regular FML @Mod mods it should be javafml +modLoader = "javafml" #mandatory +# A version range to match for said mod loader - for regular FML @Mod it will be the the FML version. This is currently 47. +loaderVersion = "${loader_version_range}" #mandatory +# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties. +# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here. +license = "${mod_license}" +# A URL to refer people to when problems occur with this mod +#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional +# A list of mods - how many allowed here is determined by the individual mod loader +[[mods]] #mandatory +# The modid of the mod +modId = "${mod_id}" #mandatory +# The version number of the mod +version = "${mod_version}" #mandatory +# A display name for the mod +displayName = "${mod_name}" #mandatory +# A URL to query for updates for this mod. See the JSON update specification https://docs.neoforge.net/docs/misc/updatechecker/ +#updateJSONURL="https://change.me.example.invalid/updates.json" #optional +# A URL for the "homepage" for this mod, displayed in the mod UI +displayURL="https://github.com/Anvil-Dev/AnvilLib" +# A file name (in the root of the mod JAR) containing a logo for display +logoFile="icon.png" #optional +# A text field displayed in the mod UI +#credits="" #optional +# A text field displayed in the mod UI +authors = "${mod_authors}" #optional +# Display Test controls the display for your mod in the server connection screen +# MATCH_VERSION means that your mod will cause a red X if the versions on client and server differ. This is the default behaviour and should be what you choose if you have server and client elements to your mod. +# IGNORE_SERVER_VERSION means that your mod will not cause a red X if it's present on the server but not on the client. This is what you should use if you're a server only mod. +# IGNORE_ALL_VERSION means that your mod will not cause a red X if it's present on the client or the server. This is a special case and should only be used if your mod has no server component. +# NONE means that no display test is set on your mod. You need to do this yourself, see IExtensionPoint.DisplayTest for more information. You can define any scheme you wish with this value. +# IMPORTANT NOTE: this is NOT an instruction as to which environments (CLIENT or DEDICATED SERVER) your mod loads on. Your mod should load (and maybe do nothing!) whereever it finds itself. +#displayTest="MATCH_VERSION" # MATCH_VERSION is the default if nothing is specified (#optional) + +# The description text for the mod (multi line!) (#mandatory) +description = '''${mod_description}''' + +# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded. +[[mixins]] +config = "${mod_id}.mixins.json" + +# The [[accessTransformers]] block allows you to declare where your AT file is. +# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg +#[[accessTransformers]] +#file="META-INF/accesstransformer.cfg" + +# The coremods config file path is not configurable and is always loaded from META-INF/coremods.json + +# A dependency - use the . to indicate dependency for a specific modid. Dependencies are optional. +[[dependencies."${mod_id}"]] #optional +# the modid of the dependency +modId = "neoforge" #mandatory +# The type of the dependency. Can be one of "required", "optional", "incompatible" or "discouraged" (case insensitive). +# 'required' requires the mod to exist, 'optional' does not +# 'incompatible' will prevent the game from loading when the mod exists, and 'discouraged' will show a warning +type = "required" #mandatory +# Optional field describing why the dependency is required or why it is incompatible +# reason="..." +# The version range of the dependency +versionRange = "${neo_version_range}" #mandatory +# An ordering relationship for the dependency. +# BEFORE - This mod is loaded BEFORE the dependency +# AFTER - This mod is loaded AFTER the dependency +ordering = "NONE" +# Side this dependency is applied on - BOTH, CLIENT, or SERVER +side = "BOTH" +# Here's another dependency +[[dependencies."${mod_id}"]] +modId = "minecraft" +type = "required" +# This version range declares a minimum of the current minecraft version up to but not including the next major version +versionRange = "${minecraft_version_range}" +ordering = "NONE" +side = "BOTH" + +# Features are specific properties of the game environment, that you may want to declare you require. This example declares +# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't +# stop your mod loading on the server for example. +#[features."${mod_id}"] +#openGLVersion="[3.2,)" diff --git a/module.collision/src/main/resources/anvillib_collision.mixins.json b/module.collision/src/main/resources/anvillib_collision.mixins.json new file mode 100644 index 00000000..a4d81a56 --- /dev/null +++ b/module.collision/src/main/resources/anvillib_collision.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "dev.anvilcraft.lib.v2.collision.mixin", + "compatibilityLevel": "JAVA_8", + "refmap": "anvillib.refmap.json", + "mixins": [ + ], + "client": [ + ], + "injectors": { + "defaultRequire": 1 + } +} \ No newline at end of file diff --git a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/Config.java b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/Config.java index c17c26b8..9c9846b7 100644 --- a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/Config.java +++ b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/Config.java @@ -12,6 +12,8 @@ public @interface Config { String name(); + String group() default ""; + ModConfig.Type type() default ModConfig.Type.COMMON; } diff --git a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/ConfigData.java b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/ConfigData.java index 76c604c3..f89dc0ad 100644 --- a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/ConfigData.java +++ b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/ConfigData.java @@ -2,6 +2,8 @@ import com.google.gson.annotations.SerializedName; import dev.anvilcraft.lib.v2.config.util.FormattingUtil; +import dev.anvilcraft.lib.v2.config.util.TranslatableEnum; +import lombok.extern.slf4j.Slf4j; import net.neoforged.fml.config.ModConfig; import net.neoforged.neoforge.common.data.LanguageProvider; import org.jetbrains.annotations.Nullable; @@ -10,8 +12,10 @@ import java.util.HashSet; import java.util.Set; +@Slf4j public class ConfigData { public static final String TITLE_STRING = "%s.configuration.title"; + public static final String ENUM_STRING = "anvillib.configuration.enum.%s.%s"; public static final String TOML_STRING = "%s.configuration.section.%s.%s.toml"; public static final String TOML_TITLE_STRING = "%s.configuration.section.%s.%s.toml.title"; public static final String OPTION_STRING = "%s.configuration.%s"; @@ -37,11 +41,11 @@ public static void readConfigClass(LanguageProvider provider, Class configCla ModConfig.Type type = config.type(); proxy.add(TITLE_STRING.formatted(name), "%s Configuration".formatted(FormattingUtil.toEnglishName(name))); proxy.add( - TOML_STRING.formatted(name, name, type.extension()), + TOML_STRING.formatted(name, FormattingUtil.toPointSplitName(name), type.extension()), "%s %s Configuration".formatted(FormattingUtil.toEnglishName(name), FormattingUtil.toEnglishName(type.extension())) ); proxy.add( - TOML_TITLE_STRING.formatted(name, name, type.extension()), + TOML_TITLE_STRING.formatted(name, FormattingUtil.toPointSplitName(name), type.extension()), "%s %s Configuration".formatted(FormattingUtil.toEnglishName(name), FormattingUtil.toEnglishName(type.extension())) ); ConfigData.readConfigClass(proxy, name, type, configClass, null); @@ -68,16 +72,44 @@ private static void providerAdd(ProviderProxy proxy, String modId, ModConfig.Typ } if (parent != null) fieldName = parent + "." + fieldName; boolean flag = field.isAnnotationPresent(CollapsibleObject.class); + Class fieldType = field.getType(); if (flag) { proxy.add(COLLAPSIBLE_OBJECT_BUTTON_STRING.formatted(modId, fieldName), name); proxy.add(COLLAPSIBLE_OBJECT_TOOLTIP_STRING.formatted(modId, fieldName), name); - ConfigData.readConfigClass(proxy, modId, type, field.getType(), fieldName); + ConfigData.readConfigClass(proxy, modId, type, fieldType, fieldName); } proxy.add(OPTION_STRING.formatted(modId, fieldName), name); if (field.isAnnotationPresent(Comment.class)) { Comment comment = field.getAnnotation(Comment.class); proxy.add(TOOLTIP_STRING.formatted(modId, fieldName), comment.value()); } + if (Enum.class.isAssignableFrom(fieldType) && TranslatableEnum.class.isAssignableFrom(fieldType)) { + ConfigData.enumValueAdd(proxy, cast(fieldType)); + } + } + } + + @SuppressWarnings("unchecked") + private static T cast(Object t) { + return (T) t; + } + + private static & TranslatableEnum> void enumValueAdd(ProviderProxy proxy, Class clazz) { + try { + for (Field field : clazz.getDeclaredFields()) { + if (!field.isEnumConstant()) continue; + T value = cast(field.get(null)); + String name; + if (field.isAnnotationPresent(SerializedName.class)) { + name = field.getAnnotation(SerializedName.class).value(); + } else { + name = FormattingUtil.toEnglishName(value.name()); + } + String key = value.getTranslationKey(); + proxy.add(key, name); + } + } catch (IllegalAccessException e) { + log.error("Failed to add enum values for {}", clazz.getName(), e); } } } diff --git a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/ConfigManager.java b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/ConfigManager.java index d55550c2..2f3c6838 100644 --- a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/ConfigManager.java +++ b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/ConfigManager.java @@ -66,6 +66,7 @@ public T register(T configObj) { if (configClass.isAnnotationPresent(Config.class)) { Config config = configClass.getAnnotation(Config.class); String name = config.name(); + String group = config.group(); ModConfig.Type type = config.type(); ImmutableList.Builder valuesBuilder = ImmutableList.builder(); try { @@ -74,7 +75,7 @@ public T register(T configObj) { log.error(e.getMessage(), e); } ModConfigSpec spec = builder.build(); - this.configSpecMap.put(configObj, new ConfigRecord(name, type, spec, configObj, valuesBuilder.build())); + this.configSpecMap.put(configObj, new ConfigRecord(group, name, type, spec, configObj, valuesBuilder.build())); } return configObj; } diff --git a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/ConfigRecord.java b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/ConfigRecord.java index 7d5a12aa..1e501fb6 100644 --- a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/ConfigRecord.java +++ b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/ConfigRecord.java @@ -8,6 +8,7 @@ import java.util.concurrent.atomic.AtomicBoolean; public record ConfigRecord( + String group, String modId, ModConfig.Type type, ModConfigSpec spec, @@ -16,13 +17,14 @@ public record ConfigRecord( AtomicBoolean registered ) { public ConfigRecord( + String group, String modId, ModConfig.Type type, ModConfigSpec spec, Object object, @Unmodifiable List values ) { - this(modId, type, spec, object, values, new AtomicBoolean(false)); + this(group, modId, type, spec, object, values, new AtomicBoolean(false)); } public void load() { @@ -31,6 +33,8 @@ public void load() { } public String getFileName() { - return "%s-%s.toml".formatted(this.modId, this.type.extension()); + String folder = this.group.isEmpty() ? "" : "%s/".formatted(this.group); + String file = "%s-%s.toml".formatted(this.modId, this.type.extension()); + return "%s%s".formatted(folder, file); } } diff --git a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/package-info.java b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/package-info.java index 7c4e5b7a..4443138a 100644 --- a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/package-info.java +++ b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.config; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/util/FormattingUtil.java b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/util/FormattingUtil.java index 382b31ba..d28c0d40 100644 --- a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/util/FormattingUtil.java +++ b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/util/FormattingUtil.java @@ -26,4 +26,13 @@ public static String toEnglishName(Object internalName) { .map(StringUtils::capitalize) .collect(Collectors.joining(" ")); } + + /** + * 将任意名称切分为点分隔的小写单词:"anvillib_explosion" -> "anvillib.explosion" + */ + public static String toPointSplitName(String name) { + return Arrays.stream(name.split("[^A-Za-z0-9]")) + .filter(s -> !s.isEmpty()) + .collect(Collectors.joining(".")); + } } diff --git a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/util/TranslatableEnum.java b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/util/TranslatableEnum.java new file mode 100644 index 00000000..7e3ef242 --- /dev/null +++ b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/util/TranslatableEnum.java @@ -0,0 +1,22 @@ +package dev.anvilcraft.lib.v2.config.util; + +import dev.anvilcraft.lib.v2.config.ConfigData; +import net.minecraft.network.chat.Component; + +import java.util.Arrays; +import java.util.stream.Collectors; + +public interface TranslatableEnum extends net.neoforged.neoforge.common.TranslatableEnum { + @Override + default Component getTranslatedName() { + return Component.translatableWithFallback(this.getTranslationKey(), ((Enum) this).name()); + } + + default String getTranslationKey() { + String className = Arrays.stream(this.getClass().getCanonicalName().split("[.$]")) + .map(FormattingUtil::toLowerCaseUnder) + .collect(Collectors.joining(".")); + String valueName = FormattingUtil.toLowerCaseUnder(((Enum) this).name().toLowerCase()); + return ConfigData.ENUM_STRING.formatted(className, valueName); + } +} diff --git a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/util/package-info.java b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/util/package-info.java index 6d03442f..e7ea2352 100644 --- a/module.config/src/main/java/dev/anvilcraft/lib/v2/config/util/package-info.java +++ b/module.config/src/main/java/dev/anvilcraft/lib/v2/config/util/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.config.util; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.explosion/build.gradle b/module.explosion/build.gradle new file mode 100644 index 00000000..87231b8c --- /dev/null +++ b/module.explosion/build.gradle @@ -0,0 +1,276 @@ +plugins { + id 'java-library' + id 'eclipse' + id 'idea' + id 'maven-publish' + alias libs.plugins.modDevGradle + alias libs.plugins.lombok + alias libs.plugins.machete + id 'org.jreleaser' version '1.23.0' + id "me.modmuss50.mod-publish-plugin" version "1.1.0" +} + +String buildType = 'snapshot' +String buildNumber +if (System.getenv("CI_BUILD") == 'false') buildNumber = null +else { + if (System.getenv("PR_BUILD") != 'false') buildType = 'pr' + buildNumber = System.getenv("GITHUB_RUN_NUMBER") +} +version = "${mod_version}" + (buildNumber != null ? "+${buildType}.${buildNumber}" : "") +group = mod_group_id + +base { + archivesName = "${project.mod_id}-neoforge-${libs.versions.minecraft.get()}" +} + +repositories { + maven { url = "https://server.cjsah.net:1002/maven/" } + mavenLocal() + mavenCentral() +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +neoForge { + // Specify the version of NeoForge to use. + version = libs.versions.neoForge.get() + + parchment { + mappingsVersion = project.parchment_mappings_version + minecraftVersion = project.parchment_minecraft_version + } + + // This line is optional. Access Transformers are automatically detected + // accessTransformers.add('src/main/resources/META-INF/accesstransformer.cfg') + + interfaceInjectionData { + // from 'src/main/resources/interface_injections.json' + // publish file('src/main/resources/interface_injections.json') + } + + // Default run configurations. + // These can be tweaked, removed, or duplicated as needed. + runs { + client { + client() + + // Comma-separated list of namespaces to load gametests from. Empty = all namespaces. + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + server { + server() + programArgument '--nogui' + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + // This run config launches GameTestServer and runs all registered gametests, then exits. + // By default, the server will crash when no gametests are provided. + // The gametest system is also enabled by default for other run configs under the /test command. + gameTestServer { + type = "gameTestServer" + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + data { + data() + + // example of overriding the workingDirectory set in configureEach above, uncomment if you want to use it + // gameDirectory = project.file('run-data') + + // Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources. + programArguments.addAll '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/').getAbsolutePath(), '--existing', file('src/main/resources/').getAbsolutePath() + } + + // applies to all the run configs above + configureEach { + // Recommended logging data for a userdev environment + // The markers can be added/remove as needed separated by commas. + // "SCAN": For mods scan. + // "REGISTRIES": For firing of registry events. + // "REGISTRYDUMP": For getting the contents of all registries. + systemProperty 'forge.logging.markers', 'REGISTRIES' + + // Recommended logging level for the console + // You can set various levels here. + // Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels + logLevel = org.slf4j.event.Level.DEBUG + } + } + + mods { + // define mod <-> source bindings + // these are used to tell the game which sources are for which mod + // mostly optional in a single mod project + // but multi mod projects should define one per mod + "${mod_id}" { + sourceSet(sourceSets.main) + } + } +} + +// Include resources generated by data generators. +sourceSets.main.resources { srcDir 'src/generated/resources' } + + +dependencies { + + if (System.getenv("NOT_DEV") == 'true') { + jarJar(api("dev.anvilcraft.lib:anvillib-config-neoforge-1.21.1:latest.release")) + } else { + jarJar(implementation project(":anvillib-config-neoforge-1.21.1")) + } +} + +// This block of code expands all declared replace properties in the specified resource targets. +// A missing property will result in an error. Properties are expanded using ${} Groovy notation. +// When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments. +// See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html +tasks.withType(ProcessResources).configureEach { + var replaceProperties = [minecraft_version : libs.versions.minecraft.get(), + minecraft_version_range: minecraft_version_range, + neo_version : libs.versions.neoForge.get(), + neo_version_range : neo_version_range, + loader_version_range : loader_version_range, + mod_id : mod_id, + mod_name : mod_name, + mod_license : mod_license, + mod_version : version, + mod_authors : mod_authors, + mod_description : mod_description] + inputs.properties replaceProperties + + filesMatching(['META-INF/neoforge.mods.toml']) { + expand replaceProperties + } +} + +tasks.register('sourcesJar', Jar) { + from delombok.outputs.files + dependsOn delombok + archiveClassifier = "sources" +} + +tasks.register('javadocJar', Jar) { + from javadoc.destinationDir + dependsOn javadoc + archiveClassifier = "javadoc" +} + +javadoc { + options.encoding = 'UTF-8' + options.addStringOption('Xdoclint:none', '-quiet') +} + +jar { + from(rootProject.file("LICENSE")) + from(rootProject.file("ASSETS_LICENSE")) + from(rootProject.file("README.md")) + from(rootProject.file("README.en.md")) +} + +artifacts { + archives tasks.jar + archives tasks.sourcesJar + archives tasks.javadocJar +} + +def this_name = base.archivesName.get() +def this_description = mod_description + +publishing { + publications { + register('mavenJava', MavenPublication) { + from components.java + + artifact sourcesJar + artifact javadocJar + + versionMapping { + allVariants { + fromResolutionResult() + } + } + + pom { + name = this_name + description = this_description + url = 'https://github.com/Anvil-Dev/AnvilLib' + + licenses { + license { + name = 'MIT License' + url = 'https://opensource.org/license/mit' + } + } + developers { + developer { + id = 'Anvil-Dev' + name = 'AnvilCraft Dev' + email = 'admin@anvilcraft.dev' + } + } + scm { + url = 'https://github.com/Anvil-Dev/AnvilLib' + connection = 'https://github.com/Anvil-Dev/AnvilLib.git' + developerConnection = 'https://github.com/Anvil-Dev/AnvilLib.git' + } + } + } + } + repositories { + // Add repositories to publish to here. + def MAVEN_URL = System.getenv("MAVEN_URL") + if (MAVEN_URL != null) { + maven { + url MAVEN_URL + credentials { + username System.getenv("MAVEN_USERNAME") + password System.getenv("MAVEN_PASSWORD") + } + } + } + mavenLocal() + maven { + name = "staging" + url = layout.buildDirectory.dir("staging-deploy") + } + } +} + +jreleaser { + signing { + active = 'ALWAYS' + armored = true + } + + deploy { + maven { + mavenCentral { + sonatype { + active = 'ALWAYS' + url = 'https://central.sonatype.com/api/v1/publisher' + stagingRepository('build/staging-deploy') + } + } + } + } +} + +// IDEA no longer automatically downloads sources/javadoc jars for dependencies, so we need to explicitly enable the behavior. +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} + +machete { + // disable machete locally for faster builds + enabled = false +} + +lombok { + version = "1.18.34" +} diff --git a/module.explosion/gradle.properties b/module.explosion/gradle.properties new file mode 100644 index 00000000..9a091514 --- /dev/null +++ b/module.explosion/gradle.properties @@ -0,0 +1,4 @@ +## Mod Properties +mod_id=anvillib_explosion +mod_name=AnvilLib-Explosion +mod_description=A library for exploding blocks in a sphere shape diff --git a/module.explosion/src/generated/resources/assets/anvillib_explosion/lang/en_us.json b/module.explosion/src/generated/resources/assets/anvillib_explosion/lang/en_us.json new file mode 100644 index 00000000..822f71ea --- /dev/null +++ b/module.explosion/src/generated/resources/assets/anvillib_explosion/lang/en_us.json @@ -0,0 +1,9 @@ +{ + "anvillib_explosion.configuration.default_remove_blocks_per_tick": "Default Remove Blocks Per Tick", + "anvillib_explosion.configuration.default_remove_blocks_per_tick.tooltip": "The default number of blocks that can be deleted per tick.", + "anvillib_explosion.configuration.max_remove_blocks_per_tick": "Max Remove Blocks Per Tick", + "anvillib_explosion.configuration.max_remove_blocks_per_tick.tooltip": "The maximum number of blocks that can be deleted per tick.", + "anvillib_explosion.configuration.section.anvillib.explosion.common.toml": "Anvillib Explosion Common Configuration", + "anvillib_explosion.configuration.section.anvillib.explosion.common.toml.title": "Anvillib Explosion Common Configuration", + "anvillib_explosion.configuration.title": "Anvillib Explosion Configuration" +} \ No newline at end of file diff --git a/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/AnvilLibExplosion.java b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/AnvilLibExplosion.java new file mode 100644 index 00000000..c79d1dbf --- /dev/null +++ b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/AnvilLibExplosion.java @@ -0,0 +1,105 @@ +package dev.anvilcraft.lib.v2.explosion; + +import dev.anvilcraft.lib.v2.config.ConfigManager; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.MinecraftServer; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.crafting.AbstractCookingRecipe; +import net.minecraft.world.item.crafting.BlastingRecipe; +import net.minecraft.world.item.crafting.Ingredient; +import net.minecraft.world.item.crafting.Recipe; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.SmeltingRecipe; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.event.OnDatapackSyncEvent; +import net.neoforged.neoforge.event.server.ServerStartedEvent; +import org.jetbrains.annotations.ApiStatus; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Mod(AnvilLibExplosion.MOD_ID) +@EventBusSubscriber(modid = AnvilLibExplosion.MOD_ID) +public class AnvilLibExplosion { + public static final String MAIN_ID = "anvillib"; + public static final String MOD_ID = "anvillib_explosion"; + public static final AnvilLibExplosionConfig CONFIG = ConfigManager.register(MOD_ID, AnvilLibExplosionConfig::new); + public static final Map MELTING_CACHE = new ConcurrentHashMap<>(); + + @ApiStatus.Internal + public AnvilLibExplosion() { + } + + public static ResourceLocation of(String path) { + return ResourceLocation.fromNamespaceAndPath(AnvilLibExplosion.MAIN_ID, path); + } + + @ApiStatus.Internal + @SubscribeEvent + public static void onDatapackSync(OnDatapackSyncEvent event) { + if (event.getPlayer() != null) { + return; + } + AnvilLibExplosion.registerMelting(event.getPlayerList().getServer()); + } + + @ApiStatus.Internal + @SubscribeEvent + public static void onServerStarted(ServerStartedEvent event) { + AnvilLibExplosion.registerMelting(event.getServer()); + } + + private static void registerMelting(MinecraftServer server) { + AnvilLibExplosion.MELTING_CACHE.clear(); + BuiltInRegistries.BLOCK.getTag(BlockTags.LOGS_THAT_BURN).ifPresent(block -> { + for (Holder blockHolder : block) { + AnvilLibExplosion.MELTING_CACHE.put(blockHolder.value(), Blocks.COAL_BLOCK); + } + }); + AnvilLibExplosion.MELTING_CACHE.put(Blocks.GRASS_BLOCK, Blocks.PODZOL); + AnvilLibExplosion.MELTING_CACHE.put(Blocks.MYCELIUM, Blocks.PODZOL); + AnvilLibExplosion.MELTING_CACHE.put(Blocks.DIRT_PATH, Blocks.COARSE_DIRT); + AnvilLibExplosion.MELTING_CACHE.put(Blocks.DIRT, Blocks.COARSE_DIRT); + AnvilLibExplosion.MELTING_CACHE.put(Blocks.ROOTED_DIRT, Blocks.COARSE_DIRT); + AnvilLibExplosion.MELTING_CACHE.put(Blocks.FARMLAND, Blocks.COARSE_DIRT); + AnvilLibExplosion.MELTING_CACHE.put(Blocks.MUD, Blocks.DIRT); + for (RecipeHolder holder : server.getRecipeManager().getRecipes()) { + Recipe value = holder.value(); + if (value instanceof SmeltingRecipe || value instanceof BlastingRecipe) { + AnvilLibExplosion.registerAbstractCookingRecipe((AbstractCookingRecipe) value, server.registryAccess()); + } + } + } + + private static void registerAbstractCookingRecipe(AbstractCookingRecipe recipe, HolderLookup.Provider registries) { + Ingredient input = recipe.getIngredients().getFirst(); + List items = Arrays.stream(input.getItems()).map(ItemStack::getItem).toList(); + ItemStack result = recipe.getResultItem(registries); + if (result.getCount() != 1) { + return; + } + if (!(result.getItem() instanceof BlockItem blockItemOutput)) { + return; + } + for (Item item : items) { + if (!(item instanceof BlockItem blockItemInput)) { + continue; + } + Block blockInput = blockItemInput.getBlock(); + Block blockOutput = blockItemOutput.getBlock(); + MELTING_CACHE.put(blockInput, blockOutput); + } + } +} diff --git a/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/AnvilLibExplosionConfig.java b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/AnvilLibExplosionConfig.java new file mode 100644 index 00000000..7513ec68 --- /dev/null +++ b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/AnvilLibExplosionConfig.java @@ -0,0 +1,12 @@ +package dev.anvilcraft.lib.v2.explosion; + +import dev.anvilcraft.lib.v2.config.Comment; +import dev.anvilcraft.lib.v2.config.Config; + +@Config(name = AnvilLibExplosion.MOD_ID) +public class AnvilLibExplosionConfig { + @Comment("The maximum number of blocks that can be deleted per tick.") + public int maxRemoveBlocksPerTick = 2048; + @Comment("The default number of blocks that can be deleted per tick.") + public int defaultRemoveBlocksPerTick = 128; +} diff --git a/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/ExplosionExecutor.java b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/ExplosionExecutor.java new file mode 100644 index 00000000..f53bd6e4 --- /dev/null +++ b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/ExplosionExecutor.java @@ -0,0 +1,132 @@ +package dev.anvilcraft.lib.v2.explosion; + +import lombok.Setter; +import lombok.experimental.Accessors; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.BlockTags; +import net.minecraft.tags.TagKey; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.block.Block; +import net.neoforged.neoforge.common.Tags; +import org.apache.logging.log4j.util.TriConsumer; +import javax.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +/// 这是一个可灵活配置的入口点,用于模拟球形爆炸效果:爆炸从中心开始,逐个方向撞击并破坏方块(每 tick 处理 N 个方块)。 +/// +/// ### 用法: +/// ``` +/// new ExplosionExecutor() +/// .radius(50) +/// .maxBreakPreTick(64) +/// .probabilityRadius(160) +/// .meltingRadius(128) +/// .execute(level, center); +/// ``` +@Setter +@Accessors(fluent = true) +public class ExplosionExecutor { + /// 爆炸时完全破坏方块的半径 + private int radius = 100; + /// 每个服务器 tick 中最多可以移除的块数。 + private int maxBreakPreTick = AnvilLibExplosion.CONFIG.defaultRemoveBlocksPerTick; + /// 当方块被破坏时,是否需要掉落物品 + private boolean dropItems = false; + /// 概率半径(以块为单位),需要大于 `radius` ,该范围内的地表方块将由近到远地被概率性地破坏,距离中心越近的概率越高,最靠近 `radius` 的概率为 `100%`,最靠近 `probabilityRadius` 的概率为 `80%` + private int probabilityRadius = 128; + /// 融化半径(以块为单位),需要大于 `radius` ,该范围内的地表方块将由近到远地被概率性地融化,距离中心越近的概率越高,最靠近 `radius` 的概率为 `100%`,最靠近 `meltingRadius` 的概率为 `80%` + private int meltingRadius = 160; + /// 不允许被爆炸破坏的方块列表 + private List> excludedBlocks = new ArrayList<>(List.of( + block -> block.defaultDestroyTime() < 0 + )); + /// 脆弱的方块列表,在范围内的这些方块会被完全破坏 + @SuppressWarnings("deprecation") + private List> frangibleBlocks = new ArrayList<>(List.of( + block -> block.builtInRegistryHolder().is(Tags.Blocks.GLASS_BLOCKS), + block -> block.builtInRegistryHolder().is(Tags.Blocks.GLASS_PANES), + block -> block.builtInRegistryHolder().is(BlockTags.LEAVES), + block -> block.builtInRegistryHolder().is(BlockTags.REPLACEABLE) + )); + private @Nullable Entity executor = null; + /// 方块破坏时触发的实体处理函数 + private @Nullable TriConsumer entityProcessor = (level, ignored, entity) -> entity.hurt( + entity.damageSources().explosion(null, ExplosionExecutor.this.executor), + ExplosionExecutor.this.radius * 0.5f + ); + + private ExplosionExecutor() { + } + + public static ExplosionExecutor create() { + return new ExplosionExecutor(); + } + + @SafeVarargs + public final ExplosionExecutor excludedBlocks(Predicate... blocks) { + this.excludedBlocks.addAll(List.of(blocks)); + return this; + } + + public final ExplosionExecutor excludedBlocks(Block... blocks) { + for (Block block : blocks) { + this.excludedBlocks.add(block1 -> block1 == block); + } + return this; + } + + @SafeVarargs + @SuppressWarnings("deprecation") + public final ExplosionExecutor excludedBlocks(TagKey... blocks) { + for (TagKey block : blocks) { + this.excludedBlocks.add(block1 -> block1.builtInRegistryHolder().is(block)); + } + return this; + } + + @SafeVarargs + public final ExplosionExecutor frangibleBlocks(Predicate... blocks) { + this.frangibleBlocks.addAll(List.of(blocks)); + return this; + } + + public final ExplosionExecutor frangibleBlocks(Block... blocks) { + for (Block block : blocks) { + this.frangibleBlocks.add(block1 -> block1 == block); + } + return this; + } + + @SafeVarargs + @SuppressWarnings("deprecation") + public final ExplosionExecutor frangibleBlocks(TagKey... blocks) { + for (TagKey block : blocks) { + this.frangibleBlocks.add(block1 -> block1.builtInRegistryHolder().is(block)); + } + return this; + } + + /// 开始进行分层球形爆炸。该爆炸会创建一个 {@link ExplosionSession} 对象,该对象会自动注册到 NeoForge 事件总线中,并从爆炸中心开始逐层(逐块)移除周围的方块。 + public void execute(ServerLevel level, BlockPos pos) { + // Ensure probabilityRadius and meltingRadius are valid + int actualProbabilityRadius = Math.max(this.probabilityRadius, this.radius); + int actualMeltingRadius = Math.max(this.meltingRadius, this.radius); + + new ExplosionSession( + level, + pos, + this.radius, + Math.min(this.maxBreakPreTick, AnvilLibExplosion.CONFIG.maxRemoveBlocksPerTick), + this.dropItems, + actualProbabilityRadius, + actualMeltingRadius, + this.excludedBlocks, + this.frangibleBlocks, + this.entityProcessor + ).start(); + } +} diff --git a/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/ExplosionSession.java b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/ExplosionSession.java new file mode 100644 index 00000000..bb9ab5e9 --- /dev/null +++ b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/ExplosionSession.java @@ -0,0 +1,483 @@ +package dev.anvilcraft.lib.v2.explosion; + +import com.google.common.collect.Multimap; +import com.google.common.collect.MultimapBuilder; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.gameevent.GameEvent; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.material.FluidState; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.tick.ServerTickEvent; +import org.apache.logging.log4j.util.TriConsumer; +import org.jetbrains.annotations.ApiStatus; +import javax.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * Performs the actual block removal in a spherical explosion, driven by + * {@link ServerTickEvent.Post}. Each tick removes at most {@code maxBreakPerTick} + * blocks from inside to outside until the explosion radius is exhausted, + * then self-unregisters. + * + *

Uses dynamic layer-by-layer generation with virtual thread pre-computation + * to avoid memory issues with large radii and improve performance through async processing. + */ +@ApiStatus.Internal +class ExplosionSession { + // Shared virtual thread executor for async layer pre-computation + private static final Executor THREAD_EXECUTOR = Executors.newWorkStealingPool(); + + private final ServerLevel level; + private final BlockPos center; + private final int maxRadius; + private final int maxBreakPerTick; + private final boolean dropItems; + private final int probabilityRadius; // Probability destruction radius + private final int meltingRadius; // Melting radius (replace with air without drops) + private final int effectiveMaxRadius; // Outermost radius to process (max of probabilityRadius and meltingRadius) + private final @Nullable List> excludedBlocks; // Blocks that cannot be destroyed by explosion + private final @Nullable List> frangibleBlocks; // Frangible blocks that are always destroyed within range + private final @Nullable TriConsumer entityProcessor; + + private final int surfaceHeight; + + // Current processing state + private int currentLayer; // Current distance layer (0 to effectiveMaxRadius) + private @Nullable List currentLayerBlocks; // Blocks in current layer + private int layerIndex; // Index within current layer + + // Async pre-computation for next layer + private @Nullable CompletableFuture> nextLayerFuture; // Future for next layer computation + private final Multimap entityCache = MultimapBuilder.hashKeys().arrayListValues().build(); + private int nextLayerToCompute; // Which layer is being pre-computed + + private boolean finished; + + ExplosionSession( + ServerLevel level, + BlockPos center, + int maxRadius, + int maxBreakPerTick, + boolean dropItems, + int probabilityRadius, + int meltingRadius, + @Nullable List> excludedBlocks, + @Nullable List> frangibleBlocks, + @Nullable TriConsumer entityProcessor + ) { + this.level = level; + this.center = center; + this.maxRadius = maxRadius; + this.maxBreakPerTick = maxBreakPerTick; + this.dropItems = dropItems; + this.probabilityRadius = probabilityRadius; + this.meltingRadius = meltingRadius; + this.effectiveMaxRadius = Math.max(probabilityRadius, meltingRadius); + this.excludedBlocks = excludedBlocks; + this.frangibleBlocks = frangibleBlocks; + this.entityProcessor = entityProcessor; + this.surfaceHeight = (int) (Math.ceil(maxRadius * 0.1) + 1); + } + + // ---- lifecycle ---- + + void start() { + this.currentLayer = 0; + this.currentLayerBlocks = null; + this.layerIndex = 0; + this.nextLayerFuture = null; + this.nextLayerToCompute = -1; + this.finished = false; + this.entityCache.clear(); + NeoForge.EVENT_BUS.register(this); + } + + private void stop() { + NeoForge.EVENT_BUS.unregister(this); + } + + // ---- tick processing ---- + + @SubscribeEvent + public void onServerTick(ServerTickEvent.Post event) { + if (this.finished) { + this.stop(); + return; + } + + int removed = 0; + + this.createEntityCache(); + // Process blocks layer by layer to avoid memory issues with large radii + while (removed < this.maxBreakPerTick && this.currentLayer <= this.effectiveMaxRadius) { + // Generate current layer if needed + if (this.currentLayerBlocks == null || this.layerIndex >= this.currentLayerBlocks.size()) { + // Try to get pre-computed next layer + if (this.nextLayerFuture != null && this.nextLayerToCompute == this.currentLayer) { + // Use pre-computed result from virtual thread + this.currentLayerBlocks = this.nextLayerFuture.join(); + this.nextLayerFuture = null; + this.nextLayerToCompute = -1; + } else { + // Fallback: compute synchronously if pre-computation wasn't ready + this.currentLayerBlocks = this.generateLayerBlocks(this.currentLayer); + } + + this.layerIndex = 0; + + // Move to next layer if current one is empty + if (this.currentLayerBlocks.isEmpty()) { + this.currentLayer++; + continue; + } + + // Start pre-computing next layer in virtual thread + this.startNextLayerPrecomputation(); + } + + // Process blocks in current layer + while (this.layerIndex < this.currentLayerBlocks.size() && removed < this.maxBreakPerTick) { + BlockPos target = this.currentLayerBlocks.get(this.layerIndex); + this.layerIndex++; + + if (!this.level.isLoaded(target)) continue; + + if (this.entityProcessor != null) { + this.entityCache.get(target).forEach(entity -> this.entityProcessor.accept(this.level, target, entity)); + } + BlockState blockState = this.level.getBlockState(target); + if (blockState.isAir()) continue; + // public int getHeight(Heightmap.Types type, int x, int z) {} + int height = level.getHeight(Heightmap.Types.WORLD_SURFACE, target.getX(), target.getZ()); + boolean isSurface = target.getY() >= height - this.surfaceHeight; + // Check if block is excluded from explosion + if (this.isBlockExcluded(blockState.getBlock())) { + continue; + } + + // Frangible blocks are always completely destroyed within range + if (this.isBlockFrangible(blockState.getBlock())) { + if (ExplosionSession.destroyBlock(this.level, target, this.dropItems)) { + removed++; + } + continue; + } + + double distance = Math.sqrt(target.distToCenterSqr(this.center.getX(), this.center.getY(), this.center.getZ())); + + // Determine action based on distance + if (distance <= this.maxRadius) { + // Core explosion: always destroy + if (ExplosionSession.destroyBlock(this.level, target, this.dropItems)) { + removed++; + } + } else { + if (distance <= this.probabilityRadius && isSurface) { + // Probability destruction: probability decreases from 80% at maxRadius to 0% at probabilityRadius + double probability = this.calculateProbability(distance, this.maxRadius, this.probabilityRadius); + if (Math.random() < probability) { + if (ExplosionSession.destroyBlock(this.level, target, this.dropItems)) { + removed++; + blockState = Blocks.AIR.defaultBlockState(); + } + } + } + if (distance <= this.meltingRadius && isSurface && !blockState.isAir()) { + // Melting: probability decreases from 80% at probabilityRadius to 0% at meltingRadius + double probability = this.calculateProbability(distance, this.probabilityRadius, this.meltingRadius); + if (Math.random() < probability) { + if (ExplosionSession.meltBlock(this.level, target)) { + removed++; + } + } + } + } + } + + // Move to next layer when current one is done + if (this.layerIndex >= this.currentLayerBlocks.size()) { + this.currentLayer++; + this.currentLayerBlocks = null; // Allow GC to collect + } + } + + if (this.currentLayer > this.effectiveMaxRadius) { + this.finished = true; + this.stop(); + } + } + + // ---- Block enumeration ---- + + /** + * Start pre-computing the next layer in a virtual thread. + * For large radii, splits the computation into multiple parallel sub-tasks. + */ + private void startNextLayerPrecomputation() { + int nextLayer = this.currentLayer + 1; + + // Don't pre-compute beyond effective max radius + if (nextLayer > this.effectiveMaxRadius) { + return; + } + + // Only start if not already computing this layer + if (this.nextLayerFuture != null && !this.nextLayerFuture.isDone()) { + return; + } + + // Use parallel computation for large layers to improve performance + boolean useParallel = nextLayer >= 32; // Threshold for parallel processing + + this.nextLayerToCompute = nextLayer; + this.nextLayerFuture = CompletableFuture.supplyAsync(() -> this.generateLayerBlocks(nextLayer, useParallel), THREAD_EXECUTOR); + } + + /** + * Generate all block positions at a specific distance layer from center. + * This approach avoids storing all blocks in memory at once for large radii. + * + * @param layer The distance layer (Euclidean distance shell) + * @return List of BlockPos at this layer, shuffled for natural explosion pattern + */ + private List generateLayerBlocks(int layer) { + return this.generateLayerBlocks(layer, false); + } + + /** + * Generate all block positions at a specific distance layer from center. + * Supports parallel computation for large radii. + * + * @param layer The distance layer (Euclidean distance shell) + * @param useParallel Whether to use parallel computation for this layer + * @return List of BlockPos at this layer, shuffled for natural explosion pattern + */ + private List generateLayerBlocks(int layer, boolean useParallel) { + if (layer == 0) { + // Center point + return List.of(this.center); + } + + // For each layer, we process a "shell" at approximately that distance + // We use integer bounds to efficiently find blocks in the shell + int rSquared = layer * layer; + int innerRSquared = (layer - 1) * (layer - 1); + + List blocks; + + if (useParallel && layer >= 96) { + // Parallel computation: split by X-axis slices + blocks = this.generateLayerBlocksParallel(layer, rSquared, innerRSquared); + } else { + // Sequential computation for smaller layers + blocks = this.generateLayerBlocksSequential(layer, rSquared, innerRSquared); + } + + Collections.shuffle(blocks); + return blocks; + } + + /** + * Sequential generation of layer blocks (for small radii). + */ + private List generateLayerBlocksSequential(int layer, int rSquared, int innerRSquared) { + List blocks = new ArrayList<>(); + + // Iterate through bounding box of this shell + for (int x = -layer; x <= layer; x++) { + for (int y = -layer; y <= layer; y++) { + for (int z = -layer; z <= layer; z++) { + int distSquared = x * x + y * y + z * z; + + // Only include blocks in this shell layer + if (distSquared > innerRSquared && distSquared <= rSquared) { + blocks.add(new BlockPos(this.center.getX() + x, this.center.getY() + y, this.center.getZ() + z)); + } + } + } + } + + return blocks; + } + + /** + * Parallel generation of layer blocks (for large radii). + * Splits the work along the X-axis and merges results. + */ + private List generateLayerBlocksParallel(int layer, int rSquared, int innerRSquared) { + // Determine number of parallel tasks based on layer size + int numTasks = Math.min(Runtime.getRuntime().availableProcessors(), layer / 8 + 1); + int sliceSize = (layer * 2 + 1) / numTasks; + + // Create parallel tasks for each X-slice + List>> futures = new ArrayList<>(numTasks); + + for (int taskIdx = 0; taskIdx < numTasks; taskIdx++) { + final int startX = -layer + taskIdx * sliceSize; + final int endX = (taskIdx == numTasks - 1) ? layer : (startX + sliceSize - 1); + + CompletableFuture> future = CompletableFuture.supplyAsync( + () -> { + List sliceBlocks = new ArrayList<>(); + + for (int x = startX; x <= endX; x++) { + for (int y = -layer; y <= layer; y++) { + for (int z = -layer; z <= layer; z++) { + int distSquared = x * x + y * y + z * z; + + // Only include blocks in this shell layer + if (distSquared > innerRSquared && distSquared <= rSquared) { + sliceBlocks.add(new BlockPos(this.center.getX() + x, this.center.getY() + y, this.center.getZ() + z)); + } + } + } + } + + return sliceBlocks; + }, THREAD_EXECUTOR + ); + + futures.add(future); + } + + // Wait for all tasks to complete and merge results + return futures.stream().flatMap(f -> f.join().stream()).collect(Collectors.toList()); + } + + /** + * Check if a block should be excluded from explosion. + * + * @param block The block to check + * @return true if the block should not be destroyed/melted + */ + private boolean isBlockExcluded(Block block) { + if (this.excludedBlocks == null) { + return false; + } + for (Predicate predicate : this.excludedBlocks) { + if (predicate.test(block)) { + return true; + } + } + return false; + } + + /** + * Check if a block is frangible (always destroyed within range). + * + * @param block The block to check + * @return true if the block is frangible + */ + private boolean isBlockFrangible(Block block) { + if (this.frangibleBlocks == null) { + return false; + } + for (Predicate predicate : this.frangibleBlocks) { + if (predicate.test(block)) { + return true; + } + } + return false; + } + + /** + * Calculate probability based on distance. + * Probability decreases linearly from 80% at innerRadius to 0% at outerRadius. + * + * @param distance Distance from explosion center + * @param innerRadius Inner boundary of the zone (80% probability) + * @param outerRadius Outer boundary of the zone (0% probability) + * @return Probability (0.0 to 0.8) + */ + private double calculateProbability(double distance, int innerRadius, int outerRadius) { + if (outerRadius <= innerRadius) return 0.0; + if (distance <= innerRadius) { + return 0.8; // 80% at inner boundary + } + if (distance >= outerRadius) { + return 0.0; // 0% at outer boundary + } + + // Linear interpolation: 80% at innerRadius, 0% at outerRadius + double ratio = (distance - innerRadius) / (double) (outerRadius - innerRadius); + return 0.8 * (1.0 - ratio); // Decreases from 0.8 to 0.0 + } + + /** + * Melt a block by replacing it with another block from MELTING_CACHE. + * Used for the melting radius effect. + */ + public static boolean meltBlock(ServerLevel level, BlockPos pos) { + if (!level.isLoaded(pos)) { + return false; + } + BlockState blockState = level.getBlockState(pos); + if (blockState.isAir()) { + return false; + } + + Block originalBlock = blockState.getBlock(); + Block targetBlock = AnvilLibExplosion.MELTING_CACHE.get(originalBlock); + + BlockState newState; + if (targetBlock == null) { + return false; + } + newState = targetBlock.defaultBlockState(); + + boolean changed = level.setBlock(pos, newState, Block.UPDATE_CLIENTS, 512); + if (changed) { + level.gameEvent(GameEvent.BLOCK_DESTROY, pos, GameEvent.Context.of(null, blockState)); + } + return changed; + } + + public static boolean destroyBlock(ServerLevel level, BlockPos pos, boolean dropResources) { + if (!level.isLoaded(pos)) { + return false; + } + BlockState blockState = level.getBlockState(pos); + if (blockState.isAir()) { + return false; + } + FluidState fluidState = level.getFluidState(pos); + if (dropResources) { + BlockEntity blockEntity = blockState.hasBlockEntity() ? level.getBlockEntity(pos) : null; + Block.dropResources(blockState, level, pos, blockEntity, null, ItemStack.EMPTY); + } + boolean destroyed = level.setBlock(pos, fluidState.createLegacyBlock(), Block.UPDATE_CLIENTS, 512); + if (destroyed) { + level.gameEvent(GameEvent.BLOCK_DESTROY, pos, GameEvent.Context.of(null, blockState)); + } + return destroyed; + } + + private void createEntityCache() { + this.entityCache.clear(); + if (this.entityProcessor == null) { + return; + } + this.level.getEntities().getAll().forEach(entity -> { + if (entity.blockPosition().distSqr(this.center) > this.maxRadius * this.maxRadius) { + return; + } + this.entityCache.put(entity.blockPosition(), entity); + }); + } +} diff --git a/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/data/AnvilLibDatagen.java b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/data/AnvilLibDatagen.java new file mode 100644 index 00000000..aa05ecfd --- /dev/null +++ b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/data/AnvilLibDatagen.java @@ -0,0 +1,21 @@ +package dev.anvilcraft.lib.v2.explosion.data; + +import dev.anvilcraft.lib.v2.explosion.AnvilLibExplosion; +import dev.anvilcraft.lib.v2.explosion.data.provider.ModLanguageProvider; +import net.minecraft.data.DataGenerator; +import net.minecraft.data.PackOutput; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.data.event.GatherDataEvent; +import org.jetbrains.annotations.ApiStatus; + +@EventBusSubscriber(modid = AnvilLibExplosion.MOD_ID) +@ApiStatus.Internal +public class AnvilLibDatagen { + @SubscribeEvent + public static void gatherData(GatherDataEvent event) { + DataGenerator generator = event.getGenerator(); + PackOutput packOutput = generator.getPackOutput(); + generator.addProvider(event.includeClient(), new ModLanguageProvider(packOutput)); + } +} diff --git a/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/data/package-info.java b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/data/package-info.java new file mode 100644 index 00000000..c758fcab --- /dev/null +++ b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/data/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.explosion.data; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/data/provider/ModLanguageProvider.java b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/data/provider/ModLanguageProvider.java new file mode 100644 index 00000000..4f159e83 --- /dev/null +++ b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/data/provider/ModLanguageProvider.java @@ -0,0 +1,20 @@ +package dev.anvilcraft.lib.v2.explosion.data.provider; + +import dev.anvilcraft.lib.v2.config.ConfigData; +import dev.anvilcraft.lib.v2.explosion.AnvilLibExplosion; +import dev.anvilcraft.lib.v2.explosion.AnvilLibExplosionConfig; +import net.minecraft.data.PackOutput; +import net.neoforged.neoforge.common.data.LanguageProvider; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public class ModLanguageProvider extends LanguageProvider { + public ModLanguageProvider(PackOutput output) { + super(output, AnvilLibExplosion.MOD_ID, "en_us"); + } + + @Override + protected void addTranslations() { + ConfigData.readConfigClass(this, AnvilLibExplosionConfig.class); + } +} diff --git a/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/data/provider/package-info.java b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/data/provider/package-info.java new file mode 100644 index 00000000..42b78d63 --- /dev/null +++ b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/data/provider/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.explosion.data.provider; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/package-info.java b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/package-info.java new file mode 100644 index 00000000..af71303d --- /dev/null +++ b/module.explosion/src/main/java/dev/anvilcraft/lib/v2/explosion/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.explosion; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.explosion/src/main/resources/META-INF/neoforge.mods.toml b/module.explosion/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 00000000..27f7ba75 --- /dev/null +++ b/module.explosion/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,86 @@ +# This is an example mods.toml file. It contains the data relating to the loading mods. +# There are several mandatory fields (#mandatory), and many more that are optional (#optional). +# The overall format is standard TOML format, v0.5.0. +# Note that there are a couple of TOML lists in this file. +# Find more information on toml format here: https://github.com/toml-lang/toml +# The name of the mod loader type to load - for regular FML @Mod mods it should be javafml +modLoader = "javafml" #mandatory +# A version range to match for said mod loader - for regular FML @Mod it will be the the FML version. This is currently 47. +loaderVersion = "${loader_version_range}" #mandatory +# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties. +# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here. +license = "${mod_license}" +# A URL to refer people to when problems occur with this mod +#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional +# A list of mods - how many allowed here is determined by the individual mod loader +[[mods]] #mandatory +# The modid of the mod +modId = "${mod_id}" #mandatory +# The version number of the mod +version = "${mod_version}" #mandatory +# A display name for the mod +displayName = "${mod_name}" #mandatory +# A URL to query for updates for this mod. See the JSON update specification https://docs.neoforge.net/docs/misc/updatechecker/ +#updateJSONURL="https://change.me.example.invalid/updates.json" #optional +# A URL for the "homepage" for this mod, displayed in the mod UI +displayURL="https://github.com/Anvil-Dev/AnvilLib" +# A file name (in the root of the mod JAR) containing a logo for display +logoFile="icon.png" #optional +# A text field displayed in the mod UI +#credits="" #optional +# A text field displayed in the mod UI +authors = "${mod_authors}" #optional +# Display Test controls the display for your mod in the server connection screen +# MATCH_VERSION means that your mod will cause a red X if the versions on client and server differ. This is the default behaviour and should be what you choose if you have server and client elements to your mod. +# IGNORE_SERVER_VERSION means that your mod will not cause a red X if it's present on the server but not on the client. This is what you should use if you're a server only mod. +# IGNORE_ALL_VERSION means that your mod will not cause a red X if it's present on the client or the server. This is a special case and should only be used if your mod has no server component. +# NONE means that no display test is set on your mod. You need to do this yourself, see IExtensionPoint.DisplayTest for more information. You can define any scheme you wish with this value. +# IMPORTANT NOTE: this is NOT an instruction as to which environments (CLIENT or DEDICATED SERVER) your mod loads on. Your mod should load (and maybe do nothing!) whereever it finds itself. +#displayTest="MATCH_VERSION" # MATCH_VERSION is the default if nothing is specified (#optional) + +# The description text for the mod (multi line!) (#mandatory) +description = '''${mod_description}''' + +# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded. +[[mixins]] +config = "${mod_id}.mixins.json" + +# The [[accessTransformers]] block allows you to declare where your AT file is. +# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg +#[[accessTransformers]] +#file="META-INF/accesstransformer.cfg" + +# The coremods config file path is not configurable and is always loaded from META-INF/coremods.json + +# A dependency - use the . to indicate dependency for a specific modid. Dependencies are optional. +[[dependencies."${mod_id}"]] #optional +# the modid of the dependency +modId = "neoforge" #mandatory +# The type of the dependency. Can be one of "required", "optional", "incompatible" or "discouraged" (case insensitive). +# 'required' requires the mod to exist, 'optional' does not +# 'incompatible' will prevent the game from loading when the mod exists, and 'discouraged' will show a warning +type = "required" #mandatory +# Optional field describing why the dependency is required or why it is incompatible +# reason="..." +# The version range of the dependency +versionRange = "${neo_version_range}" #mandatory +# An ordering relationship for the dependency. +# BEFORE - This mod is loaded BEFORE the dependency +# AFTER - This mod is loaded AFTER the dependency +ordering = "NONE" +# Side this dependency is applied on - BOTH, CLIENT, or SERVER +side = "BOTH" +# Here's another dependency +[[dependencies."${mod_id}"]] +modId = "minecraft" +type = "required" +# This version range declares a minimum of the current minecraft version up to but not including the next major version +versionRange = "${minecraft_version_range}" +ordering = "NONE" +side = "BOTH" + +# Features are specific properties of the game environment, that you may want to declare you require. This example declares +# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't +# stop your mod loading on the server for example. +#[features."${mod_id}"] +#openGLVersion="[3.2,)" diff --git a/module.explosion/src/main/resources/anvillib_explosion.mixins.json b/module.explosion/src/main/resources/anvillib_explosion.mixins.json new file mode 100644 index 00000000..7256ac24 --- /dev/null +++ b/module.explosion/src/main/resources/anvillib_explosion.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "dev.anvilcraft.lib.v2.explosion.mixin", + "compatibilityLevel": "JAVA_21", + "refmap": "anvillib.refmap.json", + "mixins": [ + ], + "client": [ + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/module.explosion/src/main/resources/assets/anvillib_explosion/lang/zh_cn.json b/module.explosion/src/main/resources/assets/anvillib_explosion/lang/zh_cn.json new file mode 100644 index 00000000..e9edafbb --- /dev/null +++ b/module.explosion/src/main/resources/assets/anvillib_explosion/lang/zh_cn.json @@ -0,0 +1,9 @@ +{ + "anvillib_explosion.configuration.default_remove_blocks_per_tick": "默认每 tick 移除的方块数", + "anvillib_explosion.configuration.default_remove_blocks_per_tick.tooltip": "每 tick 内可以删除的方块的默认数量", + "anvillib_explosion.configuration.max_remove_blocks_per_tick": "最大每 tick 移除的方块数", + "anvillib_explosion.configuration.max_remove_blocks_per_tick.tooltip": "每 tick 内可以删除的方块的最大数量", + "anvillib_explosion.configuration.section.anvillib.explosion.common.toml": "Anvillib Explosion 通用 配置", + "anvillib_explosion.configuration.section.anvillib.explosion.common.toml.title": "Anvillib Explosion 通用 配置", + "anvillib_explosion.configuration.title": "Anvillib Explosion 配置" +} \ No newline at end of file diff --git a/module.font/build.gradle b/module.font/build.gradle new file mode 100644 index 00000000..1fbbbf23 --- /dev/null +++ b/module.font/build.gradle @@ -0,0 +1,266 @@ +plugins { + id 'java-library' + id 'eclipse' + id 'idea' + id 'maven-publish' + alias libs.plugins.modDevGradle + alias libs.plugins.lombok + alias libs.plugins.machete + id 'org.jreleaser' version '1.23.0' + id "me.modmuss50.mod-publish-plugin" version "1.1.0" +} + +String buildType = 'snapshot' +String buildNumber +if (System.getenv("CI_BUILD") == 'false') buildNumber = null +else { + if (System.getenv("PR_BUILD") != 'false') buildType = 'pr' + buildNumber = System.getenv("GITHUB_RUN_NUMBER") +} +version = "${mod_version}" + (buildNumber != null ? "+${buildType}.${buildNumber}" : "") +group = mod_group_id + +base { + archivesName = "${project.mod_id}-neoforge-${libs.versions.minecraft.get()}" +} + +repositories { + maven { url = "https://server.cjsah.net:1002/maven/" } + mavenLocal() + mavenCentral() +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +neoForge { + // Specify the version of NeoForge to use. + version = libs.versions.neoForge.get() + + parchment { + mappingsVersion = project.parchment_mappings_version + minecraftVersion = project.parchment_minecraft_version + } + + // This line is optional. Access Transformers are automatically detected + // accessTransformers.add('src/main/resources/META-INF/accesstransformer.cfg') + + interfaceInjectionData { + from 'src/main/resources/interface_injections.json' + publish file('src/main/resources/interface_injections.json') + } + + // Default run configurations. + // These can be tweaked, removed, or duplicated as needed. + runs { + client { + client() + + // Comma-separated list of namespaces to load gametests from. Empty = all namespaces. + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + server { + server() + programArgument '--nogui' + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + // This run config launches GameTestServer and runs all registered gametests, then exits. + // By default, the server will crash when no gametests are provided. + // The gametest system is also enabled by default for other run configs under the /test command. + gameTestServer { + type = "gameTestServer" + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + data { + data() + + // example of overriding the workingDirectory set in configureEach above, uncomment if you want to use it + // gameDirectory = project.file('run-data') + + // Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources. + programArguments.addAll '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/').getAbsolutePath(), '--existing', file('src/main/resources/').getAbsolutePath() + } + + // applies to all the run configs above + configureEach { + // Recommended logging data for a userdev environment + // The markers can be added/remove as needed separated by commas. + // "SCAN": For mods scan. + // "REGISTRIES": For firing of registry events. + // "REGISTRYDUMP": For getting the contents of all registries. + systemProperty 'forge.logging.markers', 'REGISTRIES' + + // Recommended logging level for the console + // You can set various levels here. + // Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels + logLevel = org.slf4j.event.Level.DEBUG + } + } + + mods { + // define mod <-> source bindings + // these are used to tell the game which sources are for which mod + // mostly optional in a single mod project + // but multi mod projects should define one per mod + "${mod_id}" { + sourceSet(sourceSets.main) + } + } +} + +// Include resources generated by data generators. +sourceSets.main.resources { srcDir 'src/generated/resources' } + +// This block of code expands all declared replace properties in the specified resource targets. +// A missing property will result in an error. Properties are expanded using ${} Groovy notation. +// When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments. +// See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html +tasks.withType(ProcessResources).configureEach { + var replaceProperties = [minecraft_version : libs.versions.minecraft.get(), + minecraft_version_range: minecraft_version_range, + neo_version : libs.versions.neoForge.get(), + neo_version_range : neo_version_range, + loader_version_range : loader_version_range, + mod_id : mod_id, + mod_name : mod_name, + mod_license : mod_license, + mod_version : version, + mod_authors : mod_authors, + mod_description : mod_description] + inputs.properties replaceProperties + + filesMatching(['META-INF/neoforge.mods.toml']) { + expand replaceProperties + } +} + +tasks.register('sourcesJar', Jar) { + from delombok.outputs.files + dependsOn delombok + archiveClassifier = "sources" +} + +tasks.register('javadocJar', Jar) { + from javadoc.destinationDir + dependsOn javadoc + archiveClassifier = "javadoc" +} + +javadoc { + options.encoding = 'UTF-8' + options.addStringOption('Xdoclint:none', '-quiet') +} + +jar { + from(rootProject.file("LICENSE")) + from(rootProject.file("ASSETS_LICENSE")) + from(rootProject.file("README.md")) + from(rootProject.file("README.en.md")) +} + +artifacts { + archives tasks.jar + archives tasks.sourcesJar + archives tasks.javadocJar +} + +def this_name = base.archivesName.get() +def this_description = mod_description + +publishing { + publications { + register('mavenJava', MavenPublication) { + from components.java + + artifact sourcesJar + artifact javadocJar + + versionMapping { + allVariants { + fromResolutionResult() + } + } + + pom { + name = this_name + description = this_description + url = 'https://github.com/Anvil-Dev/AnvilLib' + + licenses { + license { + name = 'MIT License' + url = 'https://opensource.org/license/mit' + } + } + developers { + developer { + id = 'Anvil-Dev' + name = 'AnvilCraft Dev' + email = 'admin@anvilcraft.dev' + } + } + scm { + url = 'https://github.com/Anvil-Dev/AnvilLib' + connection = 'https://github.com/Anvil-Dev/AnvilLib.git' + developerConnection = 'https://github.com/Anvil-Dev/AnvilLib.git' + } + } + } + } + repositories { + // Add repositories to publish to here. + def MAVEN_URL = System.getenv("MAVEN_URL") + if (MAVEN_URL != null) { + maven { + url MAVEN_URL + credentials { + username System.getenv("MAVEN_USERNAME") + password System.getenv("MAVEN_PASSWORD") + } + } + } + mavenLocal() + maven { + name = "staging" + url = layout.buildDirectory.dir("staging-deploy") + } + } +} + +jreleaser { + signing { + active = 'ALWAYS' + armored = true + } + + deploy { + maven { + mavenCentral { + sonatype { + active = 'ALWAYS' + url = 'https://central.sonatype.com/api/v1/publisher' + stagingRepository('build/staging-deploy') + } + } + } + } +} + +// IDEA no longer automatically downloads sources/javadoc jars for dependencies, so we need to explicitly enable the behavior. +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} + +machete { + // disable machete locally for faster builds + enabled = false +} + +lombok { + version = "1.18.34" +} diff --git a/module.font/gradle.properties b/module.font/gradle.properties new file mode 100644 index 00000000..a542eb11 --- /dev/null +++ b/module.font/gradle.properties @@ -0,0 +1,4 @@ +## Mod Properties +mod_id=anvillib_font +mod_name=AnvilLib-Font +mod_description=SDF font rendering diff --git a/module.font/src/generated/resources/assets/anvillib_font/lang/en_us.json b/module.font/src/generated/resources/assets/anvillib_font/lang/en_us.json new file mode 100644 index 00000000..05bdea21 --- /dev/null +++ b/module.font/src/generated/resources/assets/anvillib_font/lang/en_us.json @@ -0,0 +1,8 @@ +{ + "narration.anvillib_font.dropdown.collapsed": "Collapsed", + "narration.anvillib_font.dropdown.expanded": "Expanded", + "screen.anvillib_font.config": "Font Config Screen", + "screen.anvillib_font.config.family": "Font Family", + "screen.anvillib_font.config.font": "Font", + "screen.anvillib_font.config.test": "Font Test" +} \ No newline at end of file diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/ALFPipelines.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/ALFPipelines.java new file mode 100644 index 00000000..139af899 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/ALFPipelines.java @@ -0,0 +1,59 @@ +package dev.anvilcraft.lib.v2.font; + +import com.mojang.blaze3d.vertex.VertexFormat; +import com.mojang.blaze3d.vertex.VertexFormatElement; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.client.renderer.ShaderInstance; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.event.RegisterShadersEvent; +import org.jetbrains.annotations.ApiStatus; +import javax.annotation.Nullable; + +import java.io.IOException; + +/** + * Font module SDF shader registration. + * + *

The SDF text shader is registered via {@link RegisterShadersEvent} and can be + * bound through {@link com.mojang.blaze3d.systems.RenderSystem#setShader} when drawing + * SDF text quads on the GUI.

+ */ +@ApiStatus.Internal +@Slf4j +@EventBusSubscriber(modid = AnvilLibFont.MOD_ID, value = Dist.CLIENT) +public final class ALFPipelines { + /** + * The element names (Position / Color / UV0) match the vertex inputs declared in {@code sdf_text.vsh}. + */ + public static final VertexFormat SDF_TEXT_FORMAT = VertexFormat.builder() + .add("Position", VertexFormatElement.POSITION) + .add("Color", VertexFormatElement.COLOR) + .add("UV", VertexFormatElement.UV) + .build(); + + @Getter + private static @Nullable ShaderInstance sdfTextShader; + + private ALFPipelines() { + } + + @SubscribeEvent + public static void on(RegisterShadersEvent event) { + try { + event.registerShader( + new ShaderInstance( + event.getResourceProvider(), + AnvilLibFont.of("sdf_text"), + SDF_TEXT_FORMAT + ), + it -> sdfTextShader = it + ); + log.info("Registered SDF_TEXT shader: {}", AnvilLibFont.of("sdf_text")); + } catch (IOException e) { + log.error("Failed to register SDF text shader", e); + } + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/ALFont.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/ALFont.java new file mode 100644 index 00000000..7c15cbb0 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/ALFont.java @@ -0,0 +1,132 @@ +package dev.anvilcraft.lib.v2.font; + +import com.google.common.annotations.Beta; +import dev.anvilcraft.lib.v2.font.sdf.SdfGlyphAtlas; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.Style; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.util.Mth; + +import java.awt.Font; +import java.util.ArrayList; +import java.util.List; + +/** + * Wraps {@link Font} with text measurement and layout utilities that + * delegate to {@link SdfGlyphAtlas} for glyph metrics. + */ +@Beta +public class ALFont { + public final int lineHeight = Minecraft.getInstance().font.lineHeight; + private final Font font; + + public ALFont(Font font) { + this.font = font; + } + + public Font awtFont() { return this.font; } + + private SdfGlyphAtlas atlas() { return SdfGlyphAtlas.getOrCreate(this.font).join(); } + + private float scale() { + return (float) lineHeight / atlas().awtHeight(); + } + + // ── Width measurement ─────────────────────────────────────── + + public int width(String str) { + return Mth.ceil(atlas().measureText(str) * scale()); + } + + public int width(FormattedText text) { return width(text.getString()); } + + public int width(FormattedCharSequence text) { return width(flatten(text)); } + + // ── Substring by width ────────────────────────────────────── + + public String plainSubstrByWidth(String str, int maxWidth, boolean reverse) { + if (reverse) return plainTailByWidth(str, maxWidth); + return plainHeadByWidth(str, maxWidth); + } + + public String plainSubstrByWidth(String str, int maxWidth) { + return plainHeadByWidth(str, maxWidth); + } + + private String plainHeadByWidth(String str, int maxWidth) { + SdfGlyphAtlas a = atlas(); + float s = scale(); + if (a.measureText(str) * s <= maxWidth) return str; + int lo = 0, hi = str.length(); + while (lo < hi) { + int mid = (lo + hi + 1) / 2; + if (a.measureText(str, 0, mid) * s <= maxWidth) lo = mid; + else hi = mid - 1; + } + return str.substring(0, lo); + } + + private String plainTailByWidth(String str, int maxWidth) { + SdfGlyphAtlas a = atlas(); + float s = scale(); + if (a.measureText(str) * s <= maxWidth) return str; + int lo = 0, hi = str.length(); + while (lo < hi) { + int mid = (lo + hi) / 2; + if (a.measureText(str, mid, str.length()) * s <= maxWidth) hi = mid; + else lo = mid + 1; + } + return str.substring(lo); + } + + public FormattedText substrByWidth(FormattedText text, int maxWidth) { + return FormattedText.of(plainSubstrByWidth(text.getString(), maxWidth)); + } + + // ── Line splitting ────────────────────────────────────────── + + public List split(FormattedText input, int maxWidth) { + SdfGlyphAtlas a = atlas(); + float s = scale(); + List lines = wrapLines(a, input.getString(), maxWidth, s); + List result = new ArrayList<>(lines.size()); + for (String line : lines) { + result.add(FormattedCharSequence.forward(line, Style.EMPTY)); + } + return result; + } + + public int wordWrapHeight(FormattedText input, int maxWidth) { + return lineHeight * split(input, maxWidth).size(); + } + + // ── Internal helpers ──────────────────────────────────────── + + static String flatten(FormattedCharSequence text) { + StringBuilder buf = new StringBuilder(); + text.accept((i, s, cp) -> { buf.appendCodePoint(cp); return true; }); + return buf.toString(); + } + + public static List wrapLines(SdfGlyphAtlas atlas, String text, int maxWidth, float scale) { + List lines = new ArrayList<>(); + if (text.isEmpty()) { lines.add(""); return lines; } + for (String para : text.split("\n", -1)) { + if (para.isEmpty()) { lines.add(""); continue; } + String[] words = para.split(" "); + StringBuilder line = new StringBuilder(); + for (String word : words) { + String cand = line.isEmpty() ? word : line + " " + word; + if (atlas.measureText(cand) * scale > maxWidth) { + if (line.isEmpty()) lines.add(word); + else { lines.add(line.toString()); line = new StringBuilder(word); } + } else { + line = new StringBuilder(cand); + } + } + if (!line.isEmpty()) lines.add(line.toString()); + } + return lines; + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/AnvilLibFont.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/AnvilLibFont.java new file mode 100644 index 00000000..4edc6633 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/AnvilLibFont.java @@ -0,0 +1,46 @@ +package dev.anvilcraft.lib.v2.font; + +import dev.anvilcraft.lib.v2.font.screen.FontConfigScreen; +import dev.anvilcraft.lib.v2.font.sdf.SdfGlyphAtlas; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.client.gui.IConfigScreenFactory; +import org.jetbrains.annotations.ApiStatus; + +import java.awt.Font; +import java.util.List; + +@Mod(value = AnvilLibFont.MOD_ID, dist = Dist.CLIENT) +public class AnvilLibFont { + public static final String MAIN_ID = "anvillib"; + public static final String MOD_ID = "anvillib_font"; + public static final AnvilLibFontConfig CONFIG = new AnvilLibFontConfig(); + + @ApiStatus.Internal + public AnvilLibFont(ModContainer container) { + AnvilLibFontConfig.AnvilLibFontConfigManager.readConfig(AnvilLibFont.CONFIG); + container.registerExtensionPoint(IConfigScreenFactory.class, FontConfigScreen::new); + // Start building the SDF atlas for the base font and all common style + // variants on background threads at mod init time. This avoids render- + // thread blocking (visible lag) when bold/italic text is first drawn. + Font base = getSelectFont(); + SdfGlyphAtlas.getOrCreate(base); + for (int style : List.of(Font.BOLD, Font.ITALIC, Font.BOLD | Font.ITALIC)) { + SdfGlyphAtlas.getOrCreate(base.deriveFont(style)); + } + } + + public static Font getSelectFont() { + Font font = FontManager.INSTANCE.getFont(AnvilLibFont.CONFIG.getFont()); + // Eagerly start building the SDF atlas on a background thread so it is + // ready (or nearly ready) by the time the first text is rendered. + SdfGlyphAtlas.getOrCreate(font); + return font; + } + + public static ResourceLocation of(String path) { + return ResourceLocation.fromNamespaceAndPath(AnvilLibFont.MOD_ID, path); + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/AnvilLibFontConfig.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/AnvilLibFontConfig.java new file mode 100644 index 00000000..1ffa027f --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/AnvilLibFontConfig.java @@ -0,0 +1,90 @@ +package dev.anvilcraft.lib.v2.font; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.annotations.SerializedName; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.neoforged.fml.loading.FMLPaths; +import org.jetbrains.annotations.ApiStatus; +import javax.annotation.Nullable; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.nio.file.Path; +import java.util.Objects; + +public class AnvilLibFontConfig { + @SerializedName("_font_family_comment") + private final String fontFamilyComment = "Select a font family"; + @Getter + @SerializedName("font_family") + private String fontFamily = FontManager.INSTANCE.getDefaultFont().getFamily(); + @SerializedName("_font_comment") + private final String fontComment = "Select a font"; + @Getter + @SerializedName("font") + private String font = FontManager.INSTANCE.getDefaultFontFamily(); + + void setValue(AnvilLibFontConfig config) { + this.fontFamily = config.fontFamily; + this.font = config.font; + } + + public void setFontFamily(String fontFamily) { + this.fontFamily = fontFamily; + AnvilLibFontConfigManager.saveConfig(this); + } + + public void setFont(String font) { + this.font = font; + AnvilLibFontConfigManager.saveConfig(this); + } + + @ApiStatus.Internal + @Slf4j + public static class AnvilLibFontConfigManager { + public static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create(); + public static @Nullable Path configPath = null; + + public static void readConfig(AnvilLibFontConfig config) { + Path path = AnvilLibFontConfigManager.getConfigPath(); + File file = path.toFile(); + if (!file.exists()) { + AnvilLibFontConfigManager.saveConfig(config); + return; + } + try (FileReader fileReader = new FileReader(file)) { + AnvilLibFontConfig fontConfig = GSON.fromJson(fileReader, AnvilLibFontConfig.class); + config.setValue(fontConfig); + } catch (Exception e) { + log.error(e.getLocalizedMessage(), e); + } + } + + public static void saveConfig(AnvilLibFontConfig config) { + Path path = AnvilLibFontConfigManager.getConfigPath(); + File file = path.toFile(); + if (!file.exists()) { + //noinspection ResultOfMethodCallIgnored + file.getParentFile().mkdirs(); + } + try (FileWriter fileWriter = new FileWriter(file)) { + GSON.toJson(config, fileWriter); + } catch (Exception e) { + log.error(e.getLocalizedMessage(), e); + } + } + + public static Path getConfigPath() { + return Objects.requireNonNullElseGet( + AnvilLibFontConfigManager.configPath, + () -> AnvilLibFontConfigManager.configPath = FMLPaths.GAMEDIR.get() + .resolve("config") + .resolve(AnvilLibFont.MAIN_ID) + .resolve("%s-client.json".formatted(AnvilLibFont.MOD_ID)) + ); + } + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/FontManager.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/FontManager.java new file mode 100644 index 00000000..fd126517 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/FontManager.java @@ -0,0 +1,140 @@ +package dev.anvilcraft.lib.v2.font; + +import com.google.common.annotations.Beta; +import com.google.common.collect.Multimap; +import com.google.common.collect.MultimapBuilder; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import javax.annotation.Nullable; + +import java.awt.Font; +import java.awt.GraphicsEnvironment; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import javax.swing.UIManager; + +@Beta +@Slf4j +@Getter +public class FontManager { + private static final List DEFAULT_FONTS = List.of( + "HarmonyOS Sans SC", + "Microsoft YaHei", + "微软雅黑", + "PingFang", + "苹方", + "Cantarell", + "Noto Sans" + ); + public static final FontManager INSTANCE = new FontManager(); + private final Map fontByName = new HashMap<>(); + private final Map familyByFont = new HashMap<>(); + private final Multimap fontMultimap; + private final Font defaultFont; + + private FontManager() { + GraphicsEnvironment graphicsEnvironment = GraphicsEnvironment.getLocalGraphicsEnvironment(); + Font[] allFonts = graphicsEnvironment.getAllFonts(); + for (Font font : allFonts) { + fontByName.put(font.getFontName(), font); + } + this.fontMultimap = FontTrieNode.process(this.fontByName.values()); + log.info("fontMultimap: {}", this.fontMultimap); + this.fontMultimap.forEach((k, v) -> this.familyByFont.put(v, k)); + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception e) { + log.error(e.getLocalizedMessage(), e); + } + Font defaultFont = null; + for (String font : FontManager.DEFAULT_FONTS) { + if (defaultFont != null) break; + defaultFont = this.getFont(font); + } + if (defaultFont == null) { + defaultFont = UIManager.getFont("Label.font"); + } + this.defaultFont = Objects.requireNonNullElseGet( + defaultFont, + () -> this.fontByName.values().stream().findFirst().orElseThrow(() -> new RuntimeException("No fonts found")) + ); + } + + public Collection getFamilyNames() { + return this.fontMultimap.keySet(); + } + + public String getFamilyName(Font font) { + return this.familyByFont.getOrDefault(font, font.getFontName()); + } + + public String getDefaultFontFamily() { + return this.getFamilyName(this.getDefaultFont()); + } + + public Collection getFamilyFontNames(String familyName) { + if (!this.fontMultimap.containsKey(familyName)) return Set.of(); + return this.fontMultimap.get(familyName).stream().map(Font::getFontName).toList(); + } + + public Font getFont(@Nullable String name) { + return name == null || !this.fontByName.containsKey(name) ? this.getDefaultFont() : this.fontByName.get(name); + } + + static class FontTrieNode { + final Map children = new TreeMap<>(); + @Nullable Font value; + + void append(String key, Font font) { + if (key.isEmpty()) return; + if (key.length() == 1) { + this.value = font; + return; + } + char prefix = key.charAt(0); + String suffix = key.substring(1); + this.children.computeIfAbsent(prefix, ignored -> new FontTrieNode()).append(suffix, font); + } + + void subFont(Set set) { + if (this.value != null) { + set.add(this.value); + } + this.children.forEach((prefixChar, child) -> child.subFont(set)); + } + + void result(String prefix, Multimap result) { + if (this.value != null) { + Set set = new TreeSet<>(Comparator.comparing(Font::getFontName)); + this.subFont(set); + result.putAll(this.value.getFontName(), set); + return; + } + // Only group at word boundaries: prefix ending with space & multiple descendants + if (prefix.endsWith(" ")) { + Set set = new TreeSet<>(Comparator.comparing(Font::getFontName)); + this.subFont(set); + if (set.size() > 1) { + result.putAll(prefix.stripTrailing(), set); + return; + } + } + this.children.forEach((prefixChar, child) -> child.result(prefix + prefixChar, result)); + } + + static Multimap process(Collection fonts) { + FontTrieNode root = new FontTrieNode(); + fonts.forEach(font -> root.append(font.getFontName(), font)); + Multimap result = MultimapBuilder.hashKeys().hashSetValues().build(); + root.result("", result); + return result; + } + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/data/AnvilLibFontData.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/data/AnvilLibFontData.java new file mode 100644 index 00000000..ea74a1d4 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/data/AnvilLibFontData.java @@ -0,0 +1,45 @@ +package dev.anvilcraft.lib.v2.font.data; + +import dev.anvilcraft.lib.v2.font.AnvilLibFont; +import net.minecraft.Util; +import net.minecraft.data.DataGenerator; +import net.minecraft.data.PackOutput; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.common.data.LanguageProvider; +import net.neoforged.neoforge.data.event.GatherDataEvent; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +@EventBusSubscriber(modid = AnvilLibFont.MOD_ID) +public class AnvilLibFontData { + @SubscribeEvent + public static void onData(GatherDataEvent event) { + if (!event.includeClient()) { + return; + } + DataGenerator generator = event.getGenerator(); + PackOutput packOutput = generator.getPackOutput(); + event.addProvider(new AnvilLibFontLanguageProvider(packOutput, AnvilLibFont.MOD_ID, "en_us")); + } + + public static class AnvilLibFontLanguageProvider extends LanguageProvider { + public AnvilLibFontLanguageProvider(PackOutput output, String modid, String locale) { + super(output, modid, locale); + } + + @Override + protected void addTranslations() { + this.addDesc("screen", "config", "Font Config Screen"); + this.addDesc("screen", "config.family", "Font Family"); + this.addDesc("screen", "config.font", "Font"); + this.addDesc("screen", "config.test", "Font Test"); + this.addDesc("narration", "dropdown.expanded", "Expanded"); + this.addDesc("narration", "dropdown.collapsed", "Collapsed"); + } + + protected void addDesc(String prefix, String key, String value) { + this.add(Util.makeDescriptionId(prefix, AnvilLibFont.of(key)), value); + } + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/data/package-info.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/data/package-info.java new file mode 100644 index 00000000..a5a28bf2 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/data/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.font.data; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/extension/GuiGraphicsExtractorExtension.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/extension/GuiGraphicsExtractorExtension.java new file mode 100644 index 00000000..863d4a4e --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/extension/GuiGraphicsExtractorExtension.java @@ -0,0 +1,84 @@ +package dev.anvilcraft.lib.v2.font.extension; + +import com.google.common.annotations.Beta; +import dev.anvilcraft.lib.v2.font.sdf.SdfTextRenderer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.util.FastColor; +import net.minecraft.util.FormattedCharSequence; +import javax.annotation.Nullable; + +import java.awt.Font; + +@Beta +public interface GuiGraphicsExtractorExtension { + default GuiGraphics self() { + return (GuiGraphics) this; + } + + default SdfTextRenderer anvillib$textRenderer() { + throw new AssertionError("Not implemented!"); + } + + default void anvillib$text(Font font, @Nullable String str, int x, int y, int color) { + this.anvillib$text(font, str, x, y, color, false); + } + + default void anvillib$text(Font font, @Nullable String str, int x, int y, int color, boolean dropShadow) { + this.anvillib$textRenderer().drawString(this.self(), font, str, x, y, color, dropShadow); + } + + default void anvillib$text(Font font, FormattedCharSequence str, int x, int y, int color) { + this.anvillib$text(font, str, x, y, color, false); + } + + default void anvillib$text(Font font, FormattedCharSequence str, int x, int y, int color, boolean dropShadow) { + this.anvillib$textRenderer().drawFormatted(this.self(), font, str, x, y, color, dropShadow); + } + + default void anvillib$text(Font font, Component str, int x, int y, int color) { + this.anvillib$text(font, str, x, y, color, false); + } + + default void anvillib$text(Font font, Component str, int x, int y, int color, boolean dropShadow) { + this.anvillib$textRenderer().drawComponent(this.self(), font, str, x, y, color, dropShadow); + } + + default void anvillib$centeredText(Font font, String str, int x, int y, int color) { + this.anvillib$centeredText(font, Component.literal(str), x, y, color); + } + + default void anvillib$centeredText(Font font, Component text, int x, int y, int color) { + this.anvillib$textRenderer().drawCentered(this.self(), font, text, x, y, color); + } + + default void anvillib$centeredText(Font font, FormattedCharSequence text, int x, int y, int color) { + this.anvillib$textRenderer().drawCentered(this.self(), font, text, x, y, color); + } + + default void anvillib$textWithWordWrap(Font font, FormattedText string, int x, int y, int width, int col) { + this.anvillib$textWithWordWrap(font, string, x, y, width, col, false); + } + + default void anvillib$textWithWordWrap(Font font, FormattedText string, int x, int y, int width, int col, boolean dropShadow) { + this.anvillib$textRenderer().drawWrapped(this.self(), font, string, x, y, width, col, dropShadow); + } + + default void anvillib$textWithBackdrop(Font font, Component str, int textX, int textY, int textWidth, int textColor) { + GuiGraphics graphics = this.self(); + int backgroundColor = Minecraft.getInstance().options.getBackgroundColor(0.0F); + if (backgroundColor != 0) { + int padding = 2; + graphics.fill( + textX - padding, + textY - padding, + textX + textWidth + padding, + textY + 9 + padding, + FastColor.ARGB32.multiply(backgroundColor, textColor) + ); + } + this.anvillib$text(font, str, textX, textY, textColor, true); + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/extension/package-info.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/extension/package-info.java new file mode 100644 index 00000000..b726412a --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/extension/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.font.extension; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/mixin/GuiGraphicsExtractorMixin.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/mixin/GuiGraphicsExtractorMixin.java new file mode 100644 index 00000000..07dbf475 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/mixin/GuiGraphicsExtractorMixin.java @@ -0,0 +1,20 @@ +package dev.anvilcraft.lib.v2.font.mixin; + +import dev.anvilcraft.lib.v2.font.extension.GuiGraphicsExtractorExtension; +import dev.anvilcraft.lib.v2.font.sdf.SdfTextRenderer; +import net.minecraft.client.gui.GuiGraphics; +import org.jetbrains.annotations.ApiStatus; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +@ApiStatus.Internal +@Mixin(GuiGraphics.class) +public abstract class GuiGraphicsExtractorMixin implements GuiGraphicsExtractorExtension { + @Unique + SdfTextRenderer anvillib$textRenderer = new SdfTextRenderer(); + + public SdfTextRenderer anvillib$textRenderer() { + return this.anvillib$textRenderer; + } +} + diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/mixin/package-info.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/mixin/package-info.java new file mode 100644 index 00000000..ec5e1614 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/mixin/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.font.mixin; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/package-info.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/package-info.java new file mode 100644 index 00000000..3e4dc59f --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.font; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/FontConfigScreen.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/FontConfigScreen.java new file mode 100644 index 00000000..51b77ed4 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/FontConfigScreen.java @@ -0,0 +1,177 @@ +package dev.anvilcraft.lib.v2.font.screen; + +import dev.anvilcraft.lib.v2.font.AnvilLibFont; +import dev.anvilcraft.lib.v2.font.FontManager; +import dev.anvilcraft.lib.v2.font.screen.widget.Dropdown; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.neoforged.fml.ModContainer; +import org.jetbrains.annotations.ApiStatus; +import javax.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +@ApiStatus.Internal +public class FontConfigScreen extends Screen { + protected final Screen lastScreen; + private @Nullable Dropdown.Shielding shielding; + @SuppressWarnings("FieldCanBeLocal") + private @Nullable Dropdown familyDropdown; + private @Nullable Dropdown fontDropdown; + private @Nullable Button testBtn; + private Component selectedFamilyText = Component.empty(); + private Component selectedFontText = Component.empty(); + private final Component familyComponent = Component.translatable("screen.anvillib_font.config.family"); + private final Component fontComponent = Component.translatable("screen.anvillib_font.config.font"); + + public FontConfigScreen(final ModContainer ignored, final Screen parent) { + super(Component.translatable("screen.anvillib_font.config")); + this.lastScreen = parent; + } + + @Override + protected void init() { + int familyComponentWidth = this.font.width(this.familyComponent); + int fontComponentWidth = this.font.width(this.fontComponent); + int labelWidth = Math.max(familyComponentWidth, fontComponentWidth); + int dropdownWidth = Math.clamp(this.width - 40, 180, 320); + int btnWidth = dropdownWidth; + int dropdownX = (this.width - dropdownWidth) / 2 + labelWidth + 10; + int btnX = (this.width - btnWidth) / 2; + dropdownWidth -= labelWidth + 10; + int btnY = this.height / 2 - 24; + int familyDropdownY = btnY + 28; + int fontDropdownY = familyDropdownY + 28; + + this.shielding = null; + + this.familyDropdown = new Dropdown(dropdownX, familyDropdownY, dropdownWidth, 20, this.width, this.height, this.familyComponent); + this.fontDropdown = new Dropdown(dropdownX, fontDropdownY, dropdownWidth, 20, this.width, this.height, this.fontComponent); + this.testBtn = Button + .builder( + Component.translatable("screen.anvillib_font.config.test"), + (button) -> this.minecraft.setScreen(new FontTestScreen(this)) + ) + .size(btnWidth, 20) + .pos(btnX, btnY) + .build(); + + this.familyDropdown.setOnShieldingAdd(shielding -> this.shielding = shielding); + this.familyDropdown.setOnShieldingRemove(() -> this.shielding = null); + this.familyDropdown.setShieldingGetter(() -> this.shielding); + + this.fontDropdown.setOnShieldingAdd(shielding -> this.shielding = shielding); + this.fontDropdown.setOnShieldingRemove(() -> this.shielding = null); + this.fontDropdown.setShieldingGetter(() -> this.shielding); + + List families = new ArrayList<>(FontManager.INSTANCE.getFamilyNames()); + families.sort(Comparator.comparing(String::toLowerCase)); + + List options = families.stream().map(name -> Dropdown.DropdownEntry.create(name, name)).toList(); + this.familyDropdown.setAllow(options); + + String configuredFamily = AnvilLibFont.CONFIG.getFontFamily(); + options.stream().filter(entry -> entry.id().equals(configuredFamily)).findFirst().ifPresent(this.familyDropdown::setValue); + + this.updateSelectedFamily(this.familyDropdown.getValue()); + this.familyDropdown.setOnValueChanged(entry -> { + if (entry == null) { + return; + } + AnvilLibFont.CONFIG.setFontFamily(entry.id()); + this.updateSelectedFamily(entry); + this.refreshFontOptions(entry.id(), null, true); + }); + + this.fontDropdown.setOnValueChanged(entry -> { + if (entry == null) { + return; + } + AnvilLibFont.CONFIG.setFont(entry.id()); + this.updateSelectedFont(entry); + }); + this.familyDropdown.setValue(AnvilLibFont.CONFIG.getFontFamily()); + + this.refreshFontOptions(this.familyDropdown.getValueId(), AnvilLibFont.CONFIG.getFont(), false); + + this.fontDropdown.setValue(AnvilLibFont.CONFIG.getFont()); + + this.addRenderableWidget(this.testBtn); + this.addRenderableWidget(this.fontDropdown); + this.addRenderableWidget(this.familyDropdown); + } + + @Override + public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { + super.render(guiGraphics, mouseX, mouseY, partialTick); + guiGraphics.drawCenteredString(this.font, this.title, this.width / 2, 24, 0xFFFFFFFF); + guiGraphics.drawCenteredString(this.font, this.selectedFamilyText, this.width / 2, this.height / 2 - 56, 0xFFFFFFFF); + if (fontDropdown != null) { + guiGraphics.anvillib$centeredText( + AnvilLibFont.getSelectFont(), + this.selectedFontText, + this.width / 2, + this.height / 2 - 44, + 0xFFFFFFFF + ); + } + int dropdownWidth = Math.clamp(this.width - 40, 180, 320); + int dropdownLabelX = (this.width - dropdownWidth) / 2; + int familyDropdownLabelY = this.height / 2 - 24 + this.font.lineHeight / 2 + 28; + int fontDropdownLabelY = familyDropdownLabelY + 28; + guiGraphics.drawString(this.font, this.familyComponent, dropdownLabelX, familyDropdownLabelY, 0xFFFFFFFF); + guiGraphics.drawString(this.font, this.fontComponent, dropdownLabelX, fontDropdownLabelY, 0xFFFFFFFF); + } + + private void updateSelectedFamily(@Nullable Dropdown.DropdownEntry entry) { + this.selectedFamilyText = entry == null + ? Component.literal("Current family: ") + : Component.literal("Current family: " + entry.id()); + } + + private void updateSelectedFont(@Nullable Dropdown.DropdownEntry entry) { + this.selectedFontText = entry == null + ? Component.literal("Current font: ") + : Component.literal("Current font: " + entry.id()); + } + + private void refreshFontOptions(@Nullable String family, @Nullable String preferredFont, boolean persistSelected) { + if (this.fontDropdown == null) { + return; + } + if (family == null || family.isBlank() || !FontManager.INSTANCE.getFamilyNames().contains(family)) { + this.fontDropdown.setAllow(List.of()); + this.updateSelectedFont(null); + return; + } + + List fontOptions = FontManager.INSTANCE.getFamilyFontNames(family) + .stream() + .sorted(Comparator.comparing(String::toLowerCase)) + .map(name -> Dropdown.DropdownEntry.create(name, name)) + .toList(); + + this.fontDropdown.setAllow(fontOptions); + + Dropdown.DropdownEntry selected = fontOptions.stream() + .filter(entry -> entry.id().equals(preferredFont)) + .findFirst() + .orElse(this.fontDropdown.getValue()); + + this.fontDropdown.setValue(selected); + this.updateSelectedFont(selected); + + if (persistSelected && selected != null) { + AnvilLibFont.CONFIG.setFont(selected.id()); + } + } + + @Override + public void onClose() { + this.minecraft.setScreen(this.lastScreen); + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/FontTestScreen.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/FontTestScreen.java new file mode 100644 index 00000000..4264a776 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/FontTestScreen.java @@ -0,0 +1,105 @@ +package dev.anvilcraft.lib.v2.font.screen; + +import dev.anvilcraft.lib.v2.font.AnvilLibFont; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public class FontTestScreen extends Screen { + protected final Screen lastScreen; + + protected FontTestScreen(final Screen parent) { + super(Component.literal("Font Test")); + this.lastScreen = parent; + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float a) { + super.render(graphics, mouseX, mouseY, a); + int offsetX = this.width / 2; + int offsetY = (this.height - this.font.lineHeight * 9) / 2; + graphics.anvillib$centeredText( + AnvilLibFont.getSelectFont(), + Component.literal("正在准备Windows").withStyle(ChatFormatting.RED), + offsetX, + offsetY, + 0xFFFFFFFF + ); + graphics.anvillib$centeredText( + AnvilLibFont.getSelectFont(), + Component.literal("请不要关闭你的计算机").withStyle(Style.EMPTY.withColor(ChatFormatting.GREEN).withStrikethrough(true)), + offsetX, + offsetY + this.font.lineHeight, + 0xFFFFFFFF + ); + graphics.anvillib$centeredText( + AnvilLibFont.getSelectFont(), + Component.literal("中国传播家文化的主题餐厅").withStyle(Style.EMPTY.withBold(true)), + offsetX, + offsetY + this.font.lineHeight * 2, + 0xFFFFFFFF + ); + graphics.anvillib$centeredText( + AnvilLibFont.getSelectFont(), + Component.literal("家是本 家是本心灵家港,幸福味道记忆处").withStyle(Style.EMPTY.withItalic(true)), + offsetX, + offsetY + this.font.lineHeight * 3, + 0xFFFFFFFF + ); + graphics.anvillib$centeredText( + AnvilLibFont.getSelectFont(), + Component.literal("一群人,一辈子,干好传播家是本文化这件事") + .withStyle(Style.EMPTY.withBold(true).withColor(ChatFormatting.DARK_PURPLE)), + offsetX, + offsetY + this.font.lineHeight * 4, + 0xFFFFFFFF + ); + graphics.anvillib$centeredText( + AnvilLibFont.getSelectFont(), + Component.literal("The quick brown fox jumped over the lazy dog.") + .withStyle(Style.EMPTY.withItalic(true).withColor(ChatFormatting.DARK_PURPLE).withUnderlined(true)), + offsetX, + offsetY + this.font.lineHeight * 5, + 0xFFFFFFFF + ); + graphics.anvillib$centeredText( + AnvilLibFont.getSelectFont(), + Component.literal("隨手存取 AI,隨時使用最佳效能。善用 Windows 11 功能,進而保護並提升您的數碼生活。") + .withStyle(Style.EMPTY.withItalic(true).withBold(true)), + offsetX, + offsetY + this.font.lineHeight * 6, + 0xFFFFFFFF + ); + graphics.anvillib$centeredText( + AnvilLibFont.getSelectFont(), + Component.literal("1234567890/*-+!@#$%^&*()_+{}:\">?<[];\\,./'"), + offsetX, + offsetY + this.font.lineHeight * 7, + 0xFFFFFFFF + ); + graphics.anvillib$centeredText( + AnvilLibFont.getSelectFont(), + Component.literal("【】;‘,。、《超かぐや姫!》?、|"), + offsetX, + offsetY + this.font.lineHeight * 8, + 0xFFFFFFFF + ); + graphics.anvillib$centeredText( + AnvilLibFont.getSelectFont(), + Component.literal("混淆文字测试") + .withStyle(Style.EMPTY.withItalic(true).withBold(true).withObfuscated(true)), + offsetX, + offsetY + this.font.lineHeight * 9, + 0xFFFFFFFF + ); + } + + @Override + public void onClose() { + this.minecraft.setScreen(this.lastScreen); + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/package-info.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/package-info.java new file mode 100644 index 00000000..5bacb848 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.font.screen; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/widget/Dropdown.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/widget/Dropdown.java new file mode 100644 index 00000000..e6c4bfc1 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/widget/Dropdown.java @@ -0,0 +1,350 @@ +package dev.anvilcraft.lib.v2.font.screen.widget; + +import dev.anvilcraft.lib.v2.font.AnvilLibFont; +import lombok.Setter; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarratedElementType; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.ApiStatus; +import javax.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; + +@ApiStatus.Internal +public class Dropdown extends AbstractWidget { + private final List allows = new ArrayList<>(); + private final Minecraft minecraft = Minecraft.getInstance(); + private @Nullable DropdownEntry value = null; + private boolean expanded = false; + @Setter + private Consumer onValueChanged = ignored -> { + }; + @Setter + private Consumer onShieldingAdd = ignored -> { + }; + @Setter + private Runnable onShieldingRemove = () -> { + }; + @Setter + private Supplier shieldingGetter = () -> null; + private final int screenWidth; + private final int screenHeight; + private int scrollOffset = 0; + private boolean draggingScrollbar; + + public Dropdown(int x, int y, int width, int height, int screenWidth, int screenHeight, Component message) { + super(x, y, width, height, message); + this.screenWidth = screenWidth; + this.screenHeight = screenHeight; + } + + public void setAllow(List allows) { + this.allows.clear(); + this.allows.addAll(allows); + this.scrollOffset = 0; + if (this.allows.contains(this.value)) { + return; + } + this.value = this.allows.isEmpty() ? null : this.allows.getFirst(); + } + + public void setValue(@Nullable DropdownEntry value) { + if (value != null && !this.allows.contains(value)) { + return; + } + this.value = value; + } + + + public void setValue(@Nullable String value) { + for (DropdownEntry allow : this.allows) { + if (Objects.equals(allow.id, value)) { + this.setValue(allow); + return; + } + } + } + + public @Nullable DropdownEntry getValue() { + return this.value; + } + + public @Nullable String getValueId() { + return this.value == null ? null : this.value.id; + } + + @Override + protected void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { + int hoveredIndex = this.getEntryIndexAt(mouseX, mouseY); + + int x1 = this.getX(); + int y1 = this.getY(); + int x2 = x1 + this.getWidth(); + int y2 = y1 + this.getHeight(); + + int borderColor = this.isHoveredOrFocused() ? 0xFFE0E0E0 : 0xFF909090; + int bgColor = this.active ? 0xCC202020 : 0xCC151515; + guiGraphics.fill(x1, y1, x2, y2, borderColor); + guiGraphics.fill(x1 + 1, y1 + 1, x2 - 1, y2 - 1, bgColor); + + Component valueText = this.value == null ? Component.empty() : this.value.desc; + guiGraphics.drawCenteredString( + this.minecraft.font, + valueText, + x1 + (this.getWidth() / 2), + y1 + (this.getHeight() - 8) / 2, + 0xFFFFFFFF + ); + guiGraphics.drawCenteredString( + this.minecraft.font, + Component.literal(this.expanded ? "▲" : "▼"), + x2 - 8, + y1 + (this.getHeight() - 8) / 2, + 0xFFFFFFFF + ); + + if (!this.expanded) { + return; + } + + int startHeight = this.getY() + this.getHeight(); + int listAreaHeight = this.calcMaxHeight(); + int maxY = startHeight + listAreaHeight; + boolean needsScrollbar = this.maxScrollOffset() > 0; + int scrollbarWidth = needsScrollbar ? 5 : 0; + + guiGraphics.enableScissor(x1, startHeight, Math.min(this.screenWidth, x2), maxY); + for (int i = 0; i < this.allows.size(); i++) { + int rowTop = startHeight + (i - this.scrollOffset) * this.getHeight(); + int rowBottom = rowTop + this.getHeight(); + + if (rowBottom <= startHeight || rowTop >= maxY) continue; + + int rowBg = i == hoveredIndex ? 0xCC3F3F3F : 0xCC1F1F1F; + guiGraphics.fill(x1, rowTop, x2, rowBottom, borderColor); + guiGraphics.fill(x1 + 1, rowTop + 1, x2 - 1, rowBottom - 1, rowBg); + + DropdownEntry entry = this.allows.get(i); + guiGraphics.drawCenteredString( + this.minecraft.font, + entry.desc, + x1 + (this.getWidth() - scrollbarWidth) / 2, + rowTop + (this.getHeight() - 8) / 2, + 0xFFFFFFFF + ); + } + guiGraphics.disableScissor(); + + if (needsScrollbar) { + int visibleRows = this.visibleRowCount(); + int totalRows = this.allows.size(); + int thumbHeight = Math.max(10, listAreaHeight * visibleRows / totalRows); + int maxScroll = this.maxScrollOffset(); + int thumbTop = maxScroll == 0 ? 0 : (listAreaHeight - thumbHeight) * this.scrollOffset / maxScroll; + + // track + guiGraphics.fill(x2 - scrollbarWidth, startHeight, x2, maxY, 0xFF303030); + // thumb + guiGraphics.fill( + x2 - scrollbarWidth + 1, + startHeight + thumbTop, + x2 - 1, + startHeight + thumbTop + thumbHeight, + 0xFF909090 + ); + } + } + + public int calcMaxHeight() { + int startHeight = this.getY() + this.getHeight(); + int maxHeight = Math.clamp((long) this.allows.size() * this.getHeight(), 0, this.screenHeight - startHeight - 10); + // Round down to a multiple of the row height so the last visible row + // fills the entire area without trailing blank space. + return (maxHeight / this.getHeight()) * this.getHeight(); + } + + private int visibleRowCount() { + return this.calcMaxHeight() / this.getHeight(); + } + + private int maxScrollOffset() { + return Math.max(0, this.allows.size() - this.visibleRowCount()); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + if (!this.expanded || scrollY == 0 || !this.isMouseOver(mouseX, mouseY)) { + return false; + } + this.scrollOffset = Math.clamp(this.scrollOffset - (int) Math.signum(scrollY), 0, this.maxScrollOffset()); + return true; + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + return this.handlePrimaryClick(mouseX, mouseY, button); + } + + private boolean handlePrimaryClick(double mouseX, double mouseY, int button) { + if (!this.active || !this.visible || button != 0) { + return false; + } + + if (this.isPointInMainBox(mouseX, mouseY)) { + this.expanded = !this.expanded; + this.shielding(); + return true; + } + + if (!this.expanded) { + return false; + } + + // Click on scrollbar: start dragging + if (this.needsScrollbar() && this.isOnScrollbar(mouseX, mouseY)) { + this.draggingScrollbar = true; + this.scrollToMouse(mouseY); + return true; + } + + int index = this.getEntryIndexAt(mouseX, mouseY); + if (index < 0) { + this.expanded = false; + this.removeShielding(); + return false; + } + + DropdownEntry selected = this.allows.get(index); + if (!selected.equals(this.value)) { + this.value = selected; + this.onValueChanged.accept(this.value); + } + this.expanded = false; + this.removeShielding(); + return true; + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) { + if (this.draggingScrollbar) { + this.scrollToMouse(mouseY); + return true; + } + return false; + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + this.draggingScrollbar = false; + return false; + } + + private boolean needsScrollbar() { + return this.maxScrollOffset() > 0; + } + + private boolean isOnScrollbar(double mouseX, double mouseY) { + int x2 = this.getX() + this.getWidth(); + int listTop = this.getY() + this.getHeight(); + int listBottom = listTop + this.calcMaxHeight(); + return mouseX >= x2 - 5 && mouseX < x2 && mouseY >= listTop && mouseY < listBottom; + } + + private void scrollToMouse(double mouseY) { + int listTop = this.getY() + this.getHeight(); + int listHeight = this.calcMaxHeight(); + int thumbHeight = Math.max(10, listHeight * this.visibleRowCount() / this.allows.size()); + int trackHeight = listHeight - thumbHeight; + int maxScroll = this.maxScrollOffset(); + if (trackHeight <= 0 || maxScroll <= 0) return; + int relativeY = (int) Math.clamp(mouseY - listTop - thumbHeight / 2.0, 0, trackHeight); + this.scrollOffset = relativeY * maxScroll / trackHeight; + } + + public boolean isMouseOver(double mouseX, double mouseY) { + Shielding shielding = this.shieldingGetter.get(); + if (!this.visible || (shielding != null && shielding.isShielding(mouseX, mouseY, this))) { + return false; + } + return this.isPointInMainBox(mouseX, mouseY) || this.isPointInDropdownList(mouseX, mouseY); + } + + private boolean isPointInMainBox(double mouseX, double mouseY) { + return mouseX >= this.getX() && mouseX < this.getX() + this.getWidth() && mouseY >= this.getY() && mouseY < this.getY() + this.getHeight(); + } + + private boolean isPointInDropdownList(double mouseX, double mouseY) { + if (!this.expanded || this.allows.isEmpty()) { + return false; + } + int listTop = this.getY() + this.getHeight(); + int listBottom = listTop + this.calcMaxHeight(); + return mouseX >= this.getX() && mouseX < this.getX() + this.getWidth() && mouseY >= listTop && mouseY < listBottom; + } + + private int getEntryIndexAt(double mouseX, double mouseY) { + if (!this.isPointInDropdownList(mouseX, mouseY)) { + return -1; + } + int listTop = this.getY() + this.getHeight(); + int index = (int) ((mouseY - listTop) / this.getHeight()) + this.scrollOffset; + return index >= 0 && index < this.allows.size() ? index : -1; + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { + narrationElementOutput.add(NarratedElementType.TITLE, this.getMessage()); + narrationElementOutput.add(NarratedElementType.USAGE, this.value == null ? Component.empty() : this.value.desc); + if (this.expanded) { + narrationElementOutput.add(NarratedElementType.USAGE, Component.translatable("narration.anvillib_font.dropdown.expanded")); + } else { + narrationElementOutput.add(NarratedElementType.USAGE, Component.translatable("narration.anvillib_font.dropdown.collapsed")); + } + } + + protected void shielding() { + if (this.expanded) { + this.createShielding( + this.getX(), + this.getY() + this.getHeight(), + this.getX() + this.getWidth(), + this.getY() + this.getHeight() + this.calcMaxHeight() + ); + return; + } + this.removeShielding(); + } + + protected void createShielding(int x1, int y1, int x2, int y2) { + this.onShieldingAdd.accept(new Shielding(Math.min(x1, x2), Math.min(y1, y2), Math.max(x1, x2), Math.max(y1, y2), this)); + } + + protected void removeShielding() { + this.onShieldingRemove.run(); + } + + public record DropdownEntry(Component desc, String id) { + public static DropdownEntry create(String id) { + return new DropdownEntry(Component.translatable(Util.makeDescriptionId("dropdown", AnvilLibFont.of(id))), id); + } + + public static DropdownEntry create(String desc, String id) { + return new DropdownEntry(Component.literal(desc), id); + } + } + + public record Shielding(double x1, double y1, double x2, double y2, Dropdown dropdown) { + public boolean isShielding(double x, double y, Dropdown dropdown) { + if (Objects.equals(this.dropdown(), dropdown)) return false; + return x >= this.x1() && x < this.x2() && y >= this.y1() && y < this.y2(); + } + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/widget/package-info.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/widget/package-info.java new file mode 100644 index 00000000..3ee0438d --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/screen/widget/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.font.screen.widget; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfAtlasTexture.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfAtlasTexture.java new file mode 100644 index 00000000..1bcf728b --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfAtlasTexture.java @@ -0,0 +1,120 @@ +package dev.anvilcraft.lib.v2.font.sdf; + +import com.mojang.blaze3d.platform.NativeImage; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.ApiStatus; + +import java.awt.image.BufferedImage; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Uploads SDF glyph atlas pages to GPU textures with LINEAR filtering. + */ +@ApiStatus.Internal +public final class SdfAtlasTexture { + private static final Map CACHE = new ConcurrentHashMap<>(); + + private SdfAtlasTexture() { + } + + /** + * Upload a single atlas page to the GPU, returning its texture location. + */ + public static ResourceLocation uploadPage(SdfGlyphAtlas atlas, int pageIndex) { + SdfGlyphPage page = atlas.page(pageIndex); + String key = atlas.key() + ".p" + pageIndex; + ResourceLocation id = ResourceLocation.fromNamespaceAndPath("anvillib_font", "dynamic/sdf_atlas/" + sanitize(key)); + // Synchronize on page to read the version and image data atomically + // with respect to the glyph creation thread, which also synchronizes + // on page for placeGlyph / fillPaddingForCell. + NativeImage nativeImage; + int version; + synchronized (page) { + version = page.version.get(); + nativeImage = toNativeImage(page.image); + page.dirty = false; + } + + PageEntry entry = CACHE.get(key); + if (entry != null && entry.version == version) return entry.id; + + SdfTexture texture = new SdfTexture(nativeImage); + Minecraft.getInstance().getTextureManager().register(id, texture); + + if (entry != null) entry.texture.close(); + CACHE.put(key, new PageEntry(id, texture, version)); + page.textureId = id; + return id; + } + + /** + * Upload all dirty pages of an atlas. Called before rendering. + */ + public static void ensureUploaded(SdfGlyphAtlas atlas) { + for (int i = 0; i < atlas.pageCount(); i++) { + SdfGlyphPage page = atlas.page(i); + String key = atlas.key() + ".p" + i; + PageEntry entry = CACHE.get(key); + if (page.textureId == null + || entry == null + || entry.version != page.version.get() + || page.dirty) { + uploadPage(atlas, i); + } + } + } + + /** + * A minimal single-channel texture with LINEAR+CLAMP filtering for SDF sampling. + */ + static final class SdfTexture extends DynamicTexture { + SdfTexture(NativeImage image) { + super(image); + // Linear filtering (no mipmap) for smooth SDF sampling. + this.setFilter(true, false); + } + } + + static NativeImage toNativeImage(BufferedImage image) { + NativeImage ni = new NativeImage(NativeImage.Format.RGBA, image.getWidth(), image.getHeight(), false); + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + int gray = image.getRGB(x, y) & 0xFF; + ni.setPixelRGBA(x, y, (0xFF << 24) | (gray << 16) | (gray << 8) | gray); + } + } + return ni; + } + + private static String sanitize(String key) { + StringBuilder sb = new StringBuilder(key.length()); + for (int i = 0; i < key.length(); i++) { + char c = key.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '/' || c == '_' || c == '-' || c == '.') { + sb.append(c); + } else if (c >= 'A' && c <= 'Z') { + sb.append((char) (c + ('a' - 'A'))); + } else if (c == '#') { + sb.append('_'); + } else { + sb.append('_'); + } + } + return sb.toString(); + } + + private static final class PageEntry { + final ResourceLocation id; + final SdfTexture texture; + final int version; + + PageEntry(ResourceLocation id, SdfTexture texture, int version) { + this.id = id; + this.texture = texture; + this.version = version; + } + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphAtlas.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphAtlas.java new file mode 100644 index 00000000..449cce1b --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphAtlas.java @@ -0,0 +1,398 @@ +package dev.anvilcraft.lib.v2.font.sdf; + +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.ApiStatus; +import javax.annotation.Nullable; + +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +/** + * Multi-page on-demand SDF glyph atlas. + *

+ * Glyphs are packed into fixed-size 1024×1024 pages. ASCII 32-126 is + * pre-warmed; all other codepoints are rendered lazily on first use. + */ +@ApiStatus.Internal +@Slf4j +public final class SdfGlyphAtlas { + static final int PAGE_SIZE = 1024; + private static final int FIRST_CHAR = 32; + private static final int LAST_CHAR = 126; + /** + * AWT system fonts report size 1; derive to a fixed rendering size for the atlas. + */ + private static final int ATLAS_FONT_SIZE = 64; + + private static final Map> CACHE = new ConcurrentHashMap<>(); + + /** + * Per-task virtual-thread executor for background glyph creation. + * Each glyph (or batch) gets its own virtual thread; the existing + * {@code synchronized} blocks on the atlas and page objects already + * provide the necessary mutual exclusion. + */ + private static final ExecutorService GLYPH_EXECUTOR = Executors.newThreadPerTaskExecutor( + Thread.ofVirtual().name("AnvilLib-SDF-Glyph-", 0).factory() + ); + + private final String key; + private final Font font; + final int cellSize; + final int padding; + final int paddedCellSize; + final float sdfRadius; + private final int awtAscent; + private final int awtHeight; + private final FontMetrics fontMetrics; + + private final List pages = new ArrayList<>(); + private final Map glyphMap = new ConcurrentHashMap<>(); + private final Set pendingGlyphs = ConcurrentHashMap.newKeySet(); + + private SdfGlyphAtlas(String key, Font font) { + this.key = key; + this.font = font; + log.info("SdfGlyphAtlas building for key={}, thread={}", key, Thread.currentThread().getName()); + + // Capture font metrics first — needed by cellSize calculation + BufferedImage tmp = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = tmp.createGraphics(); + try { + g.setFont(this.font); + this.fontMetrics = g.getFontMetrics(); + this.awtAscent = this.fontMetrics.getAscent(); + this.awtHeight = this.fontMetrics.getHeight(); + } finally { + g.dispose(); + } + + // Cell must accommodate full ascent + descent above and below baseline, + // plus 2px margin on each side. Previously used font.getSize()+12, which + // could clip descenders or ascenders for fonts with large extents. + int awtDescent = this.fontMetrics.getDescent(); + this.cellSize = Math.max(24, this.awtAscent + awtDescent + 4); + this.sdfRadius = Math.max(12, font.getSize() * 0.25f); + this.padding = Math.max(4, this.cellSize / 6); + this.paddedCellSize = this.cellSize + 2 * this.padding; + log.info("SdfGlyphAtlas cellSize={} ascent={} descent={} key={}", this.cellSize, this.awtAscent, awtDescent, key); + + preWarmAscii(); + log.info("SdfGlyphAtlas ready for key={}, pages={}", key, this.pages.size()); + } + + // ── Public API ────────────────────────────────────────────── + + /** + * Start building the atlas on a background thread; returns the future. + */ + public static CompletableFuture getOrCreate(@Nullable Font font) { + Font resolved = resolveFont(font); + String key = resolved.getFontName(Locale.ENGLISH) + "." + resolved.getStyle() + "." + resolved.getSize(); + return CACHE.computeIfAbsent(key, ignored -> { + log.debug("Starting SDF atlas build for key={}", key); + return CompletableFuture.supplyAsync(() -> new SdfGlyphAtlas(key, resolved), GLYPH_EXECUTOR); + }); + } + + /** + * Return the atlas if fully built, or {@code null} if still constructing. + *

+ * If the previous build failed (future completed exceptionally), the + * failed future is removed and a fresh build is started so that a + * transient error does not permanently disable the atlas. + */ + public static @Nullable SdfGlyphAtlas getIfReady(@Nullable Font font) { + Font resolved = resolveFont(font); + String key = resolved.getFontName(Locale.ENGLISH) + "." + resolved.getStyle() + "." + resolved.getSize(); + CompletableFuture f = CACHE.get(key); + if (f != null && f.isDone()) { + try { + SdfGlyphAtlas atlas = f.get(); + return atlas; + } catch (Exception e) { + log.error("SDF atlas build failed for key={}, retrying", key, e); + CACHE.remove(key, f); + f = null; + } + } + if (f == null) { + log.info("No atlas future for key={}, starting build", key); + getOrCreate(font); + } + return null; + } + + private static Font resolveFont(@Nullable Font font) { + if (font == null) return new Font("Dialog", Font.PLAIN, ATLAS_FONT_SIZE); + if (font.getSize() < 4) return font.deriveFont((float) ATLAS_FONT_SIZE); + return font; + } + + public String key() { + return this.key; + } + + public Font font() { + return this.font; + } + + public int awtHeight() { + return this.awtHeight; + } + + public int awtAscent() { + return this.awtAscent; + } + + public int pageCount() { + return this.pages.size(); + } + + public SdfGlyphPage page(int index) { + return this.pages.get(index); + } + + /** + * Get the glyph entry for a codepoint. If the glyph is not yet in the + * atlas, requests async creation and returns {@code null}. The caller + * should handle the missing glyph gracefully (e.g. advance by a default + * width); the glyph will be available on the next call. + */ + public @Nullable GlyphEntry glyph(int codepoint, Consumer pageConsumer) { + GlyphEntry entry = this.glyphMap.get(codepoint); + if (entry != null) return entry; + // Trigger async creation on background thread + if (this.pendingGlyphs.add(codepoint)) { + GLYPH_EXECUTOR.submit(() -> createGlyphAsync(codepoint)); + } + return null; + } + + /** + * Background-thread entry point for glyph creation. + * Synchronized to ensure only one glyph is created at a time per atlas, + * avoiding races on page state (nextCol, nextRow, image pixels). + */ + private void createGlyphAsync(int codepoint) { + try { + synchronized (this) { + // Double-check: may have been created since we were enqueued + if (this.glyphMap.containsKey(codepoint)) return; + createGlyph(codepoint, SdfGlyphPage::updateHash); + } + // Invalidate cached layouts so they pick up the new glyph + SdfTextLayout.invalidateAtlas(this.key); + } catch (Exception e) { + log.error("Failed to create SDF glyph for codepoint {} (U+{})", codepoint, Integer.toHexString(codepoint).toUpperCase(), e); + } finally { + this.pendingGlyphs.remove(codepoint); + } + } + + public int measureText(String text) { + return measureText(text, 0, text.length()); + } + + /** + * Measure width of substring {@code text[start..end)} in atlas pixels. + */ + public int measureText(String text, int start, int end) { + int width = 0; + Set glyphPages = new HashSet<>(); + for (int i = start; i < end; ) { + int cp = text.codePointAt(i); + GlyphEntry g = glyph(cp, glyphPages::add); + width += g == null ? this.cellSize / 2 : g.advance; + i += Character.charCount(cp); + } + glyphPages.forEach(SdfGlyphPage::updateHash); + return width; + } + + /** + * Measure a single codepoint's advance in atlas pixels. + */ + public int measureCodepoint(int codepoint) { + GlyphEntry g = glyph(codepoint, SdfGlyphPage::updateHash); + return g == null ? this.cellSize / 2 : g.advance; + } + + // ── Glyph creation ────────────────────────────────────────── + + private GlyphEntry createGlyph(int codepoint, Consumer pageConsumer) { + BufferedImage mask = renderMask(codepoint); + + int pageIdx = findOrCreatePageIndex(); + SdfGlyphPage page = this.pages.get(pageIdx); + GlyphEntry entry = page.placeGlyph(this, mask); + entry = new GlyphEntry( + pageIdx, + entry.atlasX(), + entry.atlasY(), + entry.width(), + entry.height(), + this.fontMetrics.charWidth(codepoint) + ); + this.glyphMap.put(codepoint, entry); + page.fillPaddingForCell(this, entry); + page.dirty = true; + pageConsumer.accept(page); + return entry; + } + + private void preWarmAscii() { + Set glyphPages = new HashSet<>(); + for (int code = FIRST_CHAR; code <= LAST_CHAR; code++) { + createGlyph(code, glyphPages::add); + } + glyphPages.forEach(SdfGlyphPage::updateHash); + } + + private int findOrCreatePageIndex() { + for (int i = 0; i < this.pages.size(); i++) { + if (this.pages.get(i).hasSpace()) return i; + } + this.pages.add(new SdfGlyphPage(this.paddedCellSize)); + return this.pages.size() - 1; + } + + /** + * Render a single glyph as a white-on-transparent mask. + */ + private BufferedImage renderMask(int codepoint) { + String s = new String(Character.toChars(codepoint)); + BufferedImage mask = new BufferedImage(this.cellSize, this.cellSize, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = mask.createGraphics(); + try { + g.setFont(this.font); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g.setColor(new Color(0, 0, 0, 0)); + g.fillRect(0, 0, this.cellSize, this.cellSize); + g.setColor(Color.WHITE); + g.drawString(s, 2, this.awtAscent + 2); + } finally { + g.dispose(); + } + return mask; + } + + // ── Static SDF computation utilities ───────────────────────── + + static void blitSdfGlyph(BufferedImage mask, BufferedImage target, int tx, int ty, int w, float maxRadius) { + boolean[] inside = new boolean[w * w]; + for (int y = 0; y < w; y++) { + for (int x = 0; x < w; x++) { + inside[y * w + x] = isInside(mask, x, y); + } + } + + // Distance to nearest outside pixel (for inside pixels) + float[] distOutside = computeDistTo(inside, w, false); + // Distance to nearest inside pixel (for outside pixels) + float[] distInside = computeDistTo(inside, w, true); + + for (int y = 0; y < w; y++) { + for (int x = 0; x < w; x++) { + int i = y * w + x; + float signed = inside[i] ? distOutside[i] : -distInside[i]; + signed = Math.min(signed, maxRadius); + float normalized = 0.5f + (signed / (2.0f * maxRadius)); + int ch = Math.round(Math.max(0f, Math.min(1f, normalized)) * 255f); + target.setRGB(tx + x, ty + y, (ch << 24) | (ch << 16) | (ch << 8) | ch); + } + } + } + + /** + * Dead Reckoning EDT: distance from each pixel to nearest pixel where {@code inside == target}. + */ + private static float[] computeDistTo(boolean[] inside, int w, boolean target) { + int n = w * w, HUGE = w * 3; + int[] dx = new int[n], dy = new int[n]; + for (int i = 0; i < n; i++) { + dx[i] = (inside[i] == target) ? 0 : HUGE; + dy[i] = (inside[i] == target) ? 0 : HUGE; + } + // Pass 1 + for (int y = 0; y < w; y++) { + for (int x = 0; x < w; x++) { + int i = y * w + x; + if (y > 0) { + if (x > 0) tryUpdate(dx, dy, i, (y - 1) * w + (x - 1), x, y, x - 1, y - 1); + tryUpdate(dx, dy, i, (y - 1) * w + x, x, y, x, y - 1); + if (x < w - 1) tryUpdate(dx, dy, i, (y - 1) * w + (x + 1), x, y, x + 1, y - 1); + } + if (x > 0) tryUpdate(dx, dy, i, y * w + (x - 1), x, y, x - 1, y); + } + } + // Pass 2 + for (int y = w - 1; y >= 0; y--) { + for (int x = w - 1; x >= 0; x--) { + int i = y * w + x; + if (x < w - 1) tryUpdate(dx, dy, i, y * w + (x + 1), x, y, x + 1, y); + if (y < w - 1) { + if (x > 0) tryUpdate(dx, dy, i, (y + 1) * w + (x - 1), x, y, x - 1, y + 1); + tryUpdate(dx, dy, i, (y + 1) * w + x, x, y, x, y + 1); + if (x < w - 1) tryUpdate(dx, dy, i, (y + 1) * w + (x + 1), x, y, x + 1, y + 1); + } + } + } + float[] dist = new float[n]; + for (int i = 0; i < n; i++) { + dist[i] = (float) Math.sqrt(dx[i] * dx[i] + dy[i] * dy[i]); + } + return dist; + } + + private static void tryUpdate(int[] dx, int[] dy, int cur, int nbr, int cx, int cy, int nx, int ny) { + int ndx = dx[nbr] + (nx - cx), ndy = dy[nbr] + (ny - cy); + if (ndx * ndx + ndy * ndy < dx[cur] * dx[cur] + dy[cur] * dy[cur]) { + dx[cur] = ndx; + dy[cur] = ndy; + } + } + + private static boolean isInside(BufferedImage image, int x, int y) { + return ((image.getRGB(x, y) >>> 24) & 0xFF) > 16; + } + + static void fillPadding(BufferedImage img, int padX, int padY, int paddedCellSize, int cellSize, int padding) { + int ix0 = padX + padding, iy0 = padY + padding; + int ix1 = ix0 + cellSize - 1, iy1 = iy0 + cellSize - 1; + for (int y = padY; y < padY + paddedCellSize; y++) { + for (int x = padX; x < padX + paddedCellSize; x++) { + if (x >= ix0 && x <= ix1 && y >= iy0 && y <= iy1) continue; + img.setRGB(x, y, img.getRGB(Math.clamp(x, ix0, ix1), Math.clamp(y, iy0, iy1))); + } + } + } + + // ── Inner types ───────────────────────────────────────────── + + public record GlyphEntry(int pageIndex, int atlasX, int atlasY, int width, int height, int advance) { + public int endX() { + return this.atlasX + this.width; + } + + public int endY() { + return this.atlasY + this.height; + } + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphPage.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphPage.java new file mode 100644 index 00000000..3b6aaa19 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphPage.java @@ -0,0 +1,64 @@ +package dev.anvilcraft.lib.v2.font.sdf; + +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.ApiStatus; + +import java.awt.image.BufferedImage; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A single 1024×1024 atlas page holding packed glyphs. + */ +@ApiStatus.Internal +public final class SdfGlyphPage { + private static final int SIZE = SdfGlyphAtlas.PAGE_SIZE; + final BufferedImage image; + final AtomicInteger version = new AtomicInteger(); + final int cols, rows; + int nextCol, nextRow; + ResourceLocation textureId; + volatile boolean dirty = true; + + SdfGlyphPage(int paddedCellSize) { + this.cols = SIZE / paddedCellSize; + this.rows = SIZE / paddedCellSize; + this.image = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_BYTE_GRAY); + } + + boolean hasSpace() { + return nextRow < rows; + } + + synchronized SdfGlyphAtlas.GlyphEntry placeGlyph(SdfGlyphAtlas atlas, BufferedImage mask) { + int col = nextCol, row = nextRow; + int padX = col * atlas.paddedCellSize; + int padY = row * atlas.paddedCellSize; + int innerX = padX + atlas.padding; + int innerY = padY + atlas.padding; + SdfGlyphAtlas.blitSdfGlyph(mask, this.image, innerX, innerY, atlas.cellSize, atlas.sdfRadius); + nextCol++; + if (nextCol >= cols) { + nextCol = 0; + nextRow++; + } + dirty = true; + return new SdfGlyphAtlas.GlyphEntry(0, innerX, innerY, atlas.cellSize, atlas.cellSize, 0); + } + + synchronized void fillPaddingForCell(SdfGlyphAtlas atlas, SdfGlyphAtlas.GlyphEntry e) { + int col = (e.atlasX() - atlas.padding) / atlas.paddedCellSize; + int row = (e.atlasY() - atlas.padding) / atlas.paddedCellSize; + SdfGlyphAtlas.fillPadding( + this.image, + col * atlas.paddedCellSize, + row * atlas.paddedCellSize, + atlas.paddedCellSize, + atlas.cellSize, + atlas.padding + ); + } + + public void updateHash() { + this.version.incrementAndGet(); + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextLayout.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextLayout.java new file mode 100644 index 00000000..b0b6566b --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextLayout.java @@ -0,0 +1,154 @@ +package dev.anvilcraft.lib.v2.font.sdf; + +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.ApiStatus; +import javax.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * CPU-side glyph layout output for SDF text rendering, grouped by atlas page. + *

+ * Layouts are cached by {@code (atlasKey, text, scale)} to avoid redundant + * computation each frame. Quads are stored with positions relative to the + * layout origin; the renderer supplies the screen-space offset. + */ +@ApiStatus.Internal +public final class SdfTextLayout { + private static final int MAX_CACHE_SIZE = 1024; + private static final Map LAYOUT_CACHE = new ConcurrentHashMap<>(); + + private final List pages; + private final int width; + private final int height; + + private SdfTextLayout(List pages, int width, int height) { + this.pages = pages; + this.width = width; + this.height = height; + } + + /** + * Compute glyph layout for a string. The returned quads have positions + * relative to (0, 0); add {@code x}/{@code y} when rendering. + *

+ * Result is cached when all glyphs are available in the atlas. + */ + public static SdfTextLayout fromAtlas(SdfGlyphAtlas atlas, @Nullable String text, int x, int y, float scale) { + if (text == null || text.isEmpty()) + return EMPTY; + + int scaleInt = Math.round(scale * 1000f); + CacheKey key = new CacheKey(atlas.key(), text, scaleInt); + SdfTextLayout cached = LAYOUT_CACHE.get(key); + if (cached != null) return cached; + + SdfTextLayout layout = computeLayout(atlas, text, scale, scaleInt); + if (layout != null && layout.allGlyphsAvailable) { + evictIfNeeded(); + LAYOUT_CACHE.put(key, layout); + } + return layout; + } + + /** + * Invalidate cached layouts for a specific atlas (e.g. after glyph additions). + */ + public static void invalidateAtlas(String atlasKey) { + LAYOUT_CACHE.keySet().removeIf(k -> k.atlasKey.equals(atlasKey)); + } + + /** + * Clear all cached layouts. + */ + public static void clearCache() { + LAYOUT_CACHE.clear(); + } + + private static void evictIfNeeded() { + if (LAYOUT_CACHE.size() >= MAX_CACHE_SIZE) { + // Evict half the entries (simple random-ish eviction via iterator) + var it = LAYOUT_CACHE.keySet().iterator(); + int toRemove = MAX_CACHE_SIZE / 2; + for (int i = 0; i < toRemove && it.hasNext(); i++) { + it.next(); + it.remove(); + } + } + } + + @Nullable + private static SdfTextLayout computeLayout(SdfGlyphAtlas atlas, String text, float scale, int scaleInt) { + int penX = 0, maxHeight = 0; + Map> buckets = new LinkedHashMap<>(); + boolean allAvailable = true; + + Set glyphPages = new HashSet<>(); + for (int ci = 0; ci < text.length(); ) { + int cp = text.codePointAt(ci); + ci += Character.charCount(cp); + + SdfGlyphAtlas.GlyphEntry glyph = atlas.glyph(cp, glyphPages::add); + if (glyph == null) { + allAvailable = false; + penX += Math.round(Math.max(6, atlas.font().getSize() / 2) * scale); + continue; + } + if (glyph.width() <= 0) { + penX += Math.max(1, Math.round(glyph.advance() * scale)); + continue; + } + + SdfGlyphPage page = atlas.page(glyph.pageIndex()); + float u0 = glyph.atlasX() / (float) page.image.getWidth(); + float v0 = glyph.atlasY() / (float) page.image.getHeight(); + float u1 = glyph.endX() / (float) page.image.getWidth(); + float v1 = glyph.endY() / (float) page.image.getHeight(); + + int w = Math.round(glyph.width() * scale); + int h = Math.round(glyph.height() * scale); + // Positions relative to layout origin (0, 0) + GlyphQuad quad = new GlyphQuad(penX, 0, penX + w, h, u0, v0, u1, v1, (char) cp); + + buckets.computeIfAbsent(glyph.pageIndex(), ignored -> new ArrayList<>()).add(quad); + penX += Math.max(1, Math.round(glyph.advance() * scale)); + maxHeight = Math.max(maxHeight, h); + } + glyphPages.forEach(SdfGlyphPage::updateHash); + + List pages = new ArrayList<>(); + for (var entry : buckets.entrySet()) { + int pi = entry.getKey(); + SdfGlyphPage page = atlas.page(pi); + ResourceLocation tex = page.textureId; + pages.add(new PageQuads(pi, tex, page.image.getWidth(), page.image.getHeight(), entry.getValue())); + } + + SdfTextLayout layout = new SdfTextLayout(pages, penX, maxHeight); + layout.allGlyphsAvailable = allAvailable; + return layout; + } + + private boolean allGlyphsAvailable = true; + + private static final SdfTextLayout EMPTY = new SdfTextLayout(List.of(), 0, 0); + + public List pages() { return Collections.unmodifiableList(this.pages); } + public int width() { return this.width; } + public int height() { return this.height; } + + private record CacheKey(String atlasKey, String text, int scaleInt) {} + + public record PageQuads(int pageIndex, @Nullable ResourceLocation atlasTexture, + int pageWidth, int pageHeight, List quads) {} + + public record GlyphQuad(int x0, int y0, int x1, int y1, + float u0, float v0, float u1, float v1, char glyph) {} +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextRenderer.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextRenderer.java new file mode 100644 index 00000000..7675386d --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextRenderer.java @@ -0,0 +1,271 @@ +package dev.anvilcraft.lib.v2.font.sdf; + +import com.google.common.annotations.Beta; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.BufferUploader; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.Tesselator; +import com.mojang.blaze3d.vertex.VertexFormat; +import dev.anvilcraft.lib.v2.font.ALFPipelines; +import dev.anvilcraft.lib.v2.font.ALFont; +import dev.anvilcraft.lib.v2.font.sdf.state.SdfTextRenderState; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.Style; +import net.minecraft.util.FormattedCharSequence; +import javax.annotation.Nullable; + +import java.awt.Font; +import java.util.List; + +/** + * SDF text renderer that draws strings via the SDF text shader. + *

+ * Uses a CPU-generated SDF glyph atlas uploaded to a GPU texture, + * sampled by a custom fragment shader for smooth anti-aliased text. + */ +@Beta +public final class SdfTextRenderer { + public SdfTextRenderer() { + } + + /** + * Get the SDF atlas for a font, blocking until it is ready on first access. + * The atlas is cached forever, so only the very first call for a particular + * font blocks (typically ~200-400 ms while the glyph atlas is built). + */ + private static SdfGlyphAtlas getAtlas(@Nullable Font font) { + return SdfGlyphAtlas.getOrCreate(font).join(); + } + + public void drawString( + GuiGraphics graphics, + @Nullable Font font, + @Nullable String text, + int x, + int y, + int color, + boolean dropShadow + ) { + if (text == null || text.isEmpty()) return; + drawStringWithAtlas(graphics, getAtlas(font), text, x, y, color); + } + + private void drawStringWithAtlas( + GuiGraphics graphics, SdfGlyphAtlas atlas, + String text, int x, int y, int color + ) { + float scale = scaleFor(atlas); + SdfAtlasTexture.ensureUploaded(atlas); + SdfTextLayout layout = SdfTextLayout.fromAtlas(atlas, text, x, y, scale); + if (layout.pages().isEmpty()) return; + for (SdfTextLayout.PageQuads pq : layout.pages()) { + drawAtlas(graphics, pq, color, x, y); + } + } + + private static float scaleFor(SdfGlyphAtlas atlas) { + return Minecraft.getInstance().font.lineHeight / (float) atlas.awtHeight(); + } + + /** + * Derive a styled font for bold/italic. + */ + private static Font styledFont(Font base, boolean bold, boolean italic) { + int mask = Font.PLAIN; + if (bold) mask |= Font.BOLD; + if (italic) mask |= Font.ITALIC; + return mask == Font.PLAIN ? base : base.deriveFont(mask); + } + + /** + * Replace codepoint with random ASCII for obfuscated style. + */ + private static int obfuscateCodepoint(int codepoint, int index) { + long t = System.currentTimeMillis() / 300L; + int r = (int) (((long) index * 7L + t) % 95L); + return 32 + r; + } + + public void drawComponent( + GuiGraphics graphics, + @Nullable Font font, + Component text, + int x, + int y, + int color, + boolean dropShadow + ) { + this.drawFormatted(graphics, font, text.getVisualOrderText(), x, y, color, dropShadow); + } + + public void drawFormatted( + GuiGraphics graphics, + @Nullable Font font, + FormattedCharSequence text, + int x, + int y, + int color, + boolean dropShadow + ) { + int[] pen = { + x, + x + }; + StringBuilder buf = new StringBuilder(); + int[] segColor = {color}; + boolean[] segBold = {false}; + boolean[] segItalic = {false}; + boolean[] segUnderline = {false}; + boolean[] segStrikethrough = {false}; + + text.accept((index, style, codepoint) -> { + if (style.isObfuscated()) { + codepoint = obfuscateCodepoint(codepoint, index); + } + + int c = colorFromStyle(style, color); + boolean b = style.isBold(); + boolean i = style.isItalic(); + + if ((c != segColor[0] || b != segBold[0] || i != segItalic[0]) && !buf.isEmpty()) { + Font segFont = styledFont(font, segBold[0], segItalic[0]); + pen[0] = flushFormattedSegment(graphics, segFont, buf.toString(), pen[0], y, segColor[0]); + drawDecorations(graphics, pen[1], pen[0], y, segColor[0], segUnderline[0], segStrikethrough[0]); + buf.setLength(0); + pen[1] = pen[0]; + } + + buf.appendCodePoint(codepoint); + segColor[0] = c; + segBold[0] = b; + segItalic[0] = i; + segUnderline[0] = style.isUnderlined(); + segStrikethrough[0] = style.isStrikethrough(); + return true; + }); + + if (!buf.isEmpty()) { + Font segFont = styledFont(font, segBold[0], segItalic[0]); + pen[0] = flushFormattedSegment(graphics, segFont, buf.toString(), pen[0], y, segColor[0]); + drawDecorations(graphics, pen[1], pen[0], y, segColor[0], segUnderline[0], segStrikethrough[0]); + } + } + + private int flushFormattedSegment(GuiGraphics graphics, @Nullable Font font, String text, int x, int y, int color) { + SdfGlyphAtlas atlas = getAtlas(font); + float scale = scaleFor(atlas); + SdfAtlasTexture.ensureUploaded(atlas); + SdfTextLayout layout = SdfTextLayout.fromAtlas(atlas, text, x, y, scale); + for (SdfTextLayout.PageQuads pq : layout.pages()) { + if (pq.atlasTexture() != null && !pq.quads().isEmpty()) { + drawAtlas(graphics, pq, color, x, y); + } + } + return x + layout.width(); + } + + /** + * Draw underline and/or strikethrough lines relative to baseline. + */ + private static void drawDecorations( + GuiGraphics graphics, + int x0, + int x1, + int y, + int color, + boolean underline, + boolean strikethrough + ) { + if (x1 <= x0) return; + int lh = Minecraft.getInstance().font.lineHeight; + PoseStack pose = graphics.pose(); + pose.pushPose(); + pose.scale(1.0F, 0.5F, 1.0F); + if (strikethrough) { + int sy = (y + lh / 2) * 2; + graphics.fill(x0, sy, x1, sy + 1, color); + } + if (underline) { + int sy = (y + lh - 2) * 2; + graphics.fill(x0, sy, x1, sy + 1, color); + } + pose.popPose(); + } + + public void drawWrapped( + GuiGraphics graphics, + @Nullable Font font, + FormattedText text, + int x, + int y, + int width, + int color, + boolean dropShadow + ) { + SdfGlyphAtlas atlas = getAtlas(font); + float scale = scaleFor(atlas); + List lines = wrapLines(atlas, text.getString(), width, scale); + int lineHeight = Minecraft.getInstance().font.lineHeight; + for (int i = 0; i < lines.size(); i++) { + this.drawStringWithAtlas(graphics, atlas, lines.get(i), x, y + i * lineHeight, color); + } + } + + public void drawCentered(GuiGraphics graphics, @Nullable Font font, Component text, int x, int y, int color) { + drawCentered(graphics, font, text.getVisualOrderText(), x, y, color); + } + + public void drawCentered(GuiGraphics graphics, @Nullable Font font, FormattedCharSequence text, int x, int y, int color) { + String value = flattenToString(text); + SdfGlyphAtlas atlas = getAtlas(font); + float scale = scaleFor(atlas); + int drawX = x - Math.round(atlas.measureText(value) * scale) / 2; + this.drawFormatted(graphics, font, text, drawX, y, color, false); + } + + private static int colorFromStyle(Style style, int defaultColor) { + return style.getColor() != null ? style.getColor().getValue() | 0xFF000000 : defaultColor; + } + + private static List wrapLines(SdfGlyphAtlas atlas, String text, int maxWidth, float scale) { + return ALFont.wrapLines(atlas, text, maxWidth, scale); + } + + private static String flattenToString(FormattedCharSequence text) { + StringBuilder buf = new StringBuilder(); + text.accept((index, style, cp) -> { + buf.appendCodePoint(cp); + return true; + }); + return buf.toString(); + } + + private static void drawAtlas( + GuiGraphics graphics, + SdfTextLayout.PageQuads pq, + int color, + int originX, + int originY + ) { + if (pq.atlasTexture() == null || pq.quads().isEmpty()) return; + SdfTextRenderState state = new SdfTextRenderState( + new org.joml.Matrix4f(graphics.pose().last().pose()), + pq.quads(), + pq.atlasTexture(), + color, + originX, + originY + ); + // GUI rendering keeps blending enabled and depth test disabled; only the + // shader and atlas texture need to be swapped for SDF text. + RenderSystem.setShader(ALFPipelines::getSdfTextShader); + RenderSystem.setShaderTexture(0, state.atlasTexture()); + BufferBuilder builder = Tesselator.getInstance().begin(VertexFormat.Mode.QUADS, ALFPipelines.SDF_TEXT_FORMAT); + state.buildVertices(builder); + BufferUploader.drawWithShader(builder.buildOrThrow()); + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/package-info.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/package-info.java new file mode 100644 index 00000000..24b99a53 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.font.sdf; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/state/SdfTextRenderState.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/state/SdfTextRenderState.java new file mode 100644 index 00000000..8d9f6fc3 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/state/SdfTextRenderState.java @@ -0,0 +1,66 @@ +package dev.anvilcraft.lib.v2.font.sdf.state; + +import com.mojang.blaze3d.vertex.VertexConsumer; +import dev.anvilcraft.lib.v2.font.sdf.SdfTextLayout; +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.ApiStatus; +import org.joml.Matrix4f; + +import java.util.List; + +/** + * Render state for SDF text rendering. + * + *

Coordinates are in screen space and are transformed via the pose matrix + * before being submitted to the GPU with the SDF shader.

+ */ +@ApiStatus.Internal +public record SdfTextRenderState( + Matrix4f pose, + List glyphs, + ResourceLocation atlasTexture, + int color, + int originX, + int originY +) { + /** + * Build quad vertices in screen space. The pose matrix transforms them to + * the GUI coordinate space; the SDF shader then applies the GUI projection. + */ + public void buildVertices(VertexConsumer consumer) { + int ox = this.originX; + int oy = this.originY; + for (SdfTextLayout.GlyphQuad quad : this.glyphs) { + float x0 = quad.x0() + ox; + float y0 = quad.y0() + oy; + float x1 = quad.x1() + ox; + float y1 = quad.y1() + oy; + + float u0 = quad.u0(); + float v0 = quad.v0(); + float u1 = quad.u1(); + float v1 = quad.v1(); + + // Draw quad as two triangles (4 vertices in QUADS mode) + // Vertex 0: top-left + consumer.addVertex(this.pose, x0, y0, 0.0F) + .setColor(this.color) + .setUv(u0, v0); + + // Vertex 1: bottom-left + consumer.addVertex(this.pose, x0, y1, 0.0F) + .setColor(this.color) + .setUv(u0, v1); + + // Vertex 2: bottom-right + consumer.addVertex(this.pose, x1, y1, 0.0F) + .setColor(this.color) + .setUv(u1, v1); + + // Vertex 3: top-right + consumer.addVertex(this.pose, x1, y0, 0.0F) + .setColor(this.color) + .setUv(u1, v0); + } + } +} diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/state/package-info.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/state/package-info.java new file mode 100644 index 00000000..e9922755 --- /dev/null +++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/state/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.font.sdf.state; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.font/src/main/resources/META-INF/neoforge.mods.toml b/module.font/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 00000000..27f7ba75 --- /dev/null +++ b/module.font/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,86 @@ +# This is an example mods.toml file. It contains the data relating to the loading mods. +# There are several mandatory fields (#mandatory), and many more that are optional (#optional). +# The overall format is standard TOML format, v0.5.0. +# Note that there are a couple of TOML lists in this file. +# Find more information on toml format here: https://github.com/toml-lang/toml +# The name of the mod loader type to load - for regular FML @Mod mods it should be javafml +modLoader = "javafml" #mandatory +# A version range to match for said mod loader - for regular FML @Mod it will be the the FML version. This is currently 47. +loaderVersion = "${loader_version_range}" #mandatory +# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties. +# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here. +license = "${mod_license}" +# A URL to refer people to when problems occur with this mod +#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional +# A list of mods - how many allowed here is determined by the individual mod loader +[[mods]] #mandatory +# The modid of the mod +modId = "${mod_id}" #mandatory +# The version number of the mod +version = "${mod_version}" #mandatory +# A display name for the mod +displayName = "${mod_name}" #mandatory +# A URL to query for updates for this mod. See the JSON update specification https://docs.neoforge.net/docs/misc/updatechecker/ +#updateJSONURL="https://change.me.example.invalid/updates.json" #optional +# A URL for the "homepage" for this mod, displayed in the mod UI +displayURL="https://github.com/Anvil-Dev/AnvilLib" +# A file name (in the root of the mod JAR) containing a logo for display +logoFile="icon.png" #optional +# A text field displayed in the mod UI +#credits="" #optional +# A text field displayed in the mod UI +authors = "${mod_authors}" #optional +# Display Test controls the display for your mod in the server connection screen +# MATCH_VERSION means that your mod will cause a red X if the versions on client and server differ. This is the default behaviour and should be what you choose if you have server and client elements to your mod. +# IGNORE_SERVER_VERSION means that your mod will not cause a red X if it's present on the server but not on the client. This is what you should use if you're a server only mod. +# IGNORE_ALL_VERSION means that your mod will not cause a red X if it's present on the client or the server. This is a special case and should only be used if your mod has no server component. +# NONE means that no display test is set on your mod. You need to do this yourself, see IExtensionPoint.DisplayTest for more information. You can define any scheme you wish with this value. +# IMPORTANT NOTE: this is NOT an instruction as to which environments (CLIENT or DEDICATED SERVER) your mod loads on. Your mod should load (and maybe do nothing!) whereever it finds itself. +#displayTest="MATCH_VERSION" # MATCH_VERSION is the default if nothing is specified (#optional) + +# The description text for the mod (multi line!) (#mandatory) +description = '''${mod_description}''' + +# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded. +[[mixins]] +config = "${mod_id}.mixins.json" + +# The [[accessTransformers]] block allows you to declare where your AT file is. +# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg +#[[accessTransformers]] +#file="META-INF/accesstransformer.cfg" + +# The coremods config file path is not configurable and is always loaded from META-INF/coremods.json + +# A dependency - use the . to indicate dependency for a specific modid. Dependencies are optional. +[[dependencies."${mod_id}"]] #optional +# the modid of the dependency +modId = "neoforge" #mandatory +# The type of the dependency. Can be one of "required", "optional", "incompatible" or "discouraged" (case insensitive). +# 'required' requires the mod to exist, 'optional' does not +# 'incompatible' will prevent the game from loading when the mod exists, and 'discouraged' will show a warning +type = "required" #mandatory +# Optional field describing why the dependency is required or why it is incompatible +# reason="..." +# The version range of the dependency +versionRange = "${neo_version_range}" #mandatory +# An ordering relationship for the dependency. +# BEFORE - This mod is loaded BEFORE the dependency +# AFTER - This mod is loaded AFTER the dependency +ordering = "NONE" +# Side this dependency is applied on - BOTH, CLIENT, or SERVER +side = "BOTH" +# Here's another dependency +[[dependencies."${mod_id}"]] +modId = "minecraft" +type = "required" +# This version range declares a minimum of the current minecraft version up to but not including the next major version +versionRange = "${minecraft_version_range}" +ordering = "NONE" +side = "BOTH" + +# Features are specific properties of the game environment, that you may want to declare you require. This example declares +# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't +# stop your mod loading on the server for example. +#[features."${mod_id}"] +#openGLVersion="[3.2,)" diff --git a/module.font/src/main/resources/anvillib_font.mixins.json b/module.font/src/main/resources/anvillib_font.mixins.json new file mode 100644 index 00000000..098588c0 --- /dev/null +++ b/module.font/src/main/resources/anvillib_font.mixins.json @@ -0,0 +1,15 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "dev.anvilcraft.lib.v2.font.mixin", + "compatibilityLevel": "JAVA_8", + "refmap": "anvillib.refmap.json", + "mixins": [ + ], + "client": [ + "GuiGraphicsExtractorMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/module.font/src/main/resources/assets/anvillib_font/lang/zh_cn.json b/module.font/src/main/resources/assets/anvillib_font/lang/zh_cn.json new file mode 100644 index 00000000..0e37a159 --- /dev/null +++ b/module.font/src/main/resources/assets/anvillib_font/lang/zh_cn.json @@ -0,0 +1,8 @@ +{ + "narration.anvillib_font.dropdown.collapsed": "折叠", + "narration.anvillib_font.dropdown.expanded": "展开", + "screen.anvillib_font.config": "铁砧库 文字渲染 配置", + "screen.anvillib_font.config.family": "字体系列", + "screen.anvillib_font.config.font": "字体", + "screen.anvillib_font.config.test": "文字渲染测试" +} \ No newline at end of file diff --git a/module.font/src/main/resources/assets/anvillib_font/shaders/core/sdf_text.fsh b/module.font/src/main/resources/assets/anvillib_font/shaders/core/sdf_text.fsh new file mode 100644 index 00000000..7770df89 --- /dev/null +++ b/module.font/src/main/resources/assets/anvillib_font/shaders/core/sdf_text.fsh @@ -0,0 +1,19 @@ +#version 150 + +uniform sampler2D Sampler0; + +in vec2 vTexCoord; +in vec4 vColor; + +out vec4 fragColor; + +void main() { + vec4 sampleColor = texture(Sampler0, vTexCoord); + + float distanceValue = sampleColor.r; + float aa = max(fwidth(distanceValue), 0.04); + float alpha = smoothstep(0.5 - aa, 0.5 + aa, distanceValue); + + vec4 color = vec4(vColor.rgb, vColor.a * alpha); + fragColor = color; +} diff --git a/module.font/src/main/resources/assets/anvillib_font/shaders/core/sdf_text.json b/module.font/src/main/resources/assets/anvillib_font/shaders/core/sdf_text.json new file mode 100644 index 00000000..72820298 --- /dev/null +++ b/module.font/src/main/resources/assets/anvillib_font/shaders/core/sdf_text.json @@ -0,0 +1,11 @@ +{ + "vertex": "anvillib_font:sdf_text", + "fragment": "anvillib_font:sdf_text", + "samplers": [ + { "name": "Sampler0" } + ], + "uniforms": [ + { "name": "ModelViewMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ProjMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] } + ] +} diff --git a/module.font/src/main/resources/assets/anvillib_font/shaders/core/sdf_text.vsh b/module.font/src/main/resources/assets/anvillib_font/shaders/core/sdf_text.vsh new file mode 100644 index 00000000..5ce8fd30 --- /dev/null +++ b/module.font/src/main/resources/assets/anvillib_font/shaders/core/sdf_text.vsh @@ -0,0 +1,17 @@ +#version 150 + +in vec3 Position; +in vec4 Color; +in vec2 UV0; + +uniform mat4 ModelViewMat; +uniform mat4 ProjMat; + +out vec2 vTexCoord; +out vec4 vColor; + +void main() { + gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0); + vTexCoord = UV0; + vColor = Color; +} diff --git a/module.font/src/main/resources/icon.png b/module.font/src/main/resources/icon.png new file mode 100644 index 00000000..15304bd5 Binary files /dev/null and b/module.font/src/main/resources/icon.png differ diff --git a/module.font/src/main/resources/interface_injections.json b/module.font/src/main/resources/interface_injections.json new file mode 100644 index 00000000..c5eb2bf7 --- /dev/null +++ b/module.font/src/main/resources/interface_injections.json @@ -0,0 +1,5 @@ +{ + "net/minecraft/client/gui/GuiGraphics": [ + "dev/anvilcraft/lib/v2/font/extension/GuiGraphicsExtractorExtension" + ] +} diff --git a/module.integration/src/main/java/dev/anvilcraft/lib/v2/integration/IntegrationManager.java b/module.integration/src/main/java/dev/anvilcraft/lib/v2/integration/IntegrationManager.java index 300121d4..9e41bc98 100644 --- a/module.integration/src/main/java/dev/anvilcraft/lib/v2/integration/IntegrationManager.java +++ b/module.integration/src/main/java/dev/anvilcraft/lib/v2/integration/IntegrationManager.java @@ -58,6 +58,7 @@ public void compileContent() { log.info("Considering integration {} for {id:{}, version:{}}", annotation.memberName(), modid, version); IntegrationInstance instance = new IntegrationInstance(modid, ModVersionRange.of(version), annotation.memberName(), type); this.instances.put(modid, instance); + meter.increment(); } } StartupNotificationManager.popBar(meter); diff --git a/module.integration/src/main/java/dev/anvilcraft/lib/v2/integration/package-info.java b/module.integration/src/main/java/dev/anvilcraft/lib/v2/integration/package-info.java index 0d42d3c7..e59e85ee 100644 --- a/module.integration/src/main/java/dev/anvilcraft/lib/v2/integration/package-info.java +++ b/module.integration/src/main/java/dev/anvilcraft/lib/v2/integration/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.integration; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.main/build.gradle b/module.main/build.gradle index 641096de..d3d61a53 100644 --- a/module.main/build.gradle +++ b/module.main/build.gradle @@ -126,30 +126,42 @@ sourceSets.main.resources { srcDir 'src/generated/resources' } dependencies { if (System.getenv("NOT_DEV") == 'true') { - jarJar(api("dev.anvilcraft.lib:anvillib-config-neoforge-1.21.1:latest.release")) jarJar(api("dev.anvilcraft.lib:anvillib-codec-neoforge-1.21.1:latest.release")) - jarJar(api("dev.anvilcraft.lib:anvillib-multiblock-neoforge-1.21.1:latest.release")) + jarJar(api("dev.anvilcraft.lib:anvillib-collision-neoforge-1.21.1:latest.release")) + jarJar(api("dev.anvilcraft.lib:anvillib-config-neoforge-1.21.1:latest.release")) + jarJar(api("dev.anvilcraft.lib:anvillib-explosion-neoforge-1.21.1:latest.release")) + jarJar(api("dev.anvilcraft.lib:anvillib-font-neoforge-1.21.1:latest.release")) jarJar(api("dev.anvilcraft.lib:anvillib-integration-neoforge-1.21.1:latest.release")) jarJar(api("dev.anvilcraft.lib:anvillib-moveable-entity-block-neoforge-1.21.1:latest.release")) interfaceInjectionData("dev.anvilcraft.lib:anvillib-moveable-entity-block-neoforge-1.21.1:latest.release") + jarJar(api("dev.anvilcraft.lib:anvillib-multiblock-neoforge-1.21.1:latest.release")) jarJar(api("dev.anvilcraft.lib:anvillib-network-neoforge-1.21.1:latest.release")) jarJar(api("dev.anvilcraft.lib:anvillib-recipe-neoforge-1.21.1:latest.release")) interfaceInjectionData("dev.anvilcraft.lib:anvillib-recipe-neoforge-1.21.1:latest.release") jarJar(api("dev.anvilcraft.lib:anvillib-registrum-neoforge-1.21.1:latest.release")) + jarJar(api("dev.anvilcraft.lib:anvillib-rpc-neoforge-1.21.1:latest.release")) + jarJar(api("dev.anvilcraft.lib:anvillib-space-select-neoforge-1.21.1:latest.release")) + jarJar(api("dev.anvilcraft.lib:anvillib-sync-neoforge-1.21.1:latest.release")) jarJar(api("dev.anvilcraft.lib:anvillib-util-neoforge-1.21.1:latest.release")) jarJar(api("dev.anvilcraft.lib:anvillib-wheel-neoforge-1.21.1:latest.release")) jarJar(api("dev.anvilcraft.lib:anvillib-yukkuri-neoforge-1.21.1:latest.release")) } else { - jarJar(api project(":anvillib-config-neoforge-1.21.1")) jarJar(api project(":anvillib-codec-neoforge-1.21.1")) - jarJar(api project(":anvillib-multiblock-neoforge-1.21.1")) + jarJar(api project(":anvillib-collision-neoforge-1.21.1")) + jarJar(api project(":anvillib-config-neoforge-1.21.1")) + jarJar(api project(":anvillib-explosion-neoforge-1.21.1")) + jarJar(api project(":anvillib-font-neoforge-1.21.1")) jarJar(api project(":anvillib-integration-neoforge-1.21.1")) jarJar(api project(":anvillib-moveable-entity-block-neoforge-1.21.1")) interfaceInjectionData project(":anvillib-moveable-entity-block-neoforge-1.21.1") + jarJar(api project(":anvillib-multiblock-neoforge-1.21.1")) jarJar(api project(":anvillib-network-neoforge-1.21.1")) jarJar(api project(":anvillib-recipe-neoforge-1.21.1")) interfaceInjectionData project(":anvillib-recipe-neoforge-1.21.1") jarJar(api project(":anvillib-registrum-neoforge-1.21.1")) + jarJar(api project(":anvillib-rpc-neoforge-1.21.1")) + jarJar(api project(":anvillib-space-select-neoforge-1.21.1")) + jarJar(api project(":anvillib-sync-neoforge-1.21.1")) jarJar(api project(":anvillib-util-neoforge-1.21.1")) jarJar(api project(":anvillib-wheel-neoforge-1.21.1")) jarJar(api project(":anvillib-yukkuri-neoforge-1.21.1")) diff --git a/module.main/src/main/java/dev/anvilcraft/lib/v2/package-info.java b/module.main/src/main/java/dev/anvilcraft/lib/v2/package-info.java index 5b0d0b07..e17e19b9 100644 --- a/module.main/src/main/java/dev/anvilcraft/lib/v2/package-info.java +++ b/module.main/src/main/java/dev/anvilcraft/lib/v2/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.moveable-entity-block/src/main/java/dev/anvilcraft/lib/v2/piston/injection/package-info.java b/module.moveable-entity-block/src/main/java/dev/anvilcraft/lib/v2/piston/injection/package-info.java index 7afb0e57..3eee62fd 100644 --- a/module.moveable-entity-block/src/main/java/dev/anvilcraft/lib/v2/piston/injection/package-info.java +++ b/module.moveable-entity-block/src/main/java/dev/anvilcraft/lib/v2/piston/injection/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.piston.injection; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.moveable-entity-block/src/main/java/dev/anvilcraft/lib/v2/piston/mixin/package-info.java b/module.moveable-entity-block/src/main/java/dev/anvilcraft/lib/v2/piston/mixin/package-info.java index 70d4f6c2..bfc0d851 100644 --- a/module.moveable-entity-block/src/main/java/dev/anvilcraft/lib/v2/piston/mixin/package-info.java +++ b/module.moveable-entity-block/src/main/java/dev/anvilcraft/lib/v2/piston/mixin/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.piston.mixin; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.moveable-entity-block/src/main/java/dev/anvilcraft/lib/v2/piston/package-info.java b/module.moveable-entity-block/src/main/java/dev/anvilcraft/lib/v2/piston/package-info.java index b10f83fc..f0aa1419 100644 --- a/module.moveable-entity-block/src/main/java/dev/anvilcraft/lib/v2/piston/package-info.java +++ b/module.moveable-entity-block/src/main/java/dev/anvilcraft/lib/v2/piston/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.piston; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.multiblock/build.gradle b/module.multiblock/build.gradle index c2579d90..c4bfd404 100644 --- a/module.multiblock/build.gradle +++ b/module.multiblock/build.gradle @@ -115,6 +115,7 @@ sourceSets.main.resources { srcDir 'src/generated/resources' } dependencies { + if (System.getenv("NOT_DEV") == 'true') { jarJar(api("dev.anvilcraft.lib:anvillib-config-neoforge-1.21.1:latest.release")) jarJar(api("dev.anvilcraft.lib:anvillib-codec-neoforge-1.21.1:latest.release")) diff --git a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/data/provider/package-info.java b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/data/provider/package-info.java index 80ab4e69..b8c79c0b 100644 --- a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/data/provider/package-info.java +++ b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/data/provider/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.multiblock.data.provider; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/DynamicMultiblockManager.java b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/DynamicMultiblockManager.java index 53e13572..2886fa37 100644 --- a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/DynamicMultiblockManager.java +++ b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/DynamicMultiblockManager.java @@ -4,6 +4,7 @@ import dev.anvilcraft.lib.v2.multiblock.dynamic.controller.ControllerRecord; import dev.anvilcraft.lib.v2.multiblock.dynamic.controller.IController; import dev.anvilcraft.lib.v2.multiblock.dynamic.definition.MultiblockDefinition; +import dev.anvilcraft.lib.v2.multiblock.dynamic.event.DynamicMultiblockEvent; import dev.anvilcraft.lib.v2.multiblock.init.LibRegistries; import dev.anvilcraft.lib.v2.multiblock.network.MultiblockFormPacket; import dev.anvilcraft.lib.v2.multiblock.network.MultiblockUnformPacket; @@ -21,6 +22,7 @@ import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.saveddata.SavedData; +import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.network.PacketDistributor; import org.jetbrains.annotations.UnknownNullability; import org.slf4j.Logger; @@ -59,7 +61,7 @@ public class DynamicMultiblockManager extends SavedData { .replace(':', '_'); private static final Map CLIENT_SIDE = new WeakHashMap<>(); - private final Map multiblocks = new HashMap<>(); + private final Map multiblocks = new HashMap<>(); private int tickCounterUnformed = 0; private int tickCounterFormed = 0; @@ -67,7 +69,7 @@ public class DynamicMultiblockManager extends SavedData { private static volatile @UnknownNullability ExecutorService asyncExecutor; /** 当前正在异步检测中的控制器位置集合,用于去重。 */ - private final Set pendingChecks = ConcurrentHashMap.newKeySet(); + private final Set pendingChecks = ConcurrentHashMap.newKeySet(); /** * 获取指定世界的 DynamicMultiblockManager 实例。 @@ -90,7 +92,7 @@ public static DynamicMultiblockManager get(Level level) { * @return 对应的 {@link MultiblockState},若不存在则返回 {@code null} */ public @Nullable MultiblockState getAt(BlockPos pos) { - return this.multiblocks.get(pos.asLong()); + return this.multiblocks.get(pos.immutable()); } /** @@ -99,7 +101,7 @@ public static DynamicMultiblockManager get(Level level) { * @param state 待注册的多方块状态,控制器位置由 {@link MultiblockState#getControllerPos()} 提供 */ public void add(MultiblockState state) { - this.multiblocks.put(state.getControllerPos().asLong(), state); + this.multiblocks.put(state.getControllerPos().immutable(), state); this.setDirty(); } @@ -110,7 +112,7 @@ public void add(MultiblockState state) { * @return 被移除的 {@link MultiblockState};若未注册则返回 {@code null} */ public @Nullable MultiblockState removeAt(BlockPos pos) { - MultiblockState removed = this.multiblocks.remove(pos.asLong()); + MultiblockState removed = this.multiblocks.remove(pos.immutable()); if (removed != null) this.setDirty(); return removed; } @@ -122,7 +124,7 @@ public void add(MultiblockState state) { * @return 若存在注册则返回 {@code true} */ public boolean containsAt(BlockPos pos) { - return this.multiblocks.containsKey(pos.asLong()); + return this.multiblocks.containsKey(pos.immutable()); } /** @@ -146,7 +148,7 @@ public void updateFormed(Level level, MultiblockState cur, boolean formed) { BlockPos controllerPos = cur.getControllerPos(); BlockState state = level.getBlockState(controllerPos); - if (cur.getDefinition().value().isController(level, state, level.getBlockEntity(controllerPos))) { + if (cur.getDefinition(level.registryAccess()).value().isController(level, state, level.getBlockEntity(controllerPos))) { IController controller; try { controller = ControllerRecord.get( @@ -158,9 +160,23 @@ public void updateFormed(Level level, MultiblockState cur, boolean formed) { throw e; } if (formed) { - controller.onFormed(level, cur); + DynamicMultiblockEvent.Form event = new DynamicMultiblockEvent.Form(level, controller, cur); + NeoForge.EVENT_BUS.post(event); + if (!event.isCanceled()) { + controller.onFormed(level, cur); + } else { + cur.setFormed(false); + return; + } } else { - controller.onUnformed(level, cur); + DynamicMultiblockEvent.Unform event = new DynamicMultiblockEvent.Unform(level, controller, cur); + NeoForge.EVENT_BUS.post(event); + if (!event.isCanceled()) { + controller.onUnformed(level, cur); + } else { + cur.setFormed(true); + return; + } } } @@ -198,7 +214,7 @@ public static void onPlace(ServerLevel level, BlockPos pos, BlockState state) { correctedState = state; } if (!definition.isController(level, correctedState, null)) continue; - MultiblockState mstate = new MultiblockState(correctedPos.immutable(), holder); + MultiblockState mstate = new MultiblockState(correctedPos.immutable(), holder.key()); manager.add(mstate); // onPlace 时同步检测(立即反馈),不走异步 manager.checkMultiblockFormedSync(level, mstate); @@ -226,14 +242,14 @@ public static void onBreak(Level level, BlockPos pos) { if (controllerState.isFormed()) { manager.updateFormed(level, controllerState, false); } - manager.multiblocks.remove(pos.asLong()); + manager.multiblocks.remove(pos.immutable()); manager.setDirty(); return; } for (MultiblockState state : manager.multiblocks.values()) { if (!state.isFormed()) continue; - MultiblockDefinition def = state.getDefinition().value(); + MultiblockDefinition def = state.getDefinition(level.registryAccess()).value(); Map global = def.toGlobal(state.getControllerPos()); if (global.containsKey(pos)) { // 非控制器方块被破坏,将多方块标记为未形成(会通知控制器并广播) @@ -275,6 +291,7 @@ public static void shutdownExecutor() { if (asyncExecutor != null) { asyncExecutor.shutdownNow(); try { + // noinspection ResultOfMethodCallIgnored - 为剩余任务等待5秒,无需判断是否完成 asyncExecutor.awaitTermination(5, TimeUnit.SECONDS); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); @@ -310,14 +327,14 @@ public static void checkMultiblockFormed(@Nullable ServerLevel level) { if (checkUnformed) { for (MultiblockState state : manager.multiblocks.values()) { if (!checkFormed && state.isFormed()) continue; - if (manager.pendingChecks.contains(state.getControllerPos().asLong())) continue; + if (manager.pendingChecks.contains(state.getControllerPos())) continue; candidates.add(state); if (candidates.size() >= maxChecks) break; } } else { for (MultiblockState state : manager.multiblocks.values()) { if (!state.isFormed()) continue; - if (manager.pendingChecks.contains(state.getControllerPos().asLong())) continue; + if (manager.pendingChecks.contains(state.getControllerPos())) continue; candidates.add(state); if (candidates.size() >= maxChecks) break; } @@ -329,24 +346,25 @@ public static void checkMultiblockFormed(@Nullable ServerLevel level) { ExecutorService executor = getOrCreateExecutor(); for (MultiblockState mstate : candidates) { MultiblockCheckSnapshot snapshot = buildSnapshot(level, mstate); + mstate.setSnapshot(snapshot); - long posLong = mstate.getControllerPos().asLong(); - manager.pendingChecks.add(posLong); + BlockPos pos = mstate.getControllerPos().immutable(); + manager.pendingChecks.add(pos); executor.submit(() -> { try { boolean formed = snapshot.test(); // 将结果回调到主线程 level.getServer().execute(() -> { - manager.pendingChecks.remove(posLong); + manager.pendingChecks.remove(pos); // 最终一致性验证:确认该 multiblock 仍然注册 - MultiblockState current = manager.multiblocks.get(posLong); + MultiblockState current = manager.multiblocks.get(pos.immutable()); if (current == null) return; manager.updateFormed(level, current, formed); }); } catch (Throwable t) { - LOGGER.error("Async multiblock check failed for pos {}", posLong, t); - level.getServer().execute(() -> manager.pendingChecks.remove(posLong)); + LOGGER.error("Async multiblock check failed for pos {}", pos, t); + level.getServer().execute(() -> manager.pendingChecks.remove(pos.immutable())); } }); } @@ -363,12 +381,21 @@ public static void checkMultiblockFormed(@Nullable ServerLevel level) { * @return 快照;若定义不存在则返回 {@code null} */ private static MultiblockCheckSnapshot buildSnapshot(ServerLevel level, MultiblockState state) { - MultiblockDefinition def = state.getDefinition().value(); + MultiblockCheckSnapshot old = state.getSnapshot(); + MultiblockDefinition def = state.getDefinition(level.registryAccess()).value(); Map global = def.toGlobal(state.getControllerPos()); Map entries = new LinkedHashMap<>(global.size()); for (Map.Entry entry : global.entrySet()) { BlockPos pos = entry.getKey(); BlockStatePredicate predicate = entry.getValue(); + if (!level.isLoaded(pos)) { + MultiblockCheckSnapshot.Entry snapshotEntry = old.entries().get(pos); + if (snapshotEntry == null) { + snapshotEntry = new MultiblockCheckSnapshot.Entry(null, null, predicate); + } + entries.put(pos, snapshotEntry); + continue; + } BlockState blockState = level.getBlockState(pos); CompoundTag entityNbt = null; if (predicate.requiresBlockEntity()) { @@ -379,7 +406,7 @@ private static MultiblockCheckSnapshot buildSnapshot(ServerLevel level, Multiblo } entries.put(pos, new MultiblockCheckSnapshot.Entry(blockState, entityNbt, predicate)); } - return new MultiblockCheckSnapshot(state.getControllerPos().asLong(), entries); + return new MultiblockCheckSnapshot(state.getControllerPos(), entries); } /** @@ -387,7 +414,7 @@ private static MultiblockCheckSnapshot buildSnapshot(ServerLevel level, Multiblo */ private void checkMultiblockFormedSync(Level level, MultiblockState state) { if (level.isClientSide) return; - MultiblockDefinition def = state.getDefinition().value(); + MultiblockDefinition def = state.getDefinition(level.registryAccess()).value(); Map global = def.toGlobal(state.getControllerPos()); boolean ok = true; for (Map.Entry entry : global.entrySet()) { @@ -417,7 +444,7 @@ private static DynamicMultiblockManager load(CompoundTag tag, HolderLookup.Provi ListTag list = tag.getList(TAG_LIST, Tag.TAG_COMPOUND); for (Tag value : list) { MultiblockState mb = MultiblockState.fromTag(Util.cast(value), registries); - manager.multiblocks.put(mb.getControllerPos().asLong(), mb); + manager.multiblocks.put(mb.getControllerPos().immutable(), mb); } } return manager; diff --git a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/MultiblockCheckSnapshot.java b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/MultiblockCheckSnapshot.java index 1a2f18d1..621464a4 100644 --- a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/MultiblockCheckSnapshot.java +++ b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/MultiblockCheckSnapshot.java @@ -14,11 +14,11 @@ *

快照在主线程上构建,包含所有必要的方块状态与(可选的)序列化 NBT 数据, * 因此工作线程无需访问 {@code Level}。 * - * @param controllerPosLong 控制器位置({@link BlockPos#asLong()} 编码) - * @param entries 每个检测位置对应的快照条目(位置 → 条目) + * @param controllerPos 控制器位置 + * @param entries 每个检测位置对应的快照条目(位置 → 条目) */ public record MultiblockCheckSnapshot( - long controllerPosLong, + BlockPos controllerPos, Map entries ) { @@ -29,7 +29,7 @@ public record MultiblockCheckSnapshot( */ public boolean test() { for (Entry entry : entries.values()) { - if (!entry.predicate().testOffThread(entry.blockState(), entry.entityNbt())) { + if (!entry.test()) { return false; } } @@ -44,10 +44,15 @@ public boolean test() { * @param predicate 应用于此位置的谓词 */ public record Entry( - BlockState blockState, + @Nullable BlockState blockState, @Nullable CompoundTag entityNbt, BlockStatePredicate predicate ) { + public boolean test() { + if (this.blockState == null) { + return true; + } + return this.predicate.testOffThread(this.blockState, this.entityNbt); + } } } - diff --git a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/MultiblockState.java b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/MultiblockState.java index 79a73763..6f15e1e1 100644 --- a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/MultiblockState.java +++ b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/MultiblockState.java @@ -4,7 +4,8 @@ import dev.anvilcraft.lib.v2.codec.StreamCodecUtil; import dev.anvilcraft.lib.v2.multiblock.dynamic.definition.MultiblockDefinition; import dev.anvilcraft.lib.v2.multiblock.init.LibRegistries; -import dev.anvilcraft.lib.v2.util.Util; +import io.netty.buffer.ByteBuf; +import lombok.AccessLevel; import lombok.Getter; import lombok.Setter; import net.minecraft.core.BlockPos; @@ -14,44 +15,49 @@ import net.minecraft.nbt.NbtOps; import net.minecraft.nbt.Tag; import net.minecraft.network.RegistryFriendlyByteBuf; -import net.minecraft.network.codec.ByteBufCodecs; import net.minecraft.network.codec.StreamCodec; import net.minecraft.resources.ResourceKey; +import javax.annotation.Nullable; + +import java.util.Map; @Getter @Setter public class MultiblockState { + public static final Codec> DEFINITION_KEY_CODEC = ResourceKey.codec(LibRegistries.DEFINITIONS_KEY); + public static final StreamCodec> DEFINITION_KEY_STREAM_CODEC = ResourceKey.streamCodec( + LibRegistries.DEFINITIONS_KEY + ); public static final StreamCodec STREAM_CODEC = StreamCodec.composite( StreamCodecUtil.VAR_INT_BLOCK_POS, MultiblockState::getControllerPos, - ByteBufCodecs.holder(LibRegistries.DEFINITIONS_KEY, MultiblockDefinition.STREAM_CODEC), - MultiblockState::getDefinition, + DEFINITION_KEY_STREAM_CODEC, + MultiblockState::getDefinitionKey, MultiblockState::new ); - public static final Codec> DEFINITION_KEY_CODEC = ResourceKey.codec(LibRegistries.DEFINITIONS_KEY); private final BlockPos controllerPos; - private final Holder.Reference definition; + private final ResourceKey definitionKey; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private @Nullable Holder.Reference definition; private boolean formed; + private MultiblockCheckSnapshot snapshot; - public MultiblockState(BlockPos controllerPos, Holder definition) { - this( - controllerPos, - definition instanceof Holder.Reference ref - ? ref - : Util.throwE(new IllegalArgumentException("Non Reference Holder '" + definition + "' found")), - false - ); + public MultiblockState(BlockPos controllerPos, ResourceKey definitionKey) { + this(controllerPos, definitionKey, false); } - public MultiblockState(BlockPos controllerPos, Holder.Reference definition, boolean formed) { + public MultiblockState(BlockPos controllerPos, ResourceKey definitionKey, boolean formed) { this.controllerPos = controllerPos; - this.definition = definition; + this.definitionKey = definitionKey; this.formed = formed; + this.snapshot = new MultiblockCheckSnapshot(this.controllerPos, Map.of()); } - public ResourceKey getDefinitionKey() { - return this.definition.key(); + public Holder.Reference getDefinition(HolderLookup.Provider registries) { + if (this.definition != null) return this.definition; + return this.definition = registries.lookup(LibRegistries.DEFINITIONS_KEY).orElseThrow().getOrThrow(this.definitionKey); } public Tag toTag(HolderLookup.Provider registries) { @@ -79,16 +85,11 @@ public static MultiblockState fromTag(CompoundTag tag, HolderLookup.Provider reg registries.createSerializationContext(NbtOps.INSTANCE), tag.get("controllerPos") ).getOrThrow().getFirst(); - Holder.Reference definition = registries.lookup(LibRegistries.DEFINITIONS_KEY).orElseThrow().getOrThrow( - MultiblockState.DEFINITION_KEY_CODEC.decode( - registries.createSerializationContext(NbtOps.INSTANCE), - tag.get("definition") - ).getOrThrow().getFirst() - ); + ResourceKey definitionKey = MultiblockState.DEFINITION_KEY_CODEC.decode( + registries.createSerializationContext(NbtOps.INSTANCE), + tag.get("definition") + ).getOrThrow().getFirst(); boolean formed = tag.getBoolean("formed"); - return new MultiblockState(controllerPos, definition, formed); + return new MultiblockState(controllerPos, definitionKey, formed); } } - - - diff --git a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/controller/package-info.java b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/controller/package-info.java index 5371e9b2..56c65c58 100644 --- a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/controller/package-info.java +++ b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/controller/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.multiblock.dynamic.controller; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; \ No newline at end of file diff --git a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/definition/package-info.java b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/definition/package-info.java index 70f938f5..daa4f1fa 100644 --- a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/definition/package-info.java +++ b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/definition/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.multiblock.dynamic.definition; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; \ No newline at end of file diff --git a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/event/DynamicMultiblockEvent.java b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/event/DynamicMultiblockEvent.java new file mode 100644 index 00000000..a2de2c9e --- /dev/null +++ b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/event/DynamicMultiblockEvent.java @@ -0,0 +1,44 @@ +package dev.anvilcraft.lib.v2.multiblock.dynamic.event; + +import dev.anvilcraft.lib.v2.multiblock.dynamic.MultiblockState; +import dev.anvilcraft.lib.v2.multiblock.dynamic.controller.IController; +import lombok.Getter; +import net.minecraft.world.level.Level; +import net.neoforged.bus.api.Event; +import net.neoforged.bus.api.ICancellableEvent; + +/// 所有与动态多方块结构有关的事件的基类。 +@Getter +public abstract class DynamicMultiblockEvent extends Event { + private final Level level; + private final IController controller; + private final MultiblockState state; + + protected DynamicMultiblockEvent(Level level, IController controller, MultiblockState state) { + this.level = level; + this.controller = controller; + this.state = state; + } + + /// 本事件会在多方块结构尝试形成时发出。 + /// + /// 取消该事件将阻止多方块结构的形成。 + /// + /// 本事件会在双端发出。 + public static class Form extends DynamicMultiblockEvent implements ICancellableEvent { + public Form(Level level, IController controller, MultiblockState state) { + super(level, controller, state); + } + } + + /// 本事件会在多方块结构尝试解散时发出。 + /// + /// 取消该事件将阻止多方块结构的解散。 + /// + /// 本事件会在双端发出。 + public static class Unform extends DynamicMultiblockEvent implements ICancellableEvent { + public Unform(Level level, IController controller, MultiblockState state) { + super(level, controller, state); + } + } +} diff --git a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/package-info.java b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/package-info.java index 5c5a03a1..95697a09 100644 --- a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/package-info.java +++ b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/dynamic/package-info.java @@ -8,10 +8,12 @@ *

  • 控制器相关接口/记录用于自定义多方块控制器行为(onFormed/onUnformed)。
  • * */ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.multiblock.dynamic; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; \ No newline at end of file diff --git a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/event/package-info.java b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/event/package-info.java index ab5aacd8..f74ac1e9 100644 --- a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/event/package-info.java +++ b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/event/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.multiblock.event; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; \ No newline at end of file diff --git a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/init/package-info.java b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/init/package-info.java index 790b69ef..814ce38c 100644 --- a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/init/package-info.java +++ b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/init/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.multiblock.init; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; \ No newline at end of file diff --git a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/network/MultiblockFormPacket.java b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/network/MultiblockFormPacket.java index 41df5898..a61cdfbb 100644 --- a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/network/MultiblockFormPacket.java +++ b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/network/MultiblockFormPacket.java @@ -4,6 +4,8 @@ import dev.anvilcraft.lib.v2.multiblock.dynamic.DynamicMultiblockManager; import dev.anvilcraft.lib.v2.multiblock.dynamic.MultiblockState; import dev.anvilcraft.lib.v2.multiblock.dynamic.controller.ControllerRecord; +import dev.anvilcraft.lib.v2.multiblock.dynamic.controller.IController; +import dev.anvilcraft.lib.v2.multiblock.dynamic.event.DynamicMultiblockEvent; import dev.anvilcraft.lib.v2.network.packet.IClientboundPacket; import dev.anvilcraft.lib.v2.network.packet.IPacket; import lombok.extern.slf4j.Slf4j; @@ -14,6 +16,7 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.state.BlockState; +import net.neoforged.neoforge.common.NeoForge; @Slf4j @SuppressWarnings("unused") @@ -38,9 +41,16 @@ public void handleOnClient(Player player) { manager.updateFormed(level, this.state, true); BlockPos pos = this.state.getControllerPos(); BlockState state = level.getBlockState(pos); - if (!this.state.getDefinition().value().isController(level, state, level.getBlockEntity(pos))) return; + if (!this.state.getDefinition(level.registryAccess()).value().isController(level, state, level.getBlockEntity(pos))) return; try { - ControllerRecord.get(state.getBlock(), this.state.getDefinitionKey().location()).onFormed(level, this.state); + IController controller = ControllerRecord.get(state.getBlock(), this.state.getDefinitionKey().location()); + DynamicMultiblockEvent.Form event = new DynamicMultiblockEvent.Form(level, controller, this.state); + NeoForge.EVENT_BUS.post(event); + if (!event.isCanceled()) { + controller.onFormed(level, this.state); + } else { + this.state.setFormed(false); + } } catch (IllegalArgumentException e) { log.error(e.getLocalizedMessage(), e); throw e; diff --git a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/network/MultiblockUnformPacket.java b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/network/MultiblockUnformPacket.java index 040f9fa3..82fe2dee 100644 --- a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/network/MultiblockUnformPacket.java +++ b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/network/MultiblockUnformPacket.java @@ -4,6 +4,8 @@ import dev.anvilcraft.lib.v2.multiblock.dynamic.DynamicMultiblockManager; import dev.anvilcraft.lib.v2.multiblock.dynamic.MultiblockState; import dev.anvilcraft.lib.v2.multiblock.dynamic.controller.ControllerRecord; +import dev.anvilcraft.lib.v2.multiblock.dynamic.controller.IController; +import dev.anvilcraft.lib.v2.multiblock.dynamic.event.DynamicMultiblockEvent; import dev.anvilcraft.lib.v2.network.packet.IClientboundPacket; import dev.anvilcraft.lib.v2.network.packet.IPacket; import lombok.extern.slf4j.Slf4j; @@ -14,6 +16,7 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.state.BlockState; +import net.neoforged.neoforge.common.NeoForge; @Slf4j @SuppressWarnings("unused") @@ -38,9 +41,16 @@ public void handleOnClient(Player player) { manager.updateFormed(level, this.state, false); BlockPos pos = this.state.getControllerPos(); BlockState state = level.getBlockState(pos); - if (!this.state.getDefinition().value().isController(level, state, level.getBlockEntity(pos))) return; + if (!this.state.getDefinition(level.registryAccess()).value().isController(level, state, level.getBlockEntity(pos))) return; try { - ControllerRecord.get(state.getBlock(), this.state.getDefinitionKey().location()).onUnformed(level, this.state); + IController controller = ControllerRecord.get(state.getBlock(), this.state.getDefinitionKey().location()); + DynamicMultiblockEvent.Unform event = new DynamicMultiblockEvent.Unform(level, controller, this.state); + NeoForge.EVENT_BUS.post(event); + if (!event.isCanceled()) { + controller.onUnformed(level, this.state); + } else { + this.state.setFormed(true); + } } catch (IllegalArgumentException e) { log.error(e.getLocalizedMessage(), e); throw e; diff --git a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/package-info.java b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/package-info.java index 45bd281d..5c330db0 100644 --- a/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/package-info.java +++ b/module.multiblock/src/main/java/dev/anvilcraft/lib/v2/multiblock/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.multiblock; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; \ No newline at end of file diff --git a/module.network/src/main/java/dev/anvilcraft/lib/v2/network/packet/package-info.java b/module.network/src/main/java/dev/anvilcraft/lib/v2/network/packet/package-info.java index 15ec89c8..fffc9b26 100644 --- a/module.network/src/main/java/dev/anvilcraft/lib/v2/network/packet/package-info.java +++ b/module.network/src/main/java/dev/anvilcraft/lib/v2/network/packet/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.network.packet; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; \ No newline at end of file diff --git a/module.network/src/main/java/dev/anvilcraft/lib/v2/network/register/PacketData.java b/module.network/src/main/java/dev/anvilcraft/lib/v2/network/register/PacketData.java index 440dd809..b0cfc24b 100644 --- a/module.network/src/main/java/dev/anvilcraft/lib/v2/network/register/PacketData.java +++ b/module.network/src/main/java/dev/anvilcraft/lib/v2/network/register/PacketData.java @@ -13,6 +13,8 @@ import java.lang.reflect.AccessFlag; import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; import java.util.Set; @Slf4j @@ -35,11 +37,17 @@ static PacketData find(Class pac ) { continue; } - Class declaringClass = field.getType(); - if (declaringClass.isAssignableFrom(CustomPacketPayload.Type.class)) { + Class fieldType = field.getType(); + if (CustomPacketPayload.Type.class.isAssignableFrom(fieldType)) { + if (!isMatchingTypeArgument(field.getGenericType(), 0, packetClass)) { + continue; + } field.setAccessible(true); type = (CustomPacketPayload.Type) field.get(null); - } else if (declaringClass.isAssignableFrom(StreamCodec.class)) { + } else if (StreamCodec.class.isAssignableFrom(fieldType)) { + if (!isMatchingTypeArgument(field.getGenericType(), 1, packetClass)) { + continue; + } field.setAccessible(true); codec = (StreamCodec) field.get(null); } @@ -77,4 +85,27 @@ static PacketData find(Class pac } return new PacketData<>(type, codec, direction, handler); } + + /** + * 检查字段的参数化类型中,指定索引的类型实参是否与期望的类匹配。 + * 如果字段类型不是参数化类型、类型实参不足、或类型不匹配,返回 false。 + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private static boolean isMatchingTypeArgument( + Type genericType, + int typeArgIndex, + Class expectedClass + ) { + if (!(genericType instanceof ParameterizedType pt)) { + return false; + } + Type[] typeArgs = pt.getActualTypeArguments(); + if (typeArgs.length <= typeArgIndex) { + return false; + } + if (!(typeArgs[typeArgIndex] instanceof Class typeArg)) { + return false; + } + return typeArg.isAssignableFrom(expectedClass); + } } diff --git a/module.network/src/main/java/dev/anvilcraft/lib/v2/network/register/PacketProtocol.java b/module.network/src/main/java/dev/anvilcraft/lib/v2/network/register/PacketProtocol.java index 930d7566..158887c5 100644 --- a/module.network/src/main/java/dev/anvilcraft/lib/v2/network/register/PacketProtocol.java +++ b/module.network/src/main/java/dev/anvilcraft/lib/v2/network/register/PacketProtocol.java @@ -1,5 +1,5 @@ package dev.anvilcraft.lib.v2.network.register; -enum PacketProtocol { +public enum PacketProtocol { CONFIGURATION, PLAY, COMMON } diff --git a/module.network/src/main/java/dev/anvilcraft/lib/v2/network/register/package-info.java b/module.network/src/main/java/dev/anvilcraft/lib/v2/network/register/package-info.java index 692698a1..1c6dbf9d 100644 --- a/module.network/src/main/java/dev/anvilcraft/lib/v2/network/register/package-info.java +++ b/module.network/src/main/java/dev/anvilcraft/lib/v2/network/register/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.network.register; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; \ No newline at end of file diff --git a/module.network/src/main/java/dev/anvilcraft/lib/v2/network/util/NetworkUtil.java b/module.network/src/main/java/dev/anvilcraft/lib/v2/network/util/NetworkUtil.java index 3a7db03e..e81ad690 100644 --- a/module.network/src/main/java/dev/anvilcraft/lib/v2/network/util/NetworkUtil.java +++ b/module.network/src/main/java/dev/anvilcraft/lib/v2/network/util/NetworkUtil.java @@ -9,6 +9,7 @@ import org.jetbrains.annotations.Nullable; import java.util.Objects; +import java.util.function.Predicate; public class NetworkUtil { public static void sendToAllPlayersExcluded( @@ -18,7 +19,7 @@ public static void sendToAllPlayersExcluded( ) { MinecraftServer server = Objects.requireNonNull(ServerLifecycleHooks.getCurrentServer(), "Cannot send clientbound payloads on the client"); for (ServerPlayer player : server.getPlayerList().getPlayers()) { - if (player.equals(excluded)) continue; + if (Objects.equals(player, excluded)) continue; PacketDistributor.sendToPlayer(player, payload, payloads); } } @@ -30,7 +31,33 @@ public static void sendToAllPlayersInDimensionExcluded( CustomPacketPayload... payloads ) { for (ServerPlayer player : level.players()) { - if (player.equals(excluded)) continue; + if (Objects.equals(player, excluded)) continue; + PacketDistributor.sendToPlayer(player, payload, payloads); + } + } + + public static void sendToAllPlayersIncluded( + @Nullable Predicate included, + CustomPacketPayload payload, + CustomPacketPayload... payloads + ) { + if (included == null) included = ignored -> true; + MinecraftServer server = Objects.requireNonNull(ServerLifecycleHooks.getCurrentServer(), "Cannot send clientbound payloads on the client"); + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + if (!included.test(player)) continue; + PacketDistributor.sendToPlayer(player, payload, payloads); + } + } + + public static void sendToAllPlayersInDimensionIncluded( + ServerLevel level, + @Nullable Predicate included, + CustomPacketPayload payload, + CustomPacketPayload... payloads + ) { + if (included == null) included = ignored -> true; + for (ServerPlayer player : level.players()) { + if (!included.test(player)) continue; PacketDistributor.sendToPlayer(player, payload, payloads); } } diff --git a/module.network/src/main/java/dev/anvilcraft/lib/v2/network/util/package-info.java b/module.network/src/main/java/dev/anvilcraft/lib/v2/network/util/package-info.java index 765a8d77..f5ab4079 100644 --- a/module.network/src/main/java/dev/anvilcraft/lib/v2/network/util/package-info.java +++ b/module.network/src/main/java/dev/anvilcraft/lib/v2/network/util/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.network.util; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/builder/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/builder/package-info.java index 808fd0ac..2791c1da 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/builder/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/builder/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.builder; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/cache/item/operation/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/cache/item/operation/package-info.java index 2d02847f..df0a22ec 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/cache/item/operation/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/cache/item/operation/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.cache.item.operation; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/cache/item/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/cache/item/package-info.java index eaf20fe7..3728baa9 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/cache/item/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/cache/item/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.cache.item; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/cache/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/cache/package-info.java index 7481d3c9..97c4590e 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/cache/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/cache/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.cache; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; \ No newline at end of file diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/component/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/component/package-info.java index 5d8c540d..aa979f30 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/component/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/component/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.component; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/data/advancement/predicate/item/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/data/advancement/predicate/item/package-info.java index 95ce952a..34ac99b2 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/data/advancement/predicate/item/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/data/advancement/predicate/item/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.data.advancement.predicate.item; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/data/gen/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/data/gen/package-info.java index 90db3869..ee65b443 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/data/gen/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/data/gen/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.data.gen; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/data/gen/provider/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/data/gen/provider/package-info.java index 2ae429d7..0ca2f060 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/data/gen/provider/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/data/gen/provider/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.data.gen.provider; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/init/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/init/package-info.java index 9432e79c..717b8263 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/init/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/init/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.init; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/init/recipe/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/init/recipe/package-info.java index 00b0dc87..042fe908 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/init/recipe/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/init/recipe/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.init.recipe; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/mixin/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/mixin/package-info.java index b2af0b05..76c02fbd 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/mixin/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/mixin/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.mixin; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/SetBlock.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/SetBlock.java index 3e49a4fe..c5f580f9 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/SetBlock.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/SetBlock.java @@ -97,7 +97,7 @@ public static class Type implements IRecipeOutcome.Type { CodecUtil.BLOCK_STATE_MAP_CODEC .forGetter(SetBlock::state), CompoundTag.CODEC - .optionalFieldOf("nbt", null) + .optionalFieldOf("nbt", new CompoundTag()) .forGetter(SetBlock::nbt), Vec3.CODEC .fieldOf("offset") diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/SpawnItem.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/SpawnItem.java index 91776081..d45062d1 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/SpawnItem.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/SpawnItem.java @@ -104,7 +104,9 @@ public Type getType() { @SuppressWarnings("unchecked") public void accept(InWorldRecipeContext context) { ItemCache cache = context.computeIfAbsent(ItemCache.ITEM_CACHE); - ItemStack stack = this.item.copyWithCount(context.getInt(this.count, 0, 99)); + int count = context.getInt(this.count, 0, 99); + if (count == 0) return; + ItemStack stack = this.item.copyWithCount(count); BlockCache blockCache = context.computeIfAbsent(BlockCache.BLOCK_CACHE); Vec3 offset = context.getPos().add(this.offset); BlockPos blockPos = BlockPos.containing(offset); diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/function/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/function/package-info.java index 1a6848ec..4dbbd6ae 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/function/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/function/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.outcome.function; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/package-info.java index 28f4ef96..7e931446 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/outcome/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.outcome; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/package-info.java index be7a477b..f98e1c18 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/block/HasBlock.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/block/HasBlock.java index 32a69feb..52c003ba 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/block/HasBlock.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/block/HasBlock.java @@ -285,4 +285,4 @@ public HasBlock build() { return new HasBlock(offset, predicate.build()); } } -} \ No newline at end of file +} diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/block/HasBlockIngredient.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/block/HasBlockIngredient.java index 71996b62..349276da 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/block/HasBlockIngredient.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/block/HasBlockIngredient.java @@ -296,4 +296,4 @@ public HasBlockIngredient build() { return new HasBlockIngredient(offset, predicate.build()); } } -} \ No newline at end of file +} diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/block/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/block/package-info.java index bc8ca1a4..ff7d4d80 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/block/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/block/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.predicate.block; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/function/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/function/package-info.java index 63486cba..717b5324 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/function/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/function/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.predicate.function; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/item/HasItem.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/item/HasItem.java index b7d6258b..8813c7a4 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/item/HasItem.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/item/HasItem.java @@ -294,4 +294,4 @@ public HasItem build() { return new HasItem(offset, range, item.build(), functions); } } -} \ No newline at end of file +} diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/item/HasItemIngredient.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/item/HasItemIngredient.java index 771e79d0..46da6be8 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/item/HasItemIngredient.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/item/HasItemIngredient.java @@ -302,4 +302,4 @@ public HasItemIngredient build() { return new HasItemIngredient(offset, range, item.build(), functions); } } -} \ No newline at end of file +} diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/item/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/item/package-info.java index c13ee1ec..b4245681 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/item/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/item/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.predicate.item; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/package-info.java index 3d9a0583..435d1f47 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/predicate/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.predicate; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/trigger/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/trigger/package-info.java index 62109eb1..8ed6adc8 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/trigger/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/trigger/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.trigger; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/util/package-info.java b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/util/package-info.java index 169adb65..d7f01740 100644 --- a/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/util/package-info.java +++ b/module.recipe/src/main/java/dev/anvilcraft/lib/v2/recipe/util/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.recipe.util; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/AbstractRegistrum.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/AbstractRegistrum.java index c7ff37f8..4a138c82 100644 --- a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/AbstractRegistrum.java +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/AbstractRegistrum.java @@ -23,15 +23,19 @@ import java.util.Set; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; import java.util.function.UnaryOperator; import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Multimap; import com.google.common.collect.Table; import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; import dev.anvilcraft.lib.v2.registrum.providers.DataProviderInitializer; import dev.anvilcraft.lib.v2.registrum.providers.ProviderType; import dev.anvilcraft.lib.v2.registrum.providers.RegistrumDataProvider; @@ -42,28 +46,47 @@ import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.inventory.MenuAccess; import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.core.Holder; import net.minecraft.core.Registry; import net.minecraft.core.registries.Registries; +import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.codec.StreamCodec; import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.world.effect.MobEffect; +import net.minecraft.world.effect.MobEffectInstance; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType.EntityFactory; import net.minecraft.world.entity.MobCategory; +import net.minecraft.world.entity.ai.village.poi.PoiType; +import net.minecraft.world.entity.npc.VillagerProfession; +import net.minecraft.world.entity.npc.VillagerType; import net.minecraft.world.inventory.AbstractContainerMenu; import net.minecraft.world.item.CreativeModeTab; import net.minecraft.world.item.CreativeModeTabs; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; +import net.minecraft.world.item.alchemy.Potion; +import net.minecraft.world.item.crafting.Recipe; +import net.minecraft.world.level.ItemLike; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.gameevent.GameEvent; import net.neoforged.bus.api.EventPriority; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; import net.neoforged.fml.loading.FMLLoader; +import net.neoforged.neoforge.attachment.IAttachmentHolder; +import net.neoforged.neoforge.common.conditions.ICondition; +import net.neoforged.neoforge.common.loot.IGlobalLootModifier; +import net.neoforged.neoforge.common.world.BiomeModifier; +import net.neoforged.neoforge.common.world.StructureModifier; import net.neoforged.neoforge.data.event.GatherDataEvent; import net.neoforged.neoforge.data.loading.DatagenModLoader; import net.neoforged.neoforge.event.BuildCreativeModeTabContentsEvent; @@ -84,18 +107,36 @@ import dev.anvilcraft.lib.v2.registrum.builders.BlockEntityBuilder.BlockEntityFactory; import dev.anvilcraft.lib.v2.registrum.builders.Builder; import dev.anvilcraft.lib.v2.registrum.builders.BuilderCallback; +import dev.anvilcraft.lib.v2.registrum.builders.ConditionBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.CreativeTabBuilder; import dev.anvilcraft.lib.v2.registrum.builders.EntityBuilder; import dev.anvilcraft.lib.v2.registrum.builders.FluidBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.GameEventBuilder; import dev.anvilcraft.lib.v2.registrum.builders.ItemBuilder; import dev.anvilcraft.lib.v2.registrum.builders.MenuBuilder; import dev.anvilcraft.lib.v2.registrum.builders.MenuBuilder.ForgeMenuFactory; import dev.anvilcraft.lib.v2.registrum.builders.MenuBuilder.MenuFactory; import dev.anvilcraft.lib.v2.registrum.builders.MenuBuilder.ScreenFactory; +import dev.anvilcraft.lib.v2.registrum.builders.MobEffectBuilder; import dev.anvilcraft.lib.v2.registrum.builders.NoConfigBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.SelfBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.data.AttachmentBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.data.DataComponentBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.modifier.BiomeModifierBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.modifier.GlobalLootModifierBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.modifier.StructureModifierBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.recipe.RecipeSerializerBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.recipe.RecipeTypeBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.self.PotionBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.self.SoundEventBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.villager.PoiTypeBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.villager.VillagerProfessionBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.villager.VillagerTypeBuilder; import dev.anvilcraft.lib.v2.registrum.util.CreativeModeTabModifier; import dev.anvilcraft.lib.v2.registrum.util.DebugMarkers; import dev.anvilcraft.lib.v2.registrum.util.OneTimeEventReceiver; import dev.anvilcraft.lib.v2.registrum.util.entry.ItemEntry; +import dev.anvilcraft.lib.v2.registrum.util.entry.RecipeEntry; import dev.anvilcraft.lib.v2.registrum.util.entry.RegistryEntry; import dev.anvilcraft.lib.v2.util.nullness.NonNullBiFunction; import dev.anvilcraft.lib.v2.util.nullness.NonNullConsumer; @@ -873,6 +914,25 @@ public > S2 entry(String name return factory.apply(this::accept); } + /** + * Create a builder for a new entry whose registry type is the entry type itself (self-registered), e.g. {@link VillagerType}, {@link Potion}, {@link SoundEvent}. + * + * @param + * Entry type (also the registry type) + * @param

    + * Parent type + * @param + * Self type + * @param name + * The name to use for the entry + * @param factory + * The factory to create the builder + * @return The {@link SelfBuilder} instance + */ + public > S2 selfEntry(String name, NonNullFunction factory) { + return factory.apply(this::accept); + } + /** * Factory method to accept a completed builder and add it to the registration queue. *

    @@ -1307,4 +1367,435 @@ public

    NoConfigBuilder defaultCreativeT return builder.build(); }); } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author Gugle + */ + protected

    CreativeTabBuilder

    creativeTab(P parent, String name, Supplier icon) { + return entry(name, callback -> CreativeTabBuilder.create(this, parent, name, callback, icon)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author Gugle + */ + public CreativeTabBuilder creativeTab(String name, ItemLike icon) { + return creativeTab(self(), name, () -> icon); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author Gugle + */ + public CreativeTabBuilder creativeTab(String name, Supplier icon) { + return creativeTab(self(), name, icon); + } + + // Attachment Type + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + protected AttachmentBuilder attachment(P parent, String name, Function const_) { + return entry(name, callback -> new AttachmentBuilder<>(this, parent, name, callback, const_)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public AttachmentBuilder attachment(String name, Function const_) { + return attachment(self(), name, const_); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + protected AttachmentBuilder attachment(P parent, String name, Supplier const_) { + return entry(name, callback -> new AttachmentBuilder<>(this, parent, name, callback, const_)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public AttachmentBuilder attachment(String name, Supplier const_) { + return attachment(self(), name, const_); + } + + // Data Component Type + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + protected DataComponentBuilder dataComponent(P parent, String name) { + return entry(name, callback -> new DataComponentBuilder<>(this, parent, name, callback)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public DataComponentBuilder dataComponent(String name) { + return dataComponent(self(), name); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author Gugle + */ + public DataComponentBuilder dataComponent(String name, Class clazz) { + return dataComponent(self(), name); + } + + // Biome Modifier + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + protected BiomeModifierBuilder biomeModifier(P parent, String name, MapCodec codec) { + return entry(name, callback -> new BiomeModifierBuilder<>(this, parent, name, callback, codec)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public BiomeModifierBuilder biomeModifier(String name, MapCodec codec) { + return biomeModifier(self(), name, codec); + } + + // Global Loot Modifier + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + protected GlobalLootModifierBuilder glm(P parent, String name, MapCodec codec) { + return entry(name, callback -> new GlobalLootModifierBuilder<>(this, parent, name, callback, codec)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public GlobalLootModifierBuilder glm(String name, MapCodec codec) { + return glm(self(), name, codec); + } + + // Structure Modifier + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + protected StructureModifierBuilder structureModifier(P parent, String name, MapCodec codec) { + return entry(name, callback -> new StructureModifierBuilder<>(this, parent, name, callback, codec)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public StructureModifierBuilder structureModifier(String name, MapCodec codec) { + return structureModifier(self(), name, codec); + } + + // Condition + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + protected ConditionBuilder condition(P parent, String name, MapCodec codec) { + return entry(name, callback -> new ConditionBuilder<>(this, parent, name, callback, codec)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public ConditionBuilder condition(String name, MapCodec codec) { + return condition(self(), name, codec); + } + + // Sound Event + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + protected

    SoundEventBuilder

    soundEvent(P parent, String name) { + return selfEntry(name, callback -> new SoundEventBuilder<>(this, parent, name, callback)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + protected

    SoundEventBuilder

    soundEvent(P parent, String name, float fix) { + return selfEntry(name, callback -> new SoundEventBuilder<>(this, parent, name, callback, fix)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public SoundEventBuilder soundEvent(String name) { + return soundEvent(self(), name); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public SoundEventBuilder soundEvent(String name, float fix) { + return soundEvent(self(), name, fix); + } + + // Recipe + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public , P> RecipeEntry recipe( + P parent, + String name, + MapCodec codec, + StreamCodec streamCodec + ) { + return new RecipeEntry<>( + entry(name, callback -> new RecipeTypeBuilder(this, parent, name, callback)).register(), + entry(name, callback -> new RecipeSerializerBuilder<>(this, parent, name, callback, codec, streamCodec)).register() + ); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public > RecipeEntry recipe( + String name, + MapCodec codec, + StreamCodec streamCodec + ) { + return recipe(self(), name, codec, streamCodec); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public

    GameEventBuilder

    gameEvent(P parent, String name, int radius) { + return entry(name, callback -> new GameEventBuilder<>(this, parent, name, callback, radius)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public GameEventBuilder gameEvent(String name, int radius) { + return gameEvent(self(), name, radius); + } + + // Potion + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public

    PotionBuilder

    potion(P parent, String name, MobEffectInstance... effects) { + return selfEntry(name, callback -> new PotionBuilder<>(this, parent, name, callback, effects)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public PotionBuilder potion(String name, MobEffectInstance... effects) { + return potion(self(), name, effects); + } + + // Mob Effect + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public MobEffectBuilder mobEffect(P parent, String name, NonNullSupplier supplier) { + return entry(name, callback -> new MobEffectBuilder<>(this, parent, name, callback, supplier)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public MobEffectBuilder mobEffect(String name, NonNullSupplier supplier) { + return mobEffect(self(), name, supplier); + } + + // Villager Settings + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public

    VillagerTypeBuilder

    villager(P parent, String name) { + return selfEntry(name, callback -> new VillagerTypeBuilder<>(this, parent, name, callback)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public VillagerTypeBuilder villager(String name) { + return villager(self(), name); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public

    PoiTypeBuilder

    poi(P parent, + String name, + Supplier> matchingStates, + int maxTickets, + int validRange) { + return selfEntry(name, callback -> new PoiTypeBuilder<>(this, parent, name, callback, matchingStates, maxTickets, validRange)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public PoiTypeBuilder poi(String name, + Supplier> matchingStates, + int maxTickets, + int validRange) { + return poi(self(), name, matchingStates, maxTickets, validRange); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public

    VillagerProfessionBuilder

    profession(P parent, String name, Predicate> heldJobSite, + Predicate> acquirableJobSite, + ImmutableSet requestedItems, + ImmutableSet secondaryPoi, + @Nullable SoundEvent workSound) { + return selfEntry(name, callback -> new VillagerProfessionBuilder<>( + this, parent, name, callback, + heldJobSite, acquirableJobSite, + requestedItems, secondaryPoi, + workSound + )); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public VillagerProfessionBuilder profession(String name, Predicate> heldJobSite, + Predicate> acquirableJobSite, + ImmutableSet requestedItems, + ImmutableSet secondaryPoi, + @Nullable SoundEvent workSound) { + return profession(self(), name, heldJobSite, acquirableJobSite, requestedItems, secondaryPoi, workSound); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public

    VillagerProfessionBuilder

    profession(P parent, + String name, + Predicate> heldJobSite, + Predicate> acquirableJobSite, + @Nullable SoundEvent workSound) { + return selfEntry(name, callback -> new VillagerProfessionBuilder<>( + this, parent, name, callback, + heldJobSite, acquirableJobSite, + workSound + )); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public VillagerProfessionBuilder profession(String name, + Predicate> heldJobSite, + Predicate> acquirableJobSite, + @Nullable SoundEvent workSound) { + return profession(self(), name, heldJobSite, acquirableJobSite, workSound); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public

    VillagerProfessionBuilder

    profession(P parent, + String name, + ResourceKey jobSite, + @Nullable SoundEvent workSound) { + return selfEntry(name, callback -> new VillagerProfessionBuilder<>(this, parent, name, callback, jobSite, workSound)); + } + + /** + * Release under the MIT License. The full license text is available at this + * + * @author baka4n + */ + public VillagerProfessionBuilder profession(String name, + ResourceKey jobSite, + @Nullable SoundEvent workSound) { + return profession(self(), name, jobSite, workSound); + } + } diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/Builder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/Builder.java index 7c5cf93f..a134bd19 100644 --- a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/Builder.java +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/Builder.java @@ -18,9 +18,11 @@ import dev.anvilcraft.lib.v2.registrum.providers.RegistrumProvider; import dev.anvilcraft.lib.v2.registrum.util.entry.RegistryEntry; import dev.anvilcraft.lib.v2.util.nullness.NonNullBiConsumer; +import dev.anvilcraft.lib.v2.util.nullness.NonNullBiFunction; import dev.anvilcraft.lib.v2.util.nullness.NonNullConsumer; import dev.anvilcraft.lib.v2.util.nullness.NonNullFunction; import dev.anvilcraft.lib.v2.util.nullness.NonNullSupplier; +import net.minecraft.core.HolderLookup; import net.minecraft.core.Registry; import net.minecraft.resources.ResourceKey; import net.neoforged.neoforge.registries.datamaps.DataMapType; @@ -169,6 +171,22 @@ default S dataMap(DataMapType type, NonNullFunction + * Note: 1.21.1 的 {@code DataMapProvider.gather()} 无参数重载已被弃用;此处通过 + * {@link dev.anvilcraft.lib.v2.registrum.providers.RegistrumDataMapProvider#getProvider()} 获取 + * {@link HolderLookup.Provider}(datagen 线程内安全)。 + */ + @SuppressWarnings("unchecked") + default S dataMap(DataMapType type, NonNullBiFunction, HolderLookup.Provider, D> factory) { + getOwner().addDataGenerator(ProviderType.DATA_MAP, e -> { + var ctx = DataGenContext.from(this); + e.builder(type).add(ctx.getId(), factory.apply(ctx, e.getProvider()), false); + }); + return (S) this; + } + /** * Add a callback to be invoked when this entry is registered. Can be called multiple times to add multiple callbacks. *

    diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/ConditionBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/ConditionBuilder.java new file mode 100644 index 00000000..7a5fd790 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/ConditionBuilder.java @@ -0,0 +1,57 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.builders; + +import com.mojang.serialization.MapCodec; +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.util.entry.ConditionEntry; +import net.neoforged.neoforge.common.conditions.ICondition; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.NeoForgeRegistries; + +public class ConditionBuilder + extends AbstractBuilder, MapCodec, P, ConditionBuilder> { + private final MapCodec mapCodec; + + public ConditionBuilder(AbstractRegistrum owner, P parent, String name, BuilderCallback callback, MapCodec mapCodec) { + super(owner, parent, name, callback, NeoForgeRegistries.Keys.CONDITION_CODECS); + this.mapCodec = mapCodec; + } + + @Override + protected MapCodec createEntry() { + return mapCodec; + } + + @Override + public ConditionEntry register() { + return (ConditionEntry) super.register(); + } + + @Override + protected ConditionEntry createEntryWrapper(DeferredHolder, MapCodec> delegate) { + return new ConditionEntry<>(getOwner(), delegate); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/CreativeTabBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/CreativeTabBuilder.java new file mode 100644 index 00000000..b034e71a --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/CreativeTabBuilder.java @@ -0,0 +1,200 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.builders; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import net.minecraft.Util; +import net.minecraft.core.Holder; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.CreativeModeTab; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ItemLike; +import net.neoforged.neoforge.registries.DeferredHolder; + +import java.util.Collection; +import java.util.function.Function; +import java.util.function.Supplier; + +@SuppressWarnings( + { + "unused", + "UnusedReturnValue" + } +) +public class CreativeTabBuilder

    extends AbstractBuilder> { + private CreativeModeTab.Builder builder = CreativeModeTab.builder(); + + public CreativeTabBuilder(AbstractRegistrum owner, P parent, String name, BuilderCallback callback) { + super(owner, parent, name, callback, Registries.CREATIVE_MODE_TAB); + } + + public static

    CreativeTabBuilder

    create( + AbstractRegistrum owner, + P parent, + String name, + BuilderCallback callback, + Supplier icon + ) { + return new CreativeTabBuilder<>(owner, parent, name, callback).defaultTitle().icon(() -> icon.get().asItem().getDefaultInstance()); + } + + public CreativeTabBuilder

    defaultTitle() { + // 1.21.1 的 AbstractBuilder 无 getResourceKey(),直接由 modid + name 构造条目 id + String descriptionId = Util.makeDescriptionId("itemGroup", ResourceLocation.fromNamespaceAndPath(getOwner().getModid(), getName())); + lang(ignored -> descriptionId); + this.title(Component.translatable(descriptionId)); + return this; + } + + public CreativeTabBuilder

    title(Component displayName) { + this.builder = this.builder.title(displayName); + return this; + } + + public CreativeTabBuilder

    icon(Supplier iconGenerator) { + this.builder = this.builder.icon(iconGenerator); + return this; + } + + public CreativeTabBuilder

    displayItems(CreativeModeTab.DisplayItemsGenerator displayItemsGenerator) { + this.builder = this.builder.displayItems(displayItemsGenerator); + return this; + } + + public CreativeTabBuilder

    alignedRight() { + this.builder = this.builder.alignedRight(); + return this; + } + + public CreativeTabBuilder

    hideTitle() { + this.builder = this.builder.hideTitle(); + return this; + } + + public CreativeTabBuilder

    noScrollBar() { + this.builder = this.builder.noScrollBar(); + return this; + } + + public CreativeTabBuilder

    backgroundTexture(ResourceLocation backgroundTexture) { + this.builder = this.builder.backgroundTexture(backgroundTexture); + return this; + } + + public CreativeTabBuilder

    withSearchBar() { + this.builder = this.builder.withSearchBar(); + return this; + } + + public CreativeTabBuilder

    withSearchBar(int searchBarWidth) { + this.builder = this.builder.withSearchBar(searchBarWidth); + return this; + } + + public CreativeTabBuilder

    withScrollBarSpriteLocation(ResourceLocation scrollBarSpriteLocation) { + this.builder = this.builder.withScrollBarSpriteLocation(scrollBarSpriteLocation); + return this; + } + + public CreativeTabBuilder

    withTabsImage(ResourceLocation tabsImage) { + this.builder = this.builder.withTabsImage(tabsImage); + return this; + } + + public CreativeTabBuilder

    withLabelColor(int labelColor) { + this.builder = this.builder.withLabelColor(labelColor); + return this; + } + + public CreativeTabBuilder

    withTabFactory(Function tabFactory) { + this.builder = this.builder.withTabFactory(tabFactory); + return this; + } + + public CreativeTabBuilder

    withTabsBefore(ResourceLocation... tabs) { + this.builder = this.builder.withTabsBefore(tabs); + return this; + } + + public CreativeTabBuilder

    withTabsAfter(ResourceLocation... tabs) { + this.builder = this.builder.withTabsAfter(tabs); + return this; + } + + @SafeVarargs + public final CreativeTabBuilder

    withTabsBefore(DeferredHolder... tabs) { + @SuppressWarnings("unchecked") ResourceKey[] array = (ResourceKey[]) new ResourceKey[tabs.length]; + for (int i = 0; i < tabs.length; i++) { + array[i] = tabs[i].getKey(); + } + this.withTabsBefore(array); + return this; + } + + @SafeVarargs + public final CreativeTabBuilder

    withTabsAfter(DeferredHolder... tabs) { + @SuppressWarnings("unchecked") ResourceKey[] array = (ResourceKey[]) new ResourceKey[tabs.length]; + for (int i = 0; i < tabs.length; i++) { + array[i] = tabs[i].getKey(); + } + this.withTabsAfter(array); + return this; + } + + @SafeVarargs + public final CreativeTabBuilder

    withTabsBefore(ResourceKey... tabs) { + this.builder = this.builder.withTabsBefore(tabs); + return this; + } + + @SafeVarargs + public final CreativeTabBuilder

    withTabsAfter(ResourceKey... tabs) { + this.builder = this.builder.withTabsAfter(tabs); + return this; + } + + public CreativeTabBuilder

    displayItems(Collection> collection) { + this.builder = this.builder.displayItems(collection); + return this; + } + + + public CreativeTabBuilder

    displayItems(ItemLike... itemLikes) { + this.displayItems((parameters, output) -> { + for (ItemLike itemLike : itemLikes) { + output.accept(itemLike); + } + }); + return this; + } + + @Override + protected CreativeModeTab createEntry() { + return this.builder.build(); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/GameEventBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/GameEventBuilder.java new file mode 100644 index 00000000..afe7ad9c --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/GameEventBuilder.java @@ -0,0 +1,30 @@ +package dev.anvilcraft.lib.v2.registrum.builders; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.util.entry.GameEventEntry; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.gameevent.GameEvent; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class GameEventBuilder

    extends AbstractBuilder> { + final int radius; + public GameEventBuilder(AbstractRegistrum owner, P parent, String name, BuilderCallback callback, int radius) { + super(owner, parent, name, callback, Registries.GAME_EVENT); + this.radius = radius; + } + + @Override + public GameEventEntry register() { + return (GameEventEntry) super.register(); + } + + @Override + protected GameEventEntry createEntryWrapper(DeferredHolder delegate) { + return new GameEventEntry(getOwner(), delegate); + } + + @Override + protected GameEvent createEntry() { + return new GameEvent(radius); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/MobEffectBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/MobEffectBuilder.java new file mode 100644 index 00000000..c0c78171 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/MobEffectBuilder.java @@ -0,0 +1,35 @@ +package dev.anvilcraft.lib.v2.registrum.builders; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.util.entry.MobEffectEntry; +import dev.anvilcraft.lib.v2.util.nullness.NonNullSupplier; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.effect.MobEffect; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class MobEffectBuilder extends AbstractBuilder> { + final NonNullSupplier supplier; + public MobEffectBuilder(AbstractRegistrum owner, + P parent, + String name, + BuilderCallback callback, + NonNullSupplier supplier) { + super(owner, parent, name, callback, Registries.MOB_EFFECT); + this.supplier = supplier; + } + + @Override + public MobEffectEntry register() { + return (MobEffectEntry) super.register(); + } + + @Override + protected MobEffectEntry createEntryWrapper(DeferredHolder delegate) { + return new MobEffectEntry<>(getOwner(), delegate); + } + + @Override + protected T createEntry() { + return supplier.get(); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/SelfBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/SelfBuilder.java new file mode 100644 index 00000000..da117a4c --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/SelfBuilder.java @@ -0,0 +1,31 @@ +package dev.anvilcraft.lib.v2.registrum.builders; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.util.entry.SelfEntry; +import dev.anvilcraft.lib.v2.util.nullness.NonNullSupplier; +import net.minecraft.core.Registry; +import net.minecraft.resources.ResourceKey; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class SelfBuilder> extends AbstractBuilder { + final NonNullSupplier factory; + public SelfBuilder(AbstractRegistrum owner, P parent, String name, BuilderCallback callback, ResourceKey> registryType, NonNullSupplier factory) { + super(owner, parent, name, callback, registryType); + this.factory = factory; + } + + @Override + protected T createEntry() { + return factory.get(); + } + + @Override + public SelfEntry register() { + return (SelfEntry) super.register(); + } + + @Override + protected SelfEntry createEntryWrapper(DeferredHolder delegate) { + return new SelfEntry<>(getOwner(), delegate); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/data/AttachmentBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/data/AttachmentBuilder.java new file mode 100644 index 00000000..1810a704 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/data/AttachmentBuilder.java @@ -0,0 +1,134 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.builders.data; + +import com.mojang.serialization.Codec; +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.builders.AbstractBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.BuilderCallback; +import dev.anvilcraft.lib.v2.registrum.util.entry.data.AttachmentEntry; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.neoforge.attachment.AttachmentSyncHandler; +import net.neoforged.neoforge.attachment.AttachmentType; +import net.neoforged.neoforge.attachment.IAttachmentCopyHandler; +import net.neoforged.neoforge.attachment.IAttachmentHolder; +import net.neoforged.neoforge.attachment.IAttachmentSerializer; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.NeoForgeRegistries; + +import java.util.function.BiPredicate; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +@SuppressWarnings("unused") +public class AttachmentBuilder extends AbstractBuilder, AttachmentType, P, AttachmentBuilder> { + + private final AttachmentType.Builder builder; + + public AttachmentBuilder( + AbstractRegistrum owner, + P parent, + String name, + BuilderCallback callback, + Function const_ + ) { + super(owner, parent, name, callback, NeoForgeRegistries.Keys.ATTACHMENT_TYPES); + this.builder = AttachmentType.builder(const_); + } + + public AttachmentBuilder(AbstractRegistrum owner, P parent, String name, BuilderCallback callback, Supplier const_) { + super(owner, parent, name, callback, NeoForgeRegistries.Keys.ATTACHMENT_TYPES); + this.builder = AttachmentType.builder(const_); + } + + public AttachmentBuilder serialize(IAttachmentSerializer serializer) { + builder.serialize(serializer); + return this; + } + + /** + * 以 {@link Codec} 序列化附件。

    + * 注:1.21.1 的 {@link AttachmentType.Builder#serialize(Codec)} 参数为 {@link Codec}(26.1 才改为 MapCodec)。 + */ + public AttachmentBuilder serialize(Codec serializer) { + builder.serialize(serializer); + return this; + } + + /** + * 以 {@link Codec} 序列化附件,并附带谓词判断是否应序列化。

    + * 注:1.21.1 的 {@link AttachmentType.Builder#serialize(Codec, Predicate)} 参数为 {@link Codec}(26.1 才改为 MapCodec)。 + */ + public AttachmentBuilder serialize(Codec serializer, Predicate predicate) { + builder.serialize(serializer, predicate); + return this; + } + + public AttachmentBuilder copyOnDeath() { + builder.copyOnDeath(); + return this; + } + + public AttachmentBuilder copyHandler(IAttachmentCopyHandler cloner) { + builder.copyHandler(cloner); + return this; + } + + public AttachmentBuilder sync(AttachmentSyncHandler syncHandler) { + builder.sync(syncHandler); + return this; + } + + public AttachmentBuilder sync(StreamCodec streamCodec) { + builder.sync(streamCodec); + return this; + } + + public AttachmentBuilder sync( + BiPredicate sendToPlayer, + StreamCodec streamCodec + ) { + builder.sync(sendToPlayer, streamCodec); + return this; + } + + @Override + public AttachmentEntry register() { + return (AttachmentEntry) super.register(); + } + + @Override + protected AttachmentEntry createEntryWrapper(DeferredHolder, AttachmentType> delegate) { + return new AttachmentEntry<>(getOwner(), delegate); + } + + @Override + protected AttachmentType createEntry() { + return builder.build(); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/data/DataComponentBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/data/DataComponentBuilder.java new file mode 100644 index 00000000..35f32af4 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/data/DataComponentBuilder.java @@ -0,0 +1,76 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.builders.data; + +import com.mojang.serialization.Codec; +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.builders.AbstractBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.BuilderCallback; +import dev.anvilcraft.lib.v2.registrum.util.entry.data.DataComponentEntry; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.neoforged.neoforge.registries.DeferredHolder; + +@SuppressWarnings("unused") +public class DataComponentBuilder extends AbstractBuilder, DataComponentType, P, DataComponentBuilder> { + final DataComponentType.Builder builder; + + public DataComponentBuilder(AbstractRegistrum owner, P parent, String name, BuilderCallback callback) { + super(owner, parent, name, callback, Registries.DATA_COMPONENT_TYPE); + builder = DataComponentType.builder(); + } + + public DataComponentBuilder persistent(Codec codec) { + builder.persistent(codec); + return this; + } + + public DataComponentBuilder networkSynchronized(StreamCodec streamCodec) { + builder.networkSynchronized(streamCodec); + return this; + } + + public DataComponentBuilder cacheEncoding() { + builder.cacheEncoding(); + return this; + } + + @Override + protected DataComponentType createEntry() { + return builder.build(); + } + + @Override + public DataComponentEntry register() { + return (DataComponentEntry) super.register(); + } + + @Override + protected DataComponentEntry createEntryWrapper(DeferredHolder, DataComponentType> delegate) { + return new DataComponentEntry<>(getOwner(), delegate); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/data/package-info.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/data/package-info.java new file mode 100644 index 00000000..1729a063 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/data/package-info.java @@ -0,0 +1,22 @@ +/* + * + * * Original work copyright (c) 2019 tterrag1098 (Registrate) + * * Additional modifications copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * * + * * This Source Code Form is subject to the terms of the Mozilla Public + * * License, v. 2.0. If a copy of the MPL was not distributed with this + * * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * * + * * Original File: https://github.com/tterrag1098/Registrate/blob/1.21.5/dev/src/main/java/com/tterrag/registrate/builders/package-info.java + * + */ + +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.registrum.builders.data; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/modifier/BiomeModifierBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/modifier/BiomeModifierBuilder.java new file mode 100644 index 00000000..a41d9860 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/modifier/BiomeModifierBuilder.java @@ -0,0 +1,59 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.builders.modifier; + +import com.mojang.serialization.MapCodec; +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.builders.AbstractBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.BuilderCallback; +import dev.anvilcraft.lib.v2.registrum.util.entry.modifier.BiomeModifierEntry; +import net.neoforged.neoforge.common.world.BiomeModifier; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.NeoForgeRegistries; + +public class BiomeModifierBuilder + extends AbstractBuilder, MapCodec, P, BiomeModifierBuilder> { + private final MapCodec mapCodec; + + public BiomeModifierBuilder(AbstractRegistrum owner, P parent, String name, BuilderCallback callback, MapCodec mapCodec) { + super(owner, parent, name, callback, NeoForgeRegistries.Keys.BIOME_MODIFIER_SERIALIZERS); + this.mapCodec = mapCodec; + } + + @Override + protected MapCodec createEntry() { + return mapCodec; + } + + @Override + public BiomeModifierEntry register() { + return (BiomeModifierEntry) super.register(); + } + + @Override + protected BiomeModifierEntry createEntryWrapper(DeferredHolder, MapCodec> delegate) { + return new BiomeModifierEntry<>(getOwner(), delegate); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/modifier/GlobalLootModifierBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/modifier/GlobalLootModifierBuilder.java new file mode 100644 index 00000000..913dcd8c --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/modifier/GlobalLootModifierBuilder.java @@ -0,0 +1,60 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.builders.modifier; + +import com.mojang.serialization.MapCodec; +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.builders.AbstractBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.BuilderCallback; +import dev.anvilcraft.lib.v2.registrum.util.entry.modifier.GlobalLootModifierEntry; +import net.neoforged.neoforge.common.loot.IGlobalLootModifier; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.NeoForgeRegistries; + +public class GlobalLootModifierBuilder + extends AbstractBuilder, MapCodec, P, GlobalLootModifierBuilder> { + + final MapCodec codec; + + public GlobalLootModifierBuilder(AbstractRegistrum owner, P parent, String name, BuilderCallback callback, MapCodec codec) { + super(owner, parent, name, callback, NeoForgeRegistries.Keys.GLOBAL_LOOT_MODIFIER_SERIALIZERS); + this.codec = codec; + } + + @Override + protected MapCodec createEntry() { + return codec; + } + + @Override + public GlobalLootModifierEntry register() { + return (GlobalLootModifierEntry) super.register(); + } + + @Override + protected GlobalLootModifierEntry createEntryWrapper(DeferredHolder, MapCodec> delegate) { + return new GlobalLootModifierEntry<>(getOwner(), delegate); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/modifier/StructureModifierBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/modifier/StructureModifierBuilder.java new file mode 100644 index 00000000..c343e6c8 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/modifier/StructureModifierBuilder.java @@ -0,0 +1,60 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.builders.modifier; + +import com.mojang.serialization.MapCodec; +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.builders.AbstractBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.BuilderCallback; +import dev.anvilcraft.lib.v2.registrum.util.entry.modifier.StructureModifierEntry; +import net.neoforged.neoforge.common.world.StructureModifier; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.NeoForgeRegistries; + +public class StructureModifierBuilder + extends AbstractBuilder, MapCodec, P, StructureModifierBuilder> { + + final MapCodec codec; + + public StructureModifierBuilder(AbstractRegistrum owner, P parent, String name, BuilderCallback callback, MapCodec codec) { + super(owner, parent, name, callback, NeoForgeRegistries.Keys.STRUCTURE_MODIFIER_SERIALIZERS); + this.codec = codec; + } + + @Override + protected MapCodec createEntry() { + return codec; + } + + @Override + public StructureModifierEntry register() { + return (StructureModifierEntry) super.register(); + } + + @Override + protected StructureModifierEntry createEntryWrapper(DeferredHolder, MapCodec> delegate) { + return new StructureModifierEntry<>(getOwner(), delegate); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/modifier/package-info.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/modifier/package-info.java new file mode 100644 index 00000000..902461a2 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/modifier/package-info.java @@ -0,0 +1,21 @@ +/* + * + * * Original work copyright (c) 2019 tterrag1098 (Registrate) + * * Additional modifications copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * * + * * This Source Code Form is subject to the terms of the Mozilla Public + * * License, v. 2.0. If a copy of the MPL was not distributed with this + * * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * * + * * Original File: https://github.com/tterrag1098/Registrate/blob/1.21.5/dev/src/main/java/com/tterrag/registrate/builders/package-info.java + * + */ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.registrum.builders.modifier; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/recipe/RecipeSerializerBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/recipe/RecipeSerializerBuilder.java new file mode 100644 index 00000000..3c1ebc1b --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/recipe/RecipeSerializerBuilder.java @@ -0,0 +1,82 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.builders.recipe; + +import com.mojang.serialization.MapCodec; +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.builders.AbstractBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.BuilderCallback; +import dev.anvilcraft.lib.v2.registrum.util.entry.recipe.RecipeSerializerEntry; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.world.item.crafting.Recipe; +import net.minecraft.world.item.crafting.RecipeSerializer; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class RecipeSerializerBuilder, P> + extends AbstractBuilder, RecipeSerializer, P, RecipeSerializerBuilder> { + private final MapCodec codec; + private final StreamCodec streamCodec; + + public RecipeSerializerBuilder( + AbstractRegistrum owner, + P parent, + String name, + BuilderCallback callback, + MapCodec codec, + StreamCodec streamCodec + ) { + super(owner, parent, name, callback, Registries.RECIPE_SERIALIZER); + this.codec = codec; + this.streamCodec = streamCodec; + } + + @Override + public RecipeSerializerEntry register() { + return (RecipeSerializerEntry) super.register(); + } + + @Override + protected RecipeSerializerEntry createEntryWrapper(DeferredHolder, RecipeSerializer> delegate) { + return new RecipeSerializerEntry<>(getOwner(), delegate); + } + + @Override + protected RecipeSerializer createEntry() { + // 1.21.1: RecipeSerializer 是接口(含 codec()/streamCodec()),不能 new RecipeSerializer<>(...),须匿名类实现 + return new RecipeSerializer() { + @Override + public MapCodec codec() { + return codec; + } + + @Override + public StreamCodec streamCodec() { + return streamCodec; + } + }; + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/recipe/RecipeTypeBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/recipe/RecipeTypeBuilder.java new file mode 100644 index 00000000..d300cab5 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/recipe/RecipeTypeBuilder.java @@ -0,0 +1,60 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.builders.recipe; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.builders.AbstractBuilder; +import dev.anvilcraft.lib.v2.registrum.builders.BuilderCallback; +import dev.anvilcraft.lib.v2.registrum.util.entry.recipe.RecipeTypeEntry; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.item.crafting.Recipe; +import net.minecraft.world.item.crafting.RecipeType; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class RecipeTypeBuilder, P> extends AbstractBuilder, RecipeType, P, RecipeTypeBuilder> { + public RecipeTypeBuilder(AbstractRegistrum owner, P parent, String name, BuilderCallback callback) { + super(owner, parent, name, callback, Registries.RECIPE_TYPE); + } + + @Override + protected RecipeType createEntry() { + return new RecipeType() { + @Override + public String toString() { + return getOwner().getModid() + ":" + getName(); + } + }; + } + + @Override + public RecipeTypeEntry register() { + return (RecipeTypeEntry) super.register(); + } + + @Override + protected RecipeTypeEntry createEntryWrapper(DeferredHolder, RecipeType> delegate) { + return new RecipeTypeEntry<>(getOwner(), delegate); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/recipe/package-info.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/recipe/package-info.java new file mode 100644 index 00000000..c90321f4 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/recipe/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.registrum.builders.recipe; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/self/PotionBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/self/PotionBuilder.java new file mode 100644 index 00000000..9c44ff11 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/self/PotionBuilder.java @@ -0,0 +1,31 @@ +package dev.anvilcraft.lib.v2.registrum.builders.self; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.builders.BuilderCallback; +import dev.anvilcraft.lib.v2.registrum.builders.SelfBuilder; +import dev.anvilcraft.lib.v2.registrum.util.entry.PotionEntry; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.item.alchemy.Potion; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class PotionBuilder

    extends SelfBuilder> { + public PotionBuilder(AbstractRegistrum owner, + P parent, + String name, + BuilderCallback callback, + MobEffectInstance... effects) { + super(owner, parent, name, callback, Registries.POTION, () -> new Potion(owner.getModid() + ":" + name, effects)); + + } + + @Override + public PotionEntry register() { + return (PotionEntry) super.register(); + } + + @Override + protected PotionEntry createEntryWrapper(DeferredHolder delegate) { + return new PotionEntry(getOwner(), delegate); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/self/SoundEventBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/self/SoundEventBuilder.java new file mode 100644 index 00000000..47a483fd --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/self/SoundEventBuilder.java @@ -0,0 +1,60 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.builders.self; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.builders.BuilderCallback; +import dev.anvilcraft.lib.v2.registrum.builders.SelfBuilder; +import dev.anvilcraft.lib.v2.registrum.util.entry.SoundEventEntry; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundEvent; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class SoundEventBuilder

    extends SelfBuilder> { + public SoundEventBuilder(AbstractRegistrum owner, + P parent, + String name, + BuilderCallback callback) { + super(owner, parent, name, callback, Registries.SOUND_EVENT, () -> SoundEvent.createVariableRangeEvent(ResourceLocation.fromNamespaceAndPath(owner.getModid(), name))); + } + public SoundEventBuilder(AbstractRegistrum owner, + P parent, + String name, + BuilderCallback callback, + float fix) { + super(owner, parent, name, callback, Registries.SOUND_EVENT, () -> SoundEvent.createFixedRangeEvent(ResourceLocation.fromNamespaceAndPath(owner.getModid(), name), fix)); + } + + @Override + public SoundEventEntry register() { + return (SoundEventEntry) super.register(); + } + + @Override + protected SoundEventEntry createEntryWrapper(DeferredHolder delegate) { + return new SoundEventEntry(getOwner(), delegate); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/self/package-info.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/self/package-info.java new file mode 100644 index 00000000..a476738a --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/self/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.registrum.builders.self; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/villager/PoiTypeBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/villager/PoiTypeBuilder.java new file mode 100644 index 00000000..88b47852 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/villager/PoiTypeBuilder.java @@ -0,0 +1,23 @@ +package dev.anvilcraft.lib.v2.registrum.builders.villager; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.builders.BuilderCallback; +import dev.anvilcraft.lib.v2.registrum.builders.SelfBuilder; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.entity.ai.village.poi.PoiType; +import net.minecraft.world.level.block.state.BlockState; + +import java.util.Set; +import java.util.function.Supplier; + +public class PoiTypeBuilder

    extends SelfBuilder> { + public PoiTypeBuilder(AbstractRegistrum owner, + P parent, + String name, + BuilderCallback callback, + Supplier> matchingStates, + int maxTickets, + int validRange) { + super(owner, parent, name, callback, Registries.POINT_OF_INTEREST_TYPE, () -> new PoiType(matchingStates.get(), maxTickets, validRange)); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/villager/VillagerProfessionBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/villager/VillagerProfessionBuilder.java new file mode 100644 index 00000000..d1fe51ed --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/villager/VillagerProfessionBuilder.java @@ -0,0 +1,69 @@ +package dev.anvilcraft.lib.v2.registrum.builders.villager; + +import com.google.common.collect.ImmutableSet; +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.builders.BuilderCallback; +import dev.anvilcraft.lib.v2.registrum.builders.SelfBuilder; +import net.minecraft.core.Holder; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.world.entity.ai.village.poi.PoiType; +import net.minecraft.world.entity.npc.VillagerProfession; +import net.minecraft.world.item.Item; +import net.minecraft.world.level.block.Block; +import javax.annotation.Nullable; + +import java.util.function.Predicate; + +/** + * 注册村民职业。

    + * 注:1.21.1 的 {@link VillagerProfession} 是 6 元 record + * {@code (String name, Predicate> heldJobSite, Predicate> acquirableJobSite, + * ImmutableSet requestedItems, ImmutableSet secondaryPoi, @Nullable SoundEvent workSound)}, + * 无 26.1 的 tradeSetsByLevel(TradeSet)参数;第一参数传 {@code modid:name} 字符串(26.1 为翻译组件)。 + * 交易注册请使用 1.21.1 机制:监听 {@code VillagerTradesEvent} 或直接操作 {@code VillagerTrades.TRADES}。 + */ +public class VillagerProfessionBuilder

    extends SelfBuilder> { + public VillagerProfessionBuilder(AbstractRegistrum owner, + P parent, + String name, + BuilderCallback callback, + Predicate> heldJobSite, + Predicate> acquirableJobSite, + ImmutableSet requestedItems, + ImmutableSet secondaryPoi, + @Nullable SoundEvent workSound) { + super(owner, parent, name, callback, + Registries.VILLAGER_PROFESSION, () -> new VillagerProfession( + owner.getModid() + ":" + name, + heldJobSite, + acquirableJobSite, + requestedItems, + secondaryPoi, + workSound + )); + } + + public VillagerProfessionBuilder(AbstractRegistrum owner, + P parent, + String name, + BuilderCallback callback, + Predicate> heldJobSite, + Predicate> acquirableJobSite, + @Nullable SoundEvent workSound) { + this(owner, parent, name, callback, heldJobSite, acquirableJobSite, ImmutableSet.of(), ImmutableSet.of(), workSound); + } + + + + public VillagerProfessionBuilder(AbstractRegistrum owner, + P parent, + String name, + BuilderCallback callback, + ResourceKey jobSite, + @Nullable SoundEvent workSound + ) { + this(owner, parent, name, callback, h -> h.is(jobSite), h -> h.is(jobSite), workSound); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/villager/VillagerTypeBuilder.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/villager/VillagerTypeBuilder.java new file mode 100644 index 00000000..8c6219d5 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/villager/VillagerTypeBuilder.java @@ -0,0 +1,15 @@ +package dev.anvilcraft.lib.v2.registrum.builders.villager; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.builders.BuilderCallback; +import dev.anvilcraft.lib.v2.registrum.builders.SelfBuilder; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.entity.npc.VillagerType; + +public class VillagerTypeBuilder

    extends SelfBuilder> { + + public VillagerTypeBuilder(AbstractRegistrum owner, P parent, String name, BuilderCallback callback) { + // 1.21.1 的 VillagerType 仅有 String 构造,不能直接方法引用为 Supplier,须用 lambda + super(owner, parent, name, callback, Registries.VILLAGER_TYPE, () -> new VillagerType(name)); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/villager/package-info.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/villager/package-info.java new file mode 100644 index 00000000..e92abeef --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/builders/villager/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.registrum.builders.villager; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/providers/RegistrumDataMapProvider.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/providers/RegistrumDataMapProvider.java index 809c0b2c..57b164c0 100644 --- a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/providers/RegistrumDataMapProvider.java +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/providers/RegistrumDataMapProvider.java @@ -17,6 +17,7 @@ import net.minecraft.data.PackOutput; import net.neoforged.fml.LogicalSide; import net.neoforged.neoforge.common.data.DataMapProvider; +import javax.annotation.Nullable; import java.util.concurrent.CompletableFuture; @@ -24,6 +25,8 @@ public class RegistrumDataMapProvider extends DataMapProvider implements Registr private final AbstractRegistrum parent; + private @Nullable HolderLookup.Provider provider; + protected RegistrumDataMapProvider(AbstractRegistrum parent, PackOutput output, CompletableFuture pvd) { super(output, pvd); this.parent = parent; @@ -34,10 +37,26 @@ public LogicalSide getSide() { return LogicalSide.SERVER; } + /** + * Generate data map entries. + * + * @param provider HolderLookup.Provider + */ @Override - @SuppressWarnings("removal") - protected void gather() { - parent.genData(ProviderType.DATA_MAP, this); - } + protected void gather(HolderLookup.Provider provider) { + this.provider = provider; + parent.genData(ProviderType.DATA_MAP, this); + this.provider = null; + } + + /** + * 当前 datagen 运行中可用的 {@link HolderLookup.Provider}。 + * + * @throws IllegalStateException 若不在 {@link #gather(HolderLookup.Provider)} 调用期间 + */ + public HolderLookup.Provider getProvider() { + if (provider == null) throw new IllegalStateException("Holder Lookup Provider is not available now"); + return provider; + } } diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/providers/RegistrumRecipeProvider.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/providers/RegistrumRecipeProvider.java index f06ff69f..da81c313 100644 --- a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/providers/RegistrumRecipeProvider.java +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/providers/RegistrumRecipeProvider.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; +import java.util.stream.Stream; import javax.annotation.CheckReturnValue; import javax.annotation.Nullable; @@ -51,6 +52,8 @@ import net.minecraft.tags.TagKey; import net.minecraft.world.flag.FeatureFlagSet; import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; import net.minecraft.world.item.crafting.*; import net.minecraft.world.level.ItemLike; import net.minecraft.world.level.block.Block; @@ -336,6 +339,85 @@ public void trapDoor(DataIngredient source, RecipeCategory @Override public CompletableFuture buildAdvancement(CachedOutput p_253674_, HolderLookup.Provider p_323646_, AdvancementHolder p_301116_) { return super.buildAdvancement(p_253674_, p_323646_, p_301116_); } + // === 1.21.1 公开包装方法(内调 RecipeProvider 的 protected static 方法,而非 override) === + + public void oneToOneConversionRecipe(ItemLike product, ItemLike resource, @Nullable String group) { + RecipeProvider.oneToOneConversionRecipe(this, product, resource, group); + } + + public void oneToOneConversionRecipe(ItemLike product, ItemLike resource, @Nullable String group, int productCount) { + RecipeProvider.oneToOneConversionRecipe(this, product, resource, group, productCount); + } + + public void oreSmelting(List smeltables, RecipeCategory category, ItemLike result, float experience, int cookingTime, String group) { + RecipeProvider.oreSmelting(this, smeltables, category, result, experience, cookingTime, group); + } + + public void oreBlasting(List blasting, RecipeCategory category, ItemLike result, float experience, int cookingTime, String group) { + RecipeProvider.oreBlasting(this, blasting, category, result, experience, cookingTime, group); + } + + public void oreCooking(RecipeSerializer serializer, AbstractCookingRecipe.Factory factory, List smeltables, RecipeCategory craftingCategory, ItemLike result, float experience, int cookingTime, String group, String fromDesc) { + RecipeProvider.oreCooking(this, serializer, factory, smeltables, craftingCategory, result, experience, cookingTime, group, fromDesc); + } + + /** + * 书架类装饰方块配方:6 个去皮原木 → 6 个 shelf。

    + * 注:26.1 的 vanilla {@code RecipeProvider.shelf} 在 1.21.1 不存在,此处按其配方手动实现。 + */ + public void shelf(ItemLike result, ItemLike strippedLogs) { + ShapedRecipeBuilder.shaped(RecipeCategory.DECORATIONS, result, 6) + .define('#', strippedLogs) + .pattern("###") + .pattern(" ") + .pattern("###") + .group("shelf") + .unlockedBy(getHasName(strippedLogs), has(strippedLogs)) + .save(this, safeId(result)); + } + + /** + * 注:26.1 的 vanilla {@code RecipeProvider.colorItemWithDye} 在 1.21.1 不存在,此处按其配方手动实现。 + */ + public void colorItemWithDye(List dyeItems, List dyeableItems, String group, RecipeCategory category) { + colorWithDye(dyeItems, dyeableItems, null, group, category); + } + + /** + * 注:26.1 的 vanilla {@code RecipeProvider.colorWithDye} 在 1.21.1 不存在,此处按其配方手动实现。 + */ + public void colorWithDye(List dyes, List dyeableItems, @Nullable Item dye, String group, RecipeCategory category) { + for (int i = 0; i < dyes.size(); i++) { + Item dyeItem = dyes.get(i); + Item dyeableItem = dyeableItems.get(i); + Stream ingredient = dyeableItems.stream().filter(dyeableItem::equals); + if (dye != null) { + ingredient = Stream.concat(ingredient, Stream.of(dye)); + } + ShapelessRecipeBuilder.shapeless(category, dyeableItem) + .requires(dyeItem) + .requires(Ingredient.of(ingredient.map(ItemStack::new))) + .group(group) + .unlockedBy("has_needed_dye", has(dyeItem)) + .save(this, "dye_" + getItemName(dyeableItem)); + } + } + + /** + * 注:26.1 的 vanilla {@code RecipeProvider.dryGhast} 在 1.21.1 不存在,此处按其配方手动实现。 + */ + public void dryGhast(ItemLike result) { + ShapedRecipeBuilder.shaped(RecipeCategory.BUILDING_BLOCKS, result) + .define('#', Items.GHAST_TEAR) + .define('X', Items.SOUL_SAND) + .pattern("###") + .pattern("#X#") + .pattern("###") + .group("dry_ghast") + .unlockedBy(getHasName(Items.GHAST_TEAR), has(Items.GHAST_TEAR)) + .save(this, safeId(result)); + } + @Override public CompletableFuture buildAdvancement(CachedOutput p_253674_, HolderLookup.Provider p_323646_, AdvancementHolder p_301116_, ICondition... conditions) { return super.buildAdvancement(p_253674_, p_323646_, p_301116_, conditions); } diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/OneTimeEventReceiver.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/OneTimeEventReceiver.java index 686fd494..7cf99c25 100644 --- a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/OneTimeEventReceiver.java +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/OneTimeEventReceiver.java @@ -46,7 +46,9 @@ public static void addModListener(AbstractRegis if (!waitingModListeners.contains(owner, evtClass)) { waitingModListeners.put(owner, evtClass, new ArrayList<>()); } - waitingModListeners.get(owner, evtClass).add(Pair.of(priority, listener)); + List>> pairs = waitingModListeners.get(owner, evtClass); + if (pairs == null) return; + pairs.add(Pair.of(priority, listener)); return; } if (!seenModBus) { @@ -112,7 +114,9 @@ private static synchronized void unregister(IEventBus bus, Object listener, Clas private static void onLoadComplete(FMLLoadCompleteEvent event) { event.enqueueWork(() -> { - toUnregister.forEach(t -> t.getLeft().unregister(t.getMiddle())); + toUnregister.forEach(t -> { + if (t.getLeft() != null) t.getLeft().unregister(t.getMiddle()); + }); toUnregister.clear(); }); } diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/ConditionEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/ConditionEntry.java new file mode 100644 index 00000000..6ebf0579 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/ConditionEntry.java @@ -0,0 +1,40 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.util.entry; + +import com.mojang.serialization.MapCodec; +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import net.neoforged.neoforge.common.conditions.ICondition; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class ConditionEntry extends RegistryEntry, MapCodec> { + public ConditionEntry(AbstractRegistrum owner, DeferredHolder, MapCodec> key) { + super(owner, key); + } + + public static ConditionEntry cast(RegistryEntry, MapCodec> entry) { + return RegistryEntry.cast(ConditionEntry.class, entry); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/GameEventEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/GameEventEntry.java new file mode 100644 index 00000000..e7f61bf4 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/GameEventEntry.java @@ -0,0 +1,41 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.util.entry; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import net.minecraft.world.level.gameevent.GameEvent; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class GameEventEntry extends RegistryEntry { + public GameEventEntry(AbstractRegistrum owner, DeferredHolder key) { + super(owner, key); + } + + public static GameEventEntry cast(RegistryEntry key) { + return RegistryEntry.cast(GameEventEntry.class, key); + } + + +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/MobEffectEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/MobEffectEntry.java new file mode 100644 index 00000000..03f5ca08 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/MobEffectEntry.java @@ -0,0 +1,15 @@ +package dev.anvilcraft.lib.v2.registrum.util.entry; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import net.minecraft.world.effect.MobEffect; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class MobEffectEntry extends RegistryEntry { + public MobEffectEntry(AbstractRegistrum owner, DeferredHolder key) { + super(owner, key); + } + + public static MobEffectEntry cast(RegistryEntry entry) { + return RegistryEntry.cast(MobEffectEntry.class, entry); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/PotionEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/PotionEntry.java new file mode 100644 index 00000000..adee99c0 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/PotionEntry.java @@ -0,0 +1,15 @@ +package dev.anvilcraft.lib.v2.registrum.util.entry; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import net.minecraft.world.item.alchemy.Potion; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class PotionEntry extends SelfEntry { + public PotionEntry(AbstractRegistrum owner, DeferredHolder key) { + super(owner, key); + } + + public static PotionEntry cast(SelfEntry entry) { + return SelfEntry.cast(PotionEntry.class, entry); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/RecipeEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/RecipeEntry.java new file mode 100644 index 00000000..0d91eb7f --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/RecipeEntry.java @@ -0,0 +1,9 @@ +package dev.anvilcraft.lib.v2.registrum.util.entry; + +import dev.anvilcraft.lib.v2.registrum.util.entry.recipe.RecipeSerializerEntry; +import dev.anvilcraft.lib.v2.registrum.util.entry.recipe.RecipeTypeEntry; +import net.minecraft.world.item.crafting.Recipe; + +public record RecipeEntry>(RecipeTypeEntry type, RecipeSerializerEntry serializer) { + +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/SelfEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/SelfEntry.java new file mode 100644 index 00000000..b09ec1d4 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/SelfEntry.java @@ -0,0 +1,14 @@ +package dev.anvilcraft.lib.v2.registrum.util.entry; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class SelfEntry extends RegistryEntry { + public SelfEntry(AbstractRegistrum owner, DeferredHolder key) { + super(owner, key); + } + + public static SelfEntry cast(RegistryEntry entry) { + return RegistryEntry.cast(SelfEntry.class, entry); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/SoundEventEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/SoundEventEntry.java new file mode 100644 index 00000000..3cea742b --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/SoundEventEntry.java @@ -0,0 +1,40 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.util.entry; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.world.level.block.Block; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class SoundEventEntry extends SelfEntry { + public SoundEventEntry(AbstractRegistrum owner, DeferredHolder key) { + super(owner, key); + } + + public static SoundEventEntry cast(SelfEntry entry) { + return RegistryEntry.cast(SoundEventEntry.class, entry); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/data/AttachmentEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/data/AttachmentEntry.java new file mode 100644 index 00000000..cbbe6baa --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/data/AttachmentEntry.java @@ -0,0 +1,41 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.util.entry.data; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.util.entry.RegistryEntry; +import net.neoforged.neoforge.attachment.AttachmentType; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class AttachmentEntry extends RegistryEntry, AttachmentType> { + + public AttachmentEntry(AbstractRegistrum owner, DeferredHolder, AttachmentType> key) { + super(owner, key); + } + + public static AttachmentEntry cast(RegistryEntry, AttachmentType> entry) { + return RegistryEntry.cast(AttachmentEntry.class, entry); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/data/DataComponentEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/data/DataComponentEntry.java new file mode 100644 index 00000000..45971c26 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/data/DataComponentEntry.java @@ -0,0 +1,41 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.util.entry.data; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.util.entry.RegistryEntry; +import net.minecraft.core.component.DataComponentType; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class DataComponentEntry extends RegistryEntry, DataComponentType> { + + public DataComponentEntry(AbstractRegistrum owner, DeferredHolder, DataComponentType> key) { + super(owner, key); + } + + public static DataComponentEntry cast(RegistryEntry, DataComponentType> entry) { + return RegistryEntry.cast(DataComponentEntry.class, entry); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/data/package-info.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/data/package-info.java new file mode 100644 index 00000000..024aa62c --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/data/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.registrum.util.entry.data; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/modifier/BiomeModifierEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/modifier/BiomeModifierEntry.java new file mode 100644 index 00000000..a4b67645 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/modifier/BiomeModifierEntry.java @@ -0,0 +1,41 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.util.entry.modifier; + +import com.mojang.serialization.MapCodec; +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.util.entry.RegistryEntry; +import net.neoforged.neoforge.common.world.BiomeModifier; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class BiomeModifierEntry extends RegistryEntry, MapCodec> { + public BiomeModifierEntry(AbstractRegistrum owner, DeferredHolder, MapCodec> key) { + super(owner, key); + } + + public static BiomeModifierEntry cast(RegistryEntry, MapCodec> entry) { + return RegistryEntry.cast(BiomeModifierEntry.class, entry); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/modifier/GlobalLootModifierEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/modifier/GlobalLootModifierEntry.java new file mode 100644 index 00000000..13098e4b --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/modifier/GlobalLootModifierEntry.java @@ -0,0 +1,41 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.util.entry.modifier; + +import com.mojang.serialization.MapCodec; +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.util.entry.RegistryEntry; +import net.neoforged.neoforge.common.loot.IGlobalLootModifier; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class GlobalLootModifierEntry extends RegistryEntry, MapCodec> { + public GlobalLootModifierEntry(AbstractRegistrum owner, DeferredHolder, MapCodec> key) { + super(owner, key); + } + + public static GlobalLootModifierEntry cast(RegistryEntry, MapCodec> entry) { + return RegistryEntry.cast(GlobalLootModifierEntry.class, entry); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/modifier/StructureModifierEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/modifier/StructureModifierEntry.java new file mode 100644 index 00000000..c6e16243 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/modifier/StructureModifierEntry.java @@ -0,0 +1,42 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.util.entry.modifier; + +import com.mojang.serialization.MapCodec; +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.util.entry.RegistryEntry; +import net.neoforged.neoforge.common.loot.IGlobalLootModifier; +import net.neoforged.neoforge.common.world.StructureModifier; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class StructureModifierEntry extends RegistryEntry, MapCodec> { + public StructureModifierEntry(AbstractRegistrum owner, DeferredHolder, MapCodec> key) { + super(owner, key); + } + + public static StructureModifierEntry cast(RegistryEntry, MapCodec> entry) { + return RegistryEntry.cast(StructureModifierEntry.class, entry); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/modifier/package-info.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/modifier/package-info.java new file mode 100644 index 00000000..02c91049 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/modifier/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.registrum.util.entry.modifier; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/recipe/RecipeSerializerEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/recipe/RecipeSerializerEntry.java new file mode 100644 index 00000000..560a962a --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/recipe/RecipeSerializerEntry.java @@ -0,0 +1,41 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.util.entry.recipe; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.util.entry.RegistryEntry; +import net.minecraft.world.item.crafting.Recipe; +import net.minecraft.world.item.crafting.RecipeSerializer; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class RecipeSerializerEntry> extends RegistryEntry, RecipeSerializer> { + public RecipeSerializerEntry(AbstractRegistrum owner, DeferredHolder, RecipeSerializer> key) { + super(owner, key); + } + + public static > RecipeSerializerEntry cast(RegistryEntry, RecipeSerializer> entry) { + return RegistryEntry.cast(RecipeSerializerEntry.class, entry); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/recipe/RecipeTypeEntry.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/recipe/RecipeTypeEntry.java new file mode 100644 index 00000000..ce4130ce --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/recipe/RecipeTypeEntry.java @@ -0,0 +1,41 @@ +/* + * + * Original work copyright (c) 2026 Anvil-Dev (AnvilLib-Registrum) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +package dev.anvilcraft.lib.v2.registrum.util.entry.recipe; + +import dev.anvilcraft.lib.v2.registrum.AbstractRegistrum; +import dev.anvilcraft.lib.v2.registrum.util.entry.RegistryEntry; +import net.minecraft.world.item.crafting.Recipe; +import net.minecraft.world.item.crafting.RecipeType; +import net.neoforged.neoforge.registries.DeferredHolder; + +public class RecipeTypeEntry> extends RegistryEntry, RecipeType> { + public RecipeTypeEntry(AbstractRegistrum owner, DeferredHolder, RecipeType> key) { + super(owner, key); + } + + public static > RecipeTypeEntry cast(RegistryEntry, RecipeType> entry) { + return RegistryEntry.cast(RecipeTypeEntry.class, entry); + } +} diff --git a/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/recipe/package-info.java b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/recipe/package-info.java new file mode 100644 index 00000000..702532d9 --- /dev/null +++ b/module.registrum/src/main/java/dev/anvilcraft/lib/v2/registrum/util/entry/recipe/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.registrum.util.entry.recipe; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.rpc/build.gradle b/module.rpc/build.gradle new file mode 100644 index 00000000..ee7f4f39 --- /dev/null +++ b/module.rpc/build.gradle @@ -0,0 +1,276 @@ +plugins { + id 'java-library' + id 'eclipse' + id 'idea' + id 'maven-publish' + alias libs.plugins.modDevGradle + alias libs.plugins.lombok + alias libs.plugins.machete + id 'org.jreleaser' version '1.23.0' + id "me.modmuss50.mod-publish-plugin" version "1.1.0" +} + +String buildType = 'snapshot' +String buildNumber +if (System.getenv("CI_BUILD") == 'false') buildNumber = null +else { + if (System.getenv("PR_BUILD") != 'false') buildType = 'pr' + buildNumber = System.getenv("GITHUB_RUN_NUMBER") +} +version = "${mod_version}" + (buildNumber != null ? "+${buildType}.${buildNumber}" : "") +group = mod_group_id + +base { + archivesName = "${project.mod_id}-neoforge-${libs.versions.minecraft.get()}" +} + +repositories { + maven { url = "https://server.cjsah.net:1002/maven/" } + mavenLocal() + mavenCentral() +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +neoForge { + // Specify the version of NeoForge to use. + version = libs.versions.neoForge.get() + + parchment { + mappingsVersion = project.parchment_mappings_version + minecraftVersion = project.parchment_minecraft_version + } + + // This line is optional. Access Transformers are automatically detected + // accessTransformers.add('src/main/resources/META-INF/accesstransformer.cfg') + + interfaceInjectionData { + // from 'src/main/resources/interface_injections.json' + // publish file('src/main/resources/interface_injections.json') + } + + // Default run configurations. + // These can be tweaked, removed, or duplicated as needed. + runs { + client { + client() + + // Comma-separated list of namespaces to load gametests from. Empty = all namespaces. + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + server { + server() + programArgument '--nogui' + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + // This run config launches GameTestServer and runs all registered gametests, then exits. + // By default, the server will crash when no gametests are provided. + // The gametest system is also enabled by default for other run configs under the /test command. + gameTestServer { + type = "gameTestServer" + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + data { + data() + + // example of overriding the workingDirectory set in configureEach above, uncomment if you want to use it + // gameDirectory = project.file('run-data') + + // Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources. + programArguments.addAll '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/').getAbsolutePath(), '--existing', file('src/main/resources/').getAbsolutePath() + } + + // applies to all the run configs above + configureEach { + // Recommended logging data for a userdev environment + // The markers can be added/remove as needed separated by commas. + // "SCAN": For mods scan. + // "REGISTRIES": For firing of registry events. + // "REGISTRYDUMP": For getting the contents of all registries. + systemProperty 'forge.logging.markers', 'REGISTRIES' + + // Recommended logging level for the console + // You can set various levels here. + // Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels + logLevel = org.slf4j.event.Level.DEBUG + } + } + + mods { + // define mod <-> source bindings + // these are used to tell the game which sources are for which mod + // mostly optional in a single mod project + // but multi mod projects should define one per mod + "${mod_id}" { + sourceSet(sourceSets.main) + } + } +} + +// Include resources generated by data generators. +sourceSets.main.resources { srcDir 'src/generated/resources' } + + +dependencies { + + if (System.getenv("NOT_DEV") == 'true') { + jarJar(api("dev.anvilcraft.lib:anvillib-network-neoforge-1.21.1:latest.release")) + } else { + jarJar(implementation project(":anvillib-network-neoforge-1.21.1")) + } +} + +// This block of code expands all declared replace properties in the specified resource targets. +// A missing property will result in an error. Properties are expanded using ${} Groovy notation. +// When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments. +// See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html +tasks.withType(ProcessResources).configureEach { + var replaceProperties = [minecraft_version : libs.versions.minecraft.get(), + minecraft_version_range: minecraft_version_range, + neo_version : libs.versions.neoForge.get(), + neo_version_range : neo_version_range, + loader_version_range : loader_version_range, + mod_id : mod_id, + mod_name : mod_name, + mod_license : mod_license, + mod_version : version, + mod_authors : mod_authors, + mod_description : mod_description] + inputs.properties replaceProperties + + filesMatching(['META-INF/neoforge.mods.toml']) { + expand replaceProperties + } +} + +tasks.register('sourcesJar', Jar) { + from delombok.outputs.files + dependsOn delombok + archiveClassifier = "sources" +} + +tasks.register('javadocJar', Jar) { + from javadoc.destinationDir + dependsOn javadoc + archiveClassifier = "javadoc" +} + +javadoc { + options.encoding = 'UTF-8' + options.addStringOption('Xdoclint:none', '-quiet') +} + +jar { + from(rootProject.file("LICENSE")) + from(rootProject.file("ASSETS_LICENSE")) + from(rootProject.file("README.md")) + from(rootProject.file("README.en.md")) +} + +artifacts { + archives tasks.jar + archives tasks.sourcesJar + archives tasks.javadocJar +} + +def this_name = base.archivesName.get() +def this_description = mod_description + +publishing { + publications { + register('mavenJava', MavenPublication) { + from components.java + + artifact sourcesJar + artifact javadocJar + + versionMapping { + allVariants { + fromResolutionResult() + } + } + + pom { + name = this_name + description = this_description + url = 'https://github.com/Anvil-Dev/AnvilLib' + + licenses { + license { + name = 'MIT License' + url = 'https://opensource.org/license/mit' + } + } + developers { + developer { + id = 'Anvil-Dev' + name = 'AnvilCraft Dev' + email = 'admin@anvilcraft.dev' + } + } + scm { + url = 'https://github.com/Anvil-Dev/AnvilLib' + connection = 'https://github.com/Anvil-Dev/AnvilLib.git' + developerConnection = 'https://github.com/Anvil-Dev/AnvilLib.git' + } + } + } + } + repositories { + // Add repositories to publish to here. + def MAVEN_URL = System.getenv("MAVEN_URL") + if (MAVEN_URL != null) { + maven { + url MAVEN_URL + credentials { + username System.getenv("MAVEN_USERNAME") + password System.getenv("MAVEN_PASSWORD") + } + } + } + mavenLocal() + maven { + name = "staging" + url = layout.buildDirectory.dir("staging-deploy") + } + } +} + +jreleaser { + signing { + active = 'ALWAYS' + armored = true + } + + deploy { + maven { + mavenCentral { + sonatype { + active = 'ALWAYS' + url = 'https://central.sonatype.com/api/v1/publisher' + stagingRepository('build/staging-deploy') + } + } + } + } +} + +// IDEA no longer automatically downloads sources/javadoc jars for dependencies, so we need to explicitly enable the behavior. +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} + +machete { + // disable machete locally for faster builds + enabled = false +} + +lombok { + version = "1.18.34" +} diff --git a/module.rpc/gradle.properties b/module.rpc/gradle.properties new file mode 100644 index 00000000..9af9cb71 --- /dev/null +++ b/module.rpc/gradle.properties @@ -0,0 +1,4 @@ +## Mod Properties +mod_id=anvillib_rpc +mod_name=AnvilLib-RPC +mod_description=RPC based on Minecraft Network System diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/AnvilLibRpc.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/AnvilLibRpc.java new file mode 100644 index 00000000..1faf0d1c --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/AnvilLibRpc.java @@ -0,0 +1,65 @@ +package dev.anvilcraft.lib.v2.rpc; + +import dev.anvilcraft.lib.v2.rpc.config.RpcConfigurationTask; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.server.ServerStoppedEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; +import net.neoforged.neoforge.network.event.RegisterConfigurationTasksEvent; +import org.jetbrains.annotations.ApiStatus; + +@Mod(AnvilLibRpc.MOD_ID) +@ApiStatus.Internal +public class AnvilLibRpc { + public static final String MAIN_ID = "anvillib"; + public static final String MOD_ID = "anvillib_rpc"; + + /** + * 服务端 / 通用侧的权威索引表:本地扫描填充,从不被服务端下发覆盖。 + */ + public static final RpcRegistry REGISTRY = new RpcRegistry(true); + + /** + * 服务端侧待响应的 {@link RPC#invoke} 调用登记表。 + */ + public static final RpcPendingCalls PENDING = new RpcPendingCalls(); + + public AnvilLibRpc(IEventBus modEventBus, ModContainer modContainer) { + modEventBus.addListener(this::onRegisterConfigurationTasks); + NeoForge.EVENT_BUS.addListener(this::onServerTick); + NeoForge.EVENT_BUS.addListener(this::onServerStopped); + } + + public static ResourceLocation of(String path) { + return ResourceLocation.fromNamespaceAndPath(AnvilLibRpc.MAIN_ID, path); + } + + public static ResourceLocation mod(String path) { + return ResourceLocation.fromNamespaceAndPath(AnvilLibRpc.MOD_ID, path); + } + + @SubscribeEvent + public void onRegisterConfigurationTasks(RegisterConfigurationTasksEvent event) { + event.register(new RpcConfigurationTask()); + } + + /** + * 驱动服务端侧 {@link RPC#invoke} 调用的超时检查。 + */ + @SubscribeEvent + public void onServerTick(ServerTickEvent.Post event) { + AnvilLibRpc.PENDING.tick(); + } + + /** + * 服务器停止时清理服务端侧未完成的 {@link RPC#invoke} 调用。 + */ + @SubscribeEvent + public void onServerStopped(ServerStoppedEvent event) { + AnvilLibRpc.PENDING.clear(); + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/AnvilLibRpcNetwork.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/AnvilLibRpcNetwork.java new file mode 100644 index 00000000..9dfcbd8e --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/AnvilLibRpcNetwork.java @@ -0,0 +1,23 @@ +package dev.anvilcraft.lib.v2.rpc; + +import dev.anvilcraft.lib.v2.network.register.NetworkRegistrar; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; +import net.neoforged.neoforge.network.registration.PayloadRegistrar; +import org.jetbrains.annotations.ApiStatus; + +/** + * 注册 RPC 网络包。 + */ +@EventBusSubscriber(modid = AnvilLibRpc.MOD_ID) +@ApiStatus.Internal +public class AnvilLibRpcNetwork { + public static final String NETWORK_VERSION = "1"; + + @SubscribeEvent + public static void register(RegisterPayloadHandlersEvent event) { + PayloadRegistrar registrar = event.registrar(NETWORK_VERSION); + NetworkRegistrar.register(registrar, AnvilLibRpc.MOD_ID); + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/CallableParam.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/CallableParam.java new file mode 100644 index 00000000..986c21f1 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/CallableParam.java @@ -0,0 +1,45 @@ +package dev.anvilcraft.lib.v2.rpc; + +import net.minecraft.network.codec.StreamCodec; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 为 {@link RemoteCallable} 方法的某个参数指定 {@link StreamCodec};当标注在方法上时,为该方法的 + * 返回值指定 {@link StreamCodec}(用于 {@link RPC#invoke} 的有返回值调用)。 + * + *

    当(参数或返回值)类型不在 + * {@link net.minecraft.network.codec.ByteBufCodecs ByteBufCodecs} 默认支持的类型之列时, + * 需用本注解指向一个 {@code public static final StreamCodec} 字段,用于该值的网络编解码。

    + * + *

    示例

    + *
    {@code
    + * @CallableParam(clazz = MyCodecs.class, field = "RESULT") // 指定返回值编解码器
    + * @RemoteCallable
    + * public static Result compute(
    + *     @CallableParam(clazz = MyCodecs.class, field = "BLOCK_POS") BlockPos pos // 指定参数编解码器
    + * ) { ... }
    + * }
    + * + * @see RemoteCallable + */ +@Target({ElementType.PARAMETER, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface CallableParam { + /** + * 声明 {@link StreamCodec} 字段的类。 + * + * @return 持有编解码器字段的类 + */ + Class clazz(); + + /** + * {@link StreamCodec} 字段的名称。该字段须为 {@code static}。 + * + * @return 编解码器字段名 + */ + String field(); +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/ClientRpcTargets.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/ClientRpcTargets.java new file mode 100644 index 00000000..d55f7dfa --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/ClientRpcTargets.java @@ -0,0 +1,48 @@ +package dev.anvilcraft.lib.v2.rpc; + +import dev.anvilcraft.lib.v2.rpc.client.AnvilLibRpcClient; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.core.RegistryAccess; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.neoforged.neoforge.network.PacketDistributor; + +import java.util.Objects; + +/** + * {@link RpcTarget#server()} 的客户端侧实现。 + * + *

    独立成类以便其引用的客户端专用类({@link Minecraft} 等)仅在客户端被加载, + * 专用服务器不会在链接期触及。

    + */ +final class ClientRpcTargets { + private ClientRpcTargets() { + } + + static RpcTarget server() { + return new RpcTarget() { + @Override + public void send(CustomPacketPayload payload) { + PacketDistributor.sendToServer(payload); + } + + @Override + public RpcRegistry registry() { + return AnvilLibRpcClient.REGISTRY; + } + + @Override + public RegistryAccess registryAccess() { + ClientPacketListener connection = Objects.requireNonNull( + Minecraft.getInstance().getConnection(), "Not connected to a server" + ); + return connection.registryAccess(); + } + + @Override + public RpcPendingCalls pending() { + return AnvilLibRpcClient.PENDING; + } + }; + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/IRemoteCallableValidator.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/IRemoteCallableValidator.java new file mode 100644 index 00000000..9da5c23a --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/IRemoteCallableValidator.java @@ -0,0 +1,30 @@ +package dev.anvilcraft.lib.v2.rpc; + +import net.neoforged.neoforge.network.handling.IPayloadContext; + +import java.lang.reflect.Method; + +/** + * {@link RemoteCallable} 方法的接收端校验器:在目标方法执行之前判定本次远程调用是否被允许。 + * + *

    校验在接收端(实际执行方法的一侧)进行,可据 {@link IPayloadContext}(发起方玩家、连接、方向等) + * 与实参决定是否放行,用于实现「仅 OP 可调用」「目标坐标须在范围内」等安全策略。

    + * + *

    实现类须提供一个无参构造器(可为非 public),框架会反射实例化并缓存。 + * 不指定校验器时(注解 {@code validator} 取默认值),等价于始终放行。

    + * + * @see RemoteCallable#validator() + */ +@FunctionalInterface +public interface IRemoteCallableValidator { + /** + * 判定一次远程调用是否被允许执行。 + * + * @param ctx 网络包上下文(含发起方玩家、方向等) + * @param method 即将执行的目标方法 + * @param args 已解码的实参 + * @return {@code true} 放行并执行;{@code false} 拒绝({@link RPC#call} 静默丢弃, + * {@link RPC#invoke} 使调用方 future 以异常失败) + */ + boolean validate(IPayloadContext ctx, Method method, Object[] args); +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/IRpcPayload.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/IRpcPayload.java new file mode 100644 index 00000000..7cf47a5b --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/IRpcPayload.java @@ -0,0 +1,30 @@ +package dev.anvilcraft.lib.v2.rpc; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import org.jetbrains.annotations.ApiStatus; + +import java.lang.reflect.Method; + +@ApiStatus.Internal +public interface IRpcPayload { + static byte[] encodeParams(RpcRegistry registry, Method method, Object[] args, RegistryFriendlyByteBuf buf) { + buf.writeVarInt(registry.index(method)); + StreamCodec[] codecs = RpcMethods.codecs(method); + for (int i = 0; i < codecs.length; i++) { + codecs[i].encode(buf, args[i]); + } + byte[] data = new byte[buf.readableBytes()]; + buf.readBytes(data); + return data; + } + + static Object[] decodeParams(Method method, RegistryFriendlyByteBuf buf) { + StreamCodec[] codecs = RpcMethods.codecs(method); + Object[] args = new Object[codecs.length]; + for (int i = 0; i < codecs.length; i++) { + args[i] = codecs[i].decode(buf); + } + return args; + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/LambdaResolver.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/LambdaResolver.java new file mode 100644 index 00000000..5f4be526 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/LambdaResolver.java @@ -0,0 +1,45 @@ +package dev.anvilcraft.lib.v2.rpc; + +import java.io.Serializable; +import java.lang.invoke.SerializedLambda; +import java.lang.reflect.Method; + +/** + * 从可序列化的方法引用中还原出其指向的目标方法。 + * + *

    {@link RPC#call} 接收的方法引用(如 {@code SomeClass::someStaticMethod})会被编译为实现了 + * {@link Serializable} 的 lambda 实例。JVM 为这类实例自动生成 {@code writeReplace} 方法,返回携带实现类、 + * 方法名与方法描述符的 {@link SerializedLambda},据此即可定位目标方法。

    + */ +final class LambdaResolver { + private LambdaResolver() { + } + + /** + * 解析方法引用所指向的、经 {@link RemoteCallable} 校验的静态方法。 + * + * @param methodRef 指向静态方法的可序列化方法引用 + * @return 已校验通过的目标方法 + */ + static Method resolve(Serializable methodRef) { + SerializedLambda lambda = serializedLambda(methodRef); + String className = lambda.getImplClass().replace('/', '.'); + return RpcMethods.resolve(className, lambda.getImplMethodName(), lambda.getImplMethodSignature()); + } + + private static SerializedLambda serializedLambda(Serializable methodRef) { + try { + Method writeReplace = methodRef.getClass().getDeclaredMethod("writeReplace"); + writeReplace.setAccessible(true); + Object replacement = writeReplace.invoke(methodRef); + if (replacement instanceof SerializedLambda lambda) { + return lambda; + } + throw new IllegalArgumentException( + "RPC target must be a method reference to a static method, got: " + methodRef.getClass().getName() + ); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException("Cannot extract method reference metadata", e); + } + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RPC.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RPC.java new file mode 100644 index 00000000..e4e6ffb7 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RPC.java @@ -0,0 +1,674 @@ +package dev.anvilcraft.lib.v2.rpc; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.lang.reflect.Method; +import java.util.concurrent.CompletableFuture; + +/** + * 远程过程调用入口。 + * + *

    将一个静态方法标注为 {@link RemoteCallable},即可通过 + * {@link #call(RpcTarget, RpcMethodRef.R1, Object) RPC.call(target, Type::method, args...)} + * 在目标端执行该方法。

    + * + *

    方法参数默认支持 {@link net.minecraft.network.codec.ByteBufCodecs ByteBufCodecs} 中提供的类型 + * (如 {@code int}、{@code String}、{@code byte[]} 等),这些参数无需额外标注;其余类型需通过 + * {@link CallableParam} 指定对应的 {@link net.minecraft.network.codec.StreamCodec StreamCodec}。

    + * + *

    示例

    + *
    {@code
    + * public final class Greetings {
    + *     @RemoteCallable
    + *     public static void hello(String name, int times) {
    + *         for (int i = 0; i < times; i++) System.out.println("Hello " + name);
    + *     }
    + * }
    + *
    + * // 服务端令某个客户端执行 Greetings.hello("world", 3)
    + * RPC.call(RpcTarget.player(serverPlayer), Greetings::hello, "world", 3);
    + * }
    + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RPC { + /** + * 调用无参方法。 + * + * @param target 目标端 + * @param methodRef 指向 {@link RemoteCallable} 静态方法的方法引用 + */ + public static void call(RpcTarget target, RpcMethodRef.R0 methodRef) { + dispatch(target, methodRef); + } + + /** + * 调用单参方法。 + * + * @param target 目标端 + * @param methodRef 指向 {@link RemoteCallable} 静态方法的方法引用 + * @param a 第 1 个参数 + * @param 第 1 个参数类型 + */ + public static void call(RpcTarget target, RpcMethodRef.R1 methodRef, A a) { + dispatch(target, methodRef, a); + } + + /** + * 调用双参方法。 + */ + public static void call(RpcTarget target, RpcMethodRef.R2 methodRef, A a, B b) { + dispatch(target, methodRef, a, b); + } + + /** + * 调用三参方法。 + */ + public static void call(RpcTarget target, RpcMethodRef.R3 methodRef, A a, B b, C c) { + dispatch(target, methodRef, a, b, c); + } + + /** + * 调用四参方法。 + */ + public static void call(RpcTarget target, RpcMethodRef.R4 methodRef, A a, B b, C c, D d) { + dispatch(target, methodRef, a, b, c, d); + } + + /** + * 调用五参方法。 + */ + public static void call(RpcTarget target, RpcMethodRef.R5 methodRef, A a, B b, C c, D d, E e) { + dispatch(target, methodRef, a, b, c, d, e); + } + + /** + * 调用六参方法。 + */ + public static void call( + RpcTarget target, + RpcMethodRef.R6 methodRef, + A a, + B b, + C c, + D d, + E e, + F f + ) { + dispatch(target, methodRef, a, b, c, d, e, f); + } + + /** + * 调用七参方法。 + */ + public static void call( + RpcTarget target, + RpcMethodRef.R7 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g + ) { + dispatch(target, methodRef, a, b, c, d, e, f, g); + } + + /** + * 调用八参方法。 + */ + public static void call( + RpcTarget target, + RpcMethodRef.R8 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h + ) { + dispatch(target, methodRef, a, b, c, d, e, f, g, h); + } + + /** + * 调用九参方法。 + */ + public static void call( + RpcTarget target, + RpcMethodRef.R9 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i + ) { + dispatch(target, methodRef, a, b, c, d, e, f, g, h, i); + } + + /** + * 调用十参方法。 + */ + public static void call( + RpcTarget target, + RpcMethodRef.R10 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j + ) { + dispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j); + } + + /** + * 调用十一参方法。 + */ + public static void call( + RpcTarget target, + RpcMethodRef.R11 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j, + K k + ) { + dispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j, k); + } + + /** + * 调用十二参方法。 + */ + public static void call( + RpcTarget target, + RpcMethodRef.R12 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j, + K k, + L l + ) { + dispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j, k, l); + } + + /** + * 调用十三参方法。 + */ + public static void call( + RpcTarget target, + RpcMethodRef.R13 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j, + K k, + L l, + M m + ) { + dispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j, k, l, m); + } + + /** + * 调用十四参方法。 + */ + public static void call( + RpcTarget target, + RpcMethodRef.R14 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j, + K k, + L l, + M m, + N n + ) { + dispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j, k, l, m, n); + } + + /** + * 调用十五参方法。 + */ + public static void call( + RpcTarget target, + RpcMethodRef.R15 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j, + K k, + L l, + M m, + N n, + O o + ) { + dispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j, k, l, m, n, o); + } + + /** + * 调用十六参方法。 + */ + public static void call( + RpcTarget target, + RpcMethodRef.R16 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j, + K k, + L l, + M m, + N n, + O o, + P p + ) { + dispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p); + } + + /** + * 逃生口:按类与方法名调用 {@link RemoteCallable} 方法,参数个数不限。 + * + *

    当参数个数超过 {@link #call} 提供的 16 个档次,或无法使用方法引用时使用。代价是失去对实参的 + * 编译期类型检查:调用者需自行保证 {@code args} 的类型与个数同目标方法一致。若该方法名存在重载, + * 将因歧义而抛出异常,此时应改用方法引用形式的 {@link #call}。

    + * + * @param target 目标端 + * @param clazz 目标方法所属类 + * @param methodName 目标方法名(须唯一且为 {@link RemoteCallable} 静态方法) + * @param args 实参 + */ + public static void callByName(RpcTarget target, Class clazz, String methodName, Object... args) { + Method method = RpcMethods.resolveByName(clazz, methodName); + sendChecked(target, method, args); + } + + /** + * 发起无参、有返回值的远程调用。 + * + * @param target 目标端 + * @param methodRef 指向 {@link RemoteCallable} 静态方法的方法引用 + * @param 返回类型 + * @return 在收到对端响应时兑现的 future + */ + public static CompletableFuture invoke(RpcTarget target, RpcFunctionRef.F0 methodRef) { + return invokeDispatch(target, methodRef); + } + + /** + * 发起单参、有返回值的远程调用。 + */ + public static CompletableFuture invoke(RpcTarget target, RpcFunctionRef.F1 methodRef, A a) { + return invokeDispatch(target, methodRef, a); + } + + /** + * 发起双参、有返回值的远程调用。 + */ + public static CompletableFuture invoke(RpcTarget target, RpcFunctionRef.F2 methodRef, A a, B b) { + return invokeDispatch(target, methodRef, a, b); + } + + /** + * 发起三参、有返回值的远程调用。 + */ + public static CompletableFuture invoke(RpcTarget target, RpcFunctionRef.F3 methodRef, A a, B b, C c) { + return invokeDispatch(target, methodRef, a, b, c); + } + + /** + * 发起四参、有返回值的远程调用。 + */ + public static CompletableFuture invoke( + RpcTarget target, + RpcFunctionRef.F4 methodRef, + A a, + B b, + C c, + D d + ) { + return invokeDispatch(target, methodRef, a, b, c, d); + } + + /** + * 发起五参、有返回值的远程调用。 + */ + public static CompletableFuture invoke( + RpcTarget target, + RpcFunctionRef.F5 methodRef, + A a, + B b, + C c, + D d, + E e + ) { + return invokeDispatch(target, methodRef, a, b, c, d, e); + } + + /** + * 发起六参、有返回值的远程调用。 + */ + public static CompletableFuture invoke( + RpcTarget target, + RpcFunctionRef.F6 methodRef, + A a, + B b, + C c, + D d, + E e, + F f + ) { + return invokeDispatch(target, methodRef, a, b, c, d, e, f); + } + + /** + * 发起七参、有返回值的远程调用。 + */ + public static CompletableFuture invoke( + RpcTarget target, + RpcFunctionRef.F7 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g + ) { + return invokeDispatch(target, methodRef, a, b, c, d, e, f, g); + } + + /** + * 发起八参、有返回值的远程调用。 + */ + public static CompletableFuture invoke( + RpcTarget target, + RpcFunctionRef.F8 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h + ) { + return invokeDispatch(target, methodRef, a, b, c, d, e, f, g, h); + } + + /** + * 发起九参、有返回值的远程调用。 + */ + public static CompletableFuture invoke( + RpcTarget target, + RpcFunctionRef.F9 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i + ) { + return invokeDispatch(target, methodRef, a, b, c, d, e, f, g, h, i); + } + + /** + * 发起十参、有返回值的远程调用。 + */ + public static CompletableFuture invoke( + RpcTarget target, + RpcFunctionRef.F10 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j + ) { + return invokeDispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j); + } + + /** + * 发起十一参、有返回值的远程调用。 + */ + public static CompletableFuture invoke( + RpcTarget target, + RpcFunctionRef.F11 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j, + K k + ) { + return invokeDispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j, k); + } + + /** + * 发起十二参、有返回值的远程调用。 + */ + public static CompletableFuture invoke( + RpcTarget target, + RpcFunctionRef.F12 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j, + K k, + L l + ) { + return invokeDispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j, k, l); + } + + /** + * 发起十三参、有返回值的远程调用。 + */ + public static CompletableFuture invoke( + RpcTarget target, + RpcFunctionRef.F13 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j, + K k, + L l, + M m + ) { + return invokeDispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j, k, l, m); + } + + /** + * 发起十四参、有返回值的远程调用。 + */ + public static CompletableFuture invoke( + RpcTarget target, + RpcFunctionRef.F14 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j, + K k, + L l, + M m, + N n + ) { + return invokeDispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j, k, l, m, n); + } + + /** + * 发起十五参、有返回值的远程调用。 + */ + public static CompletableFuture invoke( + RpcTarget target, + RpcFunctionRef.F15 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j, + K k, + L l, + M m, + N n, + O o + ) { + return invokeDispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j, k, l, m, n, o); + } + + /** + * 发起十六参、有返回值的远程调用。 + */ + public static CompletableFuture invoke( + RpcTarget target, + RpcFunctionRef.F16 methodRef, + A a, + B b, + C c, + D d, + E e, + F f, + G g, + H h, + I i, + J j, + K k, + L l, + M m, + N n, + O o, + P p + ) { + return invokeDispatch(target, methodRef, a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p); + } + + /** + * 逃生口:按类与方法名发起有返回值的远程调用,参数个数不限。 + * + *

    与 {@link #callByName} 同理,失去对实参的编译期类型检查;返回类型由调用者通过 {@code } 指定, + * 运行时若与方法实际返回类型不符将导致解码或转型失败。

    + * + * @param target 目标端 + * @param clazz 目标方法所属类 + * @param methodName 目标方法名(须唯一且为 {@link RemoteCallable} 静态方法) + * @param args 实参 + * @param 返回类型 + * @return 在收到对端响应时兑现的 future + */ + public static CompletableFuture invokeByName(RpcTarget target, Class clazz, String methodName, Object... args) { + Method method = RpcMethods.resolveByName(clazz, methodName); + return invokeChecked(target, method, args); + } + + private static void dispatch(RpcTarget target, Serializable methodRef, Object... args) { + Method method = LambdaResolver.resolve(methodRef); + sendChecked(target, method, args); + } + + private static void sendChecked(RpcTarget target, Method method, Object[] args) { + if (method.getParameterCount() != args.length) { + throw new IllegalArgumentException("RPC method " + method + " expects " + method.getParameterCount() + " arguments, got " + args.length); + } + target.send(RpcPayload.encode(target.registry(), target.registryAccess(), method, args)); + } + + private static CompletableFuture invokeDispatch(RpcTarget target, Serializable methodRef, Object... args) { + return invokeChecked(target, LambdaResolver.resolve(methodRef), args); + } + + private static CompletableFuture invokeChecked(RpcTarget target, Method method, Object[] args) { + if (method.getReturnType() == void.class) { + throw new IllegalArgumentException("RPC method " + method + " returns void; use RPC.call instead"); + } + if (method.getParameterCount() != args.length) { + throw new IllegalArgumentException("RPC method " + method + " expects " + method.getParameterCount() + " arguments, got " + args.length); + } + CompletableFuture future = new CompletableFuture<>(); + int callId = target.pending().register(method, future); + target.send(RpcRequestPayload.encode(target.registry(), target.registryAccess(), callId, method, args)); + @SuppressWarnings("unchecked") CompletableFuture typed = (CompletableFuture) future; + return typed; + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RemoteCallable.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RemoteCallable.java new file mode 100644 index 00000000..148dc39c --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RemoteCallable.java @@ -0,0 +1,30 @@ +package dev.anvilcraft.lib.v2.rpc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 标记一个静态方法为可远程调用方法。 + * + *

    仅被该注解标注的 {@code static} 方法才能作为 {@link RPC#call} 的目标, + * 接收端在执行前会校验目标方法确实带有此注解,以防止任意方法被远程触发。

    + * + * @see RPC#call + * @see CallableParam + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RemoteCallable { + /** + * 接收端校验器:在方法执行前判定本次调用是否被允许。 + * + *

    默认值 {@link IRemoteCallableValidator} 本身为「始终放行」哨兵;指定其它实现类时, + * 该类须有无参构造器,框架会反射实例化并缓存。校验不通过时,{@link RPC#call} 静默丢弃, + * {@link RPC#invoke} 使调用方 future 以异常失败。

    + * + * @return 校验器类型 + */ + Class validator() default IRemoteCallableValidator.class; +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcFunctionRef.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcFunctionRef.java new file mode 100644 index 00000000..c891a5f6 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcFunctionRef.java @@ -0,0 +1,149 @@ +package dev.anvilcraft.lib.v2.rpc; + +import java.io.Serializable; + +/** + * 指向有返回值的 {@link RemoteCallable} 静态方法的可序列化方法引用接口集合,按参数个数区分。 + * + *

    与 {@link RpcMethodRef} 对应,但方法返回类型为 {@code R},供 {@link RPC#invoke} 使用。

    + */ +public final class RpcFunctionRef { + private RpcFunctionRef() { + } + + /** + * 无参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F0 extends Serializable { + R invoke(); + } + + /** + * 单参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F1 extends Serializable { + R invoke(A a); + } + + /** + * 双参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F2 extends Serializable { + R invoke(A a, B b); + } + + /** + * 三参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F3 extends Serializable { + R invoke(A a, B b, C c); + } + + /** + * 四参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F4 extends Serializable { + R invoke(A a, B b, C c, D d); + } + + /** + * 五参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F5 extends Serializable { + R invoke(A a, B b, C c, D d, E e); + } + + /** + * 六参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F6 extends Serializable { + R invoke(A a, B b, C c, D d, E e, F f); + } + + /** + * 七参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F7 extends Serializable { + R invoke(A a, B b, C c, D d, E e, F f, G g); + } + + /** + * 八参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F8 extends Serializable { + R invoke(A a, B b, C c, D d, E e, F f, G g, H h); + } + + /** + * 九参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F9 extends Serializable { + R invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i); + } + + /** + * 十参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F10 extends Serializable { + R invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j); + } + + /** + * 十一参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F11 extends Serializable { + R invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j, K k); + } + + /** + * 十二参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F12 extends Serializable { + R invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j, K k, L l); + } + + /** + * 十三参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F13 extends Serializable { + R invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j, K k, L l, M m); + } + + /** + * 十四参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F14 extends Serializable { + R invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j, K k, L l, M m, N n); + } + + /** + * 十五参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F15 extends Serializable { + R invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j, K k, L l, M m, N n, O o); + } + + /** + * 十六参数、返回 {@code R} 的方法引用。 + */ + @FunctionalInterface + public interface F16 extends Serializable { + R invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j, K k, L l, M m, N n, O o, P p); + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcMethodRef.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcMethodRef.java new file mode 100644 index 00000000..c0a56ef0 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcMethodRef.java @@ -0,0 +1,155 @@ +package dev.anvilcraft.lib.v2.rpc; + +import java.io.Serializable; + +/** + * 指向 {@link RemoteCallable} 静态方法的可序列化方法引用接口集合,按参数个数区分。 + * + *

    这些接口继承 {@link Serializable},使得 {@code Foo::bar} 形式的方法引用能被编译为可序列化 lambda, + * 从而可通过 {@link LambdaResolver} 还原出目标方法。

    + */ +public final class RpcMethodRef { + private RpcMethodRef() { + } + + /** + * 无参数方法引用。 + */ + @FunctionalInterface + public interface R0 extends Serializable { + void invoke(); + } + + /** + * 单参数方法引用。 + * + * @param 第 1 个参数类型 + */ + @FunctionalInterface + public interface R1 extends Serializable { + void invoke(A a); + } + + /** + * 双参数方法引用。 + * + * @param 第 1 个参数类型 + * @param 第 2 个参数类型 + */ + @FunctionalInterface + public interface R2 extends Serializable { + void invoke(A a, B b); + } + + /** + * 三参数方法引用。 + */ + @FunctionalInterface + public interface R3 extends Serializable { + void invoke(A a, B b, C c); + } + + /** + * 四参数方法引用。 + */ + @FunctionalInterface + public interface R4 extends Serializable { + void invoke(A a, B b, C c, D d); + } + + /** + * 五参数方法引用。 + */ + @FunctionalInterface + public interface R5 extends Serializable { + void invoke(A a, B b, C c, D d, E e); + } + + /** + * 六参数方法引用。 + */ + @FunctionalInterface + public interface R6 extends Serializable { + void invoke(A a, B b, C c, D d, E e, F f); + } + + /** + * 七参数方法引用。 + */ + @FunctionalInterface + public interface R7 extends Serializable { + void invoke(A a, B b, C c, D d, E e, F f, G g); + } + + /** + * 八参数方法引用。 + */ + @FunctionalInterface + public interface R8 extends Serializable { + void invoke(A a, B b, C c, D d, E e, F f, G g, H h); + } + + /** + * 九参数方法引用。 + */ + @FunctionalInterface + public interface R9 extends Serializable { + void invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i); + } + + /** + * 十参数方法引用。 + */ + @FunctionalInterface + public interface R10 extends Serializable { + void invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j); + } + + /** + * 十一参数方法引用。 + */ + @FunctionalInterface + public interface R11 extends Serializable { + void invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j, K k); + } + + /** + * 十二参数方法引用。 + */ + @FunctionalInterface + public interface R12 extends Serializable { + void invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j, K k, L l); + } + + /** + * 十三参数方法引用。 + */ + @FunctionalInterface + public interface R13 extends Serializable { + void invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j, K k, L l, M m); + } + + /** + * 十四参数方法引用。 + */ + @FunctionalInterface + public interface R14 extends Serializable { + void invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j, K k, L l, M m, N n); + } + + /** + * 十五参数方法引用。 + */ + @FunctionalInterface + public interface R15 extends Serializable { + void invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j, K k, L l, M m, N n, O o); + } + + /** + * 十六参数方法引用。 + */ + @FunctionalInterface + public interface R16 extends Serializable { + void invoke(A a, B b, C c, D d, E e, F f, G g, H h, I i, J j, K k, L l, M m, N n, O o, P p); + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcMethods.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcMethods.java new file mode 100644 index 00000000..629695b3 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcMethods.java @@ -0,0 +1,372 @@ +package dev.anvilcraft.lib.v2.rpc; + +import io.netty.buffer.ByteBuf; +import net.minecraft.core.UUIDUtil; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.VarInt; +import net.minecraft.network.VarLong; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import javax.annotation.Nullable; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Parameter; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * RPC 方法解析与参数编解码器解析的内部工具。 + * + *

    负责:

    + *
      + *
    • 根据类名、方法名、方法描述符定位 {@link RemoteCallable} 静态方法(并做安全校验);
    • + *
    • 为方法的每个参数解析 {@link StreamCodec}:优先使用 {@link CallableParam} 指定的编解码器, + * 否则回退到 {@link ByteBufCodecs} 中的默认编解码器。
    • + *
    + */ +final class RpcMethods { + /** + * {@link ByteBufCodecs} 中按参数类型提供的默认编解码器。使用这些类型的参数无需 {@link CallableParam}。 + */ + private static final Map, StreamCodec> DEFAULTS = new HashMap<>(); + /** + * 方法解析缓存,键为 {@code className#methodName + descriptor}。 + */ + private static final Map METHOD_CACHE = new ConcurrentHashMap<>(); + /** + * 参数编解码器解析缓存。 + */ + private static final Map[]> CODEC_CACHE = new ConcurrentHashMap<>(); + /** + * 返回值编解码器解析缓存。 + */ + private static final Map> RETURN_CODEC_CACHE = new ConcurrentHashMap<>(); + /** + * 方法校验器实例缓存。{@code Optional.empty()} 表示该方法无校验器(始终放行)。 + */ + private static final Map> VALIDATOR_CACHE = new ConcurrentHashMap<>(); + + static { + // int / long 默认使用 VarInt / VarLong,与原版网络包惯例一致 + register(boolean.class, Boolean.class, ByteBufCodecs.BOOL); + register(byte.class, Byte.class, ByteBufCodecs.BYTE); + register(short.class, Short.class, ByteBufCodecs.SHORT); + register(int.class, Integer.class, ByteBufCodecs.VAR_INT); + register(long.class, Long.class, ByteBufCodecs.VAR_LONG); + register(float.class, Float.class, ByteBufCodecs.FLOAT); + register(double.class, Double.class, ByteBufCodecs.DOUBLE); + DEFAULTS.put(String.class, ByteBufCodecs.STRING_UTF8); + DEFAULTS.put(byte[].class, ByteBufCodecs.BYTE_ARRAY); + // 1.21.1 的 ByteBufCodecs 无 LONG_ARRAY(26.1 新增),手写等价编解码:VarInt 长度 + VarLong 元素 + DEFAULTS.put(long[].class, StreamCodec.of( + (ByteBuf buf, long[] arr) -> { + VarInt.write(buf, arr.length); + for (long v : arr) VarLong.write(buf, v); + }, + buf -> { + long[] arr = new long[VarInt.read(buf)]; + for (int i = 0; i < arr.length; i++) arr[i] = VarLong.read(buf); + return arr; + } + )); + DEFAULTS.put(UUID.class, UUIDUtil.STREAM_CODEC); + DEFAULTS.put(Tag.class, ByteBufCodecs.TAG); + DEFAULTS.put(CompoundTag.class, ByteBufCodecs.COMPOUND_TAG); + } + + private RpcMethods() { + } + + private static void register(Class primitive, Class boxed, StreamCodec codec) { + DEFAULTS.put(primitive, codec); + DEFAULTS.put(boxed, codec); + } + + /** + * 解析并校验目标方法。 + * + *

    该方法是接收端的安全闸口:仅当目标方法是 {@code static} 且标注了 {@link RemoteCallable} 时才会返回, + * 否则抛出异常拒绝调用。

    + * + * @param className 方法所属类的全限定名 + * @param methodName 方法名 + * @param descriptor 方法描述符(JVM 字节码格式),用于区分重载 + * @return 已校验通过、可访问的目标方法 + */ + static Method resolve(String className, String methodName, String descriptor) { + return METHOD_CACHE.computeIfAbsent(className + "#" + methodName + descriptor, ignored -> doResolve(className, methodName, descriptor)); + } + + private static Method doResolve(String className, String methodName, String descriptor) { + Class clazz = loadClass(className); + for (Method method : clazz.getDeclaredMethods()) { + if (!method.getName().equals(methodName)) continue; + if (!methodDescriptor(method).equals(descriptor)) continue; + validate(method); + method.setAccessible(true); + return method; + } + throw new IllegalStateException("Cannot find method " + methodName + descriptor + " in " + className); + } + + /** + * 按类与方法名解析唯一的 {@link RemoteCallable} 方法(用于无方法引用的逃生口调用)。 + * + *

    若该名称存在多个重载,视为歧义并抛出异常——此时应改用方法引用形式以借助参数类型消歧。

    + * + * @param clazz 方法所属类 + * @param methodName 方法名 + * @return 已校验通过、可访问的目标方法 + */ + static Method resolveByName(Class clazz, String methodName) { + Method found = null; + for (Method method : clazz.getDeclaredMethods()) { + if (!method.getName().equals(methodName)) continue; + if (!Modifier.isStatic(method.getModifiers())) continue; + if (!method.isAnnotationPresent(RemoteCallable.class)) continue; + if (found != null) { + throw new IllegalStateException("Ambiguous @RemoteCallable method " + clazz.getName() + "#" + methodName + "; use a method reference (RPC.call) to disambiguate overloads"); + } + method.setAccessible(true); + found = method; + } + if (found == null) { + throw new IllegalStateException("Cannot find @RemoteCallable static method " + clazz.getName() + "#" + methodName); + } + return found; + } + + private static Class loadClass(String className) { + try { + return Class.forName(className, true, Thread.currentThread().getContextClassLoader()); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("Cannot find RPC target class: " + className, e); + } + } + + private static void validate(Method method) { + if (!Modifier.isStatic(method.getModifiers())) { + throw new IllegalStateException("@RemoteCallable method must be static: " + method); + } + if (!method.isAnnotationPresent(RemoteCallable.class)) { + throw new IllegalStateException("Method is not @RemoteCallable: " + method); + } + } + + /** + * 解析方法各参数的编解码器,顺序与参数声明顺序一致。 + */ + static StreamCodec[] codecs(Method method) { + return CODEC_CACHE.computeIfAbsent(method, RpcMethods::resolveCodecs); + } + + @SuppressWarnings( + { + "unchecked", + "rawtypes" + } + ) + private static StreamCodec[] resolveCodecs(Method method) { + Parameter[] parameters = method.getParameters(); + StreamCodec[] codecs = new StreamCodec[parameters.length]; + for (int i = 0; i < parameters.length; i++) { + codecs[i] = resolveCodec(parameters[i]); + } + return (StreamCodec[]) codecs; + } + + private static StreamCodec resolveCodec(Parameter parameter) { + // 1. 优先使用 @CallableParam 显式指定的编解码器 + CallableParam annotation = parameter.getAnnotation(CallableParam.class); + if (annotation != null) { + return readCodecField(annotation.clazz(), annotation.field()); + } + // 2. 其次按类型解析(默认编解码器 / 类型自身声明的字段) + StreamCodec codec = codecForType(parameter.getType()); + if (codec != null) { + return codec; + } + throw new IllegalStateException("No StreamCodec for parameter type " + parameter.getType() + .getName() + "; annotate the parameter with @CallableParam, or declare a public static final StreamCodec field in " + parameter.getType() + .getName()); + } + + /** + * 解析方法返回值的编解码器(用于有返回值的 {@link RPC#invoke} 调用)。 + * + *

    解析顺序:方法上的 {@link CallableParam} 指定的字段 → 返回类型的默认编解码器 → + * 返回类型自身声明的 {@code public static final StreamCodec} 字段。

    + * + * @param method 目标方法(返回类型不得为 {@code void}) + * @return 返回值编解码器 + */ + static StreamCodec returnCodec(Method method) { + return RETURN_CODEC_CACHE.computeIfAbsent(method, RpcMethods::resolveReturnCodec); + } + + @SuppressWarnings("unchecked") + private static StreamCodec resolveReturnCodec(Method method) { + Class returnType = method.getReturnType(); + if (returnType == void.class) { + throw new IllegalStateException("@RemoteCallable method has no return value: " + method + "; use RPC.call instead"); + } + StreamCodec codec; + CallableParam annotation = method.getAnnotation(CallableParam.class); + if (annotation != null) { + codec = readCodecField(annotation.clazz(), annotation.field()); + } else { + codec = codecForType(returnType); + } + if (codec == null) { + throw new IllegalStateException("No StreamCodec for return type " + returnType.getName() + "; annotate the method with @CallableParam, or declare a public static final StreamCodec field in " + returnType.getName()); + } + return (StreamCodec) codec; + } + + /** + * 按类型解析编解码器:先查 {@link ByteBufCodecs} 默认编解码器,再回退到该类型自身声明的 + * {@code public static final StreamCodec} 字段。 + * + * @param type 目标类型 + * @return 匹配的编解码器;若都没有则返回 {@code null} + */ + private static @Nullable StreamCodec codecForType(Class type) { + StreamCodec codec = DEFAULTS.get(type); + if (codec != null) { + return codec; + } + return findDeclaredCodec(type); + } + + /** + * 在给定类型中查找可用于编码该类型自身的 {@code public static final StreamCodec} 字段。 + * + *

    字段的负载类型实参须与参数类型兼容;若存在多个匹配字段则视为歧义并抛出异常。

    + * + * @param type 参数类型 + * @return 匹配的编解码器;若无匹配字段返回 {@code null} + */ + private static @Nullable StreamCodec findDeclaredCodec(Class type) { + StreamCodec found = null; + String foundName = null; + for (Field field : type.getDeclaredFields()) { + int mods = field.getModifiers(); + if (!Modifier.isPublic(mods) || !Modifier.isStatic(mods) || !Modifier.isFinal(mods)) { + continue; + } + if (!StreamCodec.class.isAssignableFrom(field.getType())) continue; + if (!isCodecForPayload(field.getGenericType(), type)) continue; + if (found != null) { + throw new IllegalStateException("Ambiguous StreamCodec fields in " + type.getName() + ": " + foundName + " and " + field.getName() + "; use @CallableParam to disambiguate"); + } + try { + field.setAccessible(true); + found = (StreamCodec) field.get(null); + foundName = field.getName(); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Cannot read StreamCodec field " + type.getName() + "." + field.getName(), e); + } + } + return found; + } + + /** + * 判断 {@code StreamCodec} 的负载类型实参 {@code P} 是否可承载 {@code payloadType}。 + */ + private static boolean isCodecForPayload(Type genericType, Class payloadType) { + if (!(genericType instanceof ParameterizedType pt)) { + // 原始类型无法校验负载类型,保守地接受 + return true; + } + Type[] args = pt.getActualTypeArguments(); + if (args.length < 2) return true; + Type payloadArg = args[1]; + if (payloadArg instanceof Class payloadClass) { + return payloadClass.isAssignableFrom(payloadType); + } + if (payloadArg instanceof ParameterizedType payloadPt && payloadPt.getRawType() instanceof Class raw) { + return raw.isAssignableFrom(payloadType); + } + // 通配符 / 类型变量等无法静态判定,保守接受 + return true; + } + + private static StreamCodec readCodecField(Class clazz, String fieldName) { + try { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + Object value = field.get(null); + if (value instanceof StreamCodec codec) { + return codec; + } + throw new IllegalStateException("Field " + clazz.getName() + "." + fieldName + " is not a StreamCodec"); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new IllegalStateException("Cannot read StreamCodec from " + clazz.getName() + "." + fieldName, e); + } + } + + /** + * 运行目标方法的接收端校验器。无校验器(默认哨兵)时始终放行。 + * + * @param method 目标方法 + * @param ctx 网络包上下文 + * @param args 已解码的实参 + * @return 是否允许执行 + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + static boolean validate(Method method, IPayloadContext ctx, Object[] args) { + IRemoteCallableValidator validator = VALIDATOR_CACHE.computeIfAbsent(method, RpcMethods::resolveValidator).orElse(null); + return validator == null || validator.validate(ctx, method, args); + } + + private static Optional resolveValidator(Method method) { + RemoteCallable annotation = method.getAnnotation(RemoteCallable.class); + Class type = annotation.validator(); + // 默认哨兵:注解未指定校验器,等价于始终放行 + if (type == IRemoteCallableValidator.class) { + return Optional.empty(); + } + try { + var constructor = type.getDeclaredConstructor(); + constructor.setAccessible(true); + return Optional.of(constructor.newInstance()); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Cannot instantiate RPC validator " + type.getName() + " (needs a no-arg constructor)", e); + } + } + + /** + * 计算方法的 JVM 字节码描述符,例如 {@code (Ljava/lang/String;I)V}。 + */ + static String methodDescriptor(Method method) { + StringBuilder builder = new StringBuilder("("); + for (Class parameterType : method.getParameterTypes()) { + builder.append(typeDescriptor(parameterType)); + } + return builder.append(')').append(typeDescriptor(method.getReturnType())).toString(); + } + + private static String typeDescriptor(Class type) { + if (type == void.class) return "V"; + if (type == boolean.class) return "Z"; + if (type == byte.class) return "B"; + if (type == char.class) return "C"; + if (type == short.class) return "S"; + if (type == int.class) return "I"; + if (type == long.class) return "J"; + if (type == float.class) return "F"; + if (type == double.class) return "D"; + if (type.isArray()) return "[" + typeDescriptor(type.getComponentType()); + return "L" + type.getName().replace('.', '/') + ";"; + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcPayload.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcPayload.java new file mode 100644 index 00000000..0e7d952c --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcPayload.java @@ -0,0 +1,103 @@ +package dev.anvilcraft.lib.v2.rpc; + +import dev.anvilcraft.lib.v2.network.packet.IInsensitiveBiPacket; +import dev.anvilcraft.lib.v2.network.packet.IPacket; +import dev.anvilcraft.lib.v2.rpc.client.AnvilLibRpcClient; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import net.minecraft.core.RegistryAccess; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.world.entity.player.Player; +import net.neoforged.neoforge.network.connection.ConnectionType; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import org.jetbrains.annotations.ApiStatus; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * 承载一次远程调用的网络包。 + * + *

    线上仅传输一段不透明字节({@code 索引 + 编码后的实参})。仿照 + * {@link dev.anvilcraft.lib.v2.network.packet 网络包} 中 SyncPayload 的做法,真正依赖「当前侧」的 + * 编解码在已知方向的位置进行:

    + *
      + *
    • 编码在发送侧({@link RpcTarget} 已知发送方向,故已知使用哪个 {@link RpcRegistry});
    • + *
    • 解码在处理侧({@link IPayloadContext#flow()} 已知接收方向)——而非 {@link StreamCodec} + * 的解码回调中(其运行于网络线程,无法可靠判断当前侧)。
    • + *
    + * + * @see RPC#call + */ +@ApiStatus.Internal +public class RpcPayload implements IRpcPayload, IInsensitiveBiPacket { + public static final Type TYPE = IPacket.type(AnvilLibRpc.mod("rpc")); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.BYTE_ARRAY, + RpcPayload::data, + RpcPayload::new + ); + + private final byte[] data; + + RpcPayload(byte[] data) { + this.data = data; + } + + /** + * 在发送侧将方法索引与实参编码为字节。 + * + * @param registry 发送侧索引表(服务端为权威表,客户端为已采纳表) + * @param registryAccess 发送侧注册表访问器,用于构造可承载注册表对象的缓冲区 + * @param method 目标方法 + * @param args 实参 + * @return 编码后的网络包 + */ + static RpcPayload encode(RpcRegistry registry, RegistryAccess registryAccess, Method method, Object[] args) { + RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf(Unpooled.buffer(), registryAccess, ConnectionType.NEOFORGE); + return new RpcPayload(IRpcPayload.encodeParams(registry, method, args, buf)); + } + + byte[] data() { + return this.data; + } + + @Override + public void bidirectionalHandler(IPayloadContext ctx) { + ctx.enqueueWork(() -> this.handle(ctx)); + } + + @Override + public void handleOnBothSide(Player player) { + // 实际处理在 handle(ctx) 中完成(需要 ctx.flow() 与 ctx.player()) + } + + private void handle(IPayloadContext ctx) { + // 接收 clientbound 表示本侧为客户端,使用已采纳的客户端表;serverbound 则本侧为服务端,使用权威表 + RpcRegistry registry = ctx.flow().isClientbound() ? AnvilLibRpcClient.REGISTRY : AnvilLibRpc.REGISTRY; + RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf( + Unpooled.wrappedBuffer(this.data), ctx.player().registryAccess(), ConnectionType.NEOFORGE + ); + Method method = registry.byIndex(buf.readVarInt()); + Object[] args = IRpcPayload.decodeParams(method, buf); + if (!RpcMethods.validate(method, ctx, args)) { + // 校验未通过:静默丢弃该单向调用 + return; + } + try { + method.invoke(null, args); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Cannot invoke RPC method " + method, e); + } catch (InvocationTargetException e) { + throw new RuntimeException("RPC method " + method + " threw an exception", e.getCause()); + } + } + + @Override + public CustomPacketPayload.Type type() { + return RpcPayload.TYPE; + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcPendingCalls.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcPendingCalls.java new file mode 100644 index 00000000..cfe13564 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcPendingCalls.java @@ -0,0 +1,109 @@ +package dev.anvilcraft.lib.v2.rpc; + +import org.jetbrains.annotations.ApiStatus; +import javax.annotation.Nullable; + +import java.lang.reflect.Method; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 一侧(caller)待响应的 {@link RPC#invoke} 调用登记表。 + * + *

    每次 invoke 分配一个自增 {@code callId},登记 {@code callId -> (目标方法, future, 登记时的 tick)}; + * 当对端返回携带相同 {@code callId} 的响应时,按其完成对应的 {@link CompletableFuture}。目标方法用于在解码 + * 响应时确定返回值编解码器。

    + * + *

    超时

    + *

    由所属侧的 tick 事件驱动 {@link #tick()}:超过 {@link #TIMEOUT_TICKS} 个 tick 仍未收到响应的调用, + * 其 future 以 {@link TimeoutException} 失败并从表中移除,避免泄漏。

    + * + *

    双实例

    + *

    与 {@link RpcRegistry} 同理,服务端 / 客户端各持一个实例({@link AnvilLibRpc#PENDING} / + * {@code AnvilLibRpcClient.PENDING}),各由自身的 tick 事件驱动。否则在单人 / 局域网(同 JVM)下两侧的 + * {@code callId} 会相互覆盖。响应处理时按 {@code ctx.flow()} 选择实例。

    + */ +@ApiStatus.Internal +public final class RpcPendingCalls { + /** + * 调用超时的 tick 数:超过该时长仍无响应即判定超时。 + */ + public static final int TIMEOUT_TICKS = 100; + + private final AtomicInteger nextId = new AtomicInteger(); + private final Map pending = new ConcurrentHashMap<>(); + /** + * 自增的 tick 计数器,作为登记项的时间戳基准。 + */ + private final AtomicInteger currentTick = new AtomicInteger(0); + + /** + * 构造一个空的登记表。 + */ + public RpcPendingCalls() { + } + + /** + * 登记一次待响应调用。 + * + * @param method 目标方法 + * @param future 调用完成时要兑现的 future + * @return 分配的 callId + */ + int register(Method method, CompletableFuture future) { + int id = nextId.getAndIncrement(); + pending.put(id, new Pending(method, future, currentTick.get())); + return id; + } + + /** + * 取出(并移除)指定 callId 的登记项。 + * + * @param callId 调用 id + * @return 登记项;若不存在(如超时已移除)返回 {@code null} + */ + @Nullable Pending remove(int callId) { + return pending.remove(callId); + } + + /** + * 由所属侧的 tick 事件每 tick 调用一次:推进计时并使超时调用失败。 + */ + @ApiStatus.Internal + public void tick() { + int now = currentTick.incrementAndGet(); + Iterator> it = pending.entrySet().iterator(); + while (it.hasNext()) { + Pending entry = it.next().getValue(); + if (now - entry.registeredTick() < TIMEOUT_TICKS) continue; + it.remove(); + entry.future() + .completeExceptionally(new TimeoutException("RPC call timed out after " + TIMEOUT_TICKS + " ticks: " + entry.method())); + } + } + + /** + * 清空登记表,并使所有未完成的调用以异常失败。 + * + *

    在所属侧的连接终止时调用(服务端 {@code ServerStoppedEvent}、客户端 + * {@code ClientPlayerNetworkEvent.LoggingOut}),避免遗留的 future 永久挂起或残留到下一次会话。

    + */ + @ApiStatus.Internal + public void clear() { + Iterator> it = pending.entrySet().iterator(); + while (it.hasNext()) { + Pending entry = it.next().getValue(); + it.remove(); + entry.future().completeExceptionally(new CancellationException("RPC connection closed before response")); + } + currentTick.set(0); + } + + record Pending(Method method, CompletableFuture future, int registeredTick) { + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcRegistry.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcRegistry.java new file mode 100644 index 00000000..5e17777d --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcRegistry.java @@ -0,0 +1,163 @@ +package dev.anvilcraft.lib.v2.rpc; + +import lombok.extern.slf4j.Slf4j; +import net.neoforged.fml.loading.LoadingModList; +import net.neoforged.fml.loading.moddiscovery.ModFileInfo; +import net.neoforged.neoforgespi.language.ModFileScanData; +import org.jetbrains.annotations.ApiStatus; +import javax.annotation.Nullable; + +import java.lang.annotation.ElementType; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * {@link RemoteCallable} 方法的索引表。索引以「规范键」{@code 全限定类名 + '#' + 方法名 + 方法描述符} + * 为单位分配,网络包仅传输整数索引。 + * + *

    双实例与一致性

    + *

    本类按服务端 / 客户端各维护一个实例:

    + *
      + *
    • 权威实例({@link AnvilLibRpc#REGISTRY}):由本地扫描填充,从不被 + * {@link #adopt(Map)} 覆盖。服务端在 Configuration 阶段用 {@link #snapshot()} 将其映射下发给客户端。
    • + *
    • 客户端实例({@code AnvilLibRpcClient.REGISTRY}):仅通过 {@link #adopt(Map)} 采纳服务端 + * 下发的映射。
    • + *
    + * + *

    由此即便客户端断开某服务器后再开局域网,权威实例仍是本地扫描结果(未被污染),不会把上一个服务器的 + * 映射当作自己的下发出去。编解码时按方向选择实例(发送方向决定编码端、{@code ctx.flow()} 决定解码端), + * 双向 RPC 的索引始终一致。

    + */ +@Slf4j +@ApiStatus.Internal +public final class RpcRegistry { + private static final String ANNOTATION_DESCRIPTOR = "L" + RemoteCallable.class.getName().replace('.', '/') + ";"; + + /** + * 是否为权威实例:权威实例在未初始化时执行本地扫描,非权威(客户端)实例则要求先经 + * {@link #adopt(Map)} 采纳服务端映射。 + */ + private final boolean authoritative; + private @Nullable Map indexByKey; + private @Nullable Map keyByIndex; + + /** + * @param authoritative {@code true} 为权威(服务端)实例,按需本地扫描;{@code false} 为客户端实例,仅采纳下发 + */ + public RpcRegistry(boolean authoritative) { + this.authoritative = authoritative; + } + + private static String canonicalKey(Method method) { + return method.getDeclaringClass().getName() + "#" + method.getName() + RpcMethods.methodDescriptor(method); + } + + private static Method resolve(String key) { + int hash = key.indexOf('#'); + int paren = key.indexOf('(', hash); + String className = key.substring(0, hash); + String methodName = key.substring(hash + 1, paren); + String descriptor = key.substring(paren); + return RpcMethods.resolve(className, methodName, descriptor); + } + + /** + * 返回方法对应的整数索引。 + * + * @param method 已注册的 {@link RemoteCallable} 方法 + * @return 该方法的索引 + */ + public synchronized int index(Method method) { + ensureLoaded(); + String key = canonicalKey(method); + assert indexByKey != null; + Integer index = indexByKey.get(key); + if (index == null) { + throw new IllegalStateException("Method is not a registered @RemoteCallable: " + key); + } + return index; + } + + /** + * 返回索引对应的方法。 + * + * @param index 方法索引 + * @return 对应的 {@link RemoteCallable} 方法 + */ + public synchronized Method byIndex(int index) { + ensureLoaded(); + assert keyByIndex != null; + String key = keyByIndex.get(index); + if (key == null) { + throw new IllegalStateException("Unknown RPC method index: " + index + " (registered: " + keyByIndex.size() + ")"); + } + return resolve(key); + } + + /** + * 返回当前 {@code 索引 -> 规范键} 映射的快照,用于在 Configuration 阶段下发给客户端。 + * + * @return 索引到规范键的映射 + */ + public synchronized Map snapshot() { + ensureLoaded(); + assert keyByIndex != null; + return new HashMap<>(keyByIndex); + } + + /** + * 用给定(服务端权威)映射覆盖本实例的索引表。 + * + * @param map 服务端下发的 {@code 索引 -> 规范键} 映射 + */ + public synchronized void adopt(Map map) { + Map byIndex = new HashMap<>(map); + Map byKey = new HashMap<>(); + byIndex.forEach((index, key) -> byKey.put(key, index)); + this.keyByIndex = byIndex; + this.indexByKey = byKey; + log.debug("Adopted {} authoritative @RemoteCallable index mapping(s)", byIndex.size()); + } + + private void ensureLoaded() { + if (indexByKey != null) return; + if (!authoritative) { + throw new IllegalStateException("RPC index mapping has not been received from the server yet"); + } + scan(); + } + + private void scan() { + List keys = getKeys(); + // 排序仅为本地索引分配的确定性与日志可读性;跨端一致性由服务端下发映射保证 + keys.sort(null); + + Map byIndex = new HashMap<>(); + Map byKey = new HashMap<>(); + for (int i = 0; i < keys.size(); i++) { + byIndex.put(i, keys.get(i)); + byKey.put(keys.get(i), i); + log.debug("Registered @RemoteCallable [{}] {}", i, keys.get(i)); + } + this.keyByIndex = byIndex; + this.indexByKey = byKey; + log.info("Scan complete - {} @RemoteCallable method(s) registered.", keys.size()); + } + + @SuppressWarnings("UnstableApiUsage") + private static List getKeys() { + List keys = new ArrayList<>(); + for (ModFileInfo fileInfo : LoadingModList.get().getModFiles()) { + for (ModFileScanData.AnnotationData annotation : fileInfo.getFile().getScanResult().getAnnotations()) { + if (!annotation.annotationType().getDescriptor().equals(ANNOTATION_DESCRIPTOR)) continue; + if (annotation.targetType() != ElementType.METHOD) continue; + // memberName 形如 "methodName(Ljava/lang/String;I)V" + keys.add(annotation.clazz().getClassName() + "#" + annotation.memberName()); + } + } + return keys; + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcRequestPayload.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcRequestPayload.java new file mode 100644 index 00000000..a87df604 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcRequestPayload.java @@ -0,0 +1,109 @@ +package dev.anvilcraft.lib.v2.rpc; + +import dev.anvilcraft.lib.v2.network.packet.IInsensitiveBiPacket; +import dev.anvilcraft.lib.v2.network.packet.IPacket; +import dev.anvilcraft.lib.v2.rpc.client.AnvilLibRpcClient; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import net.minecraft.core.RegistryAccess; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.world.entity.player.Player; +import net.neoforged.neoforge.network.connection.ConnectionType; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import org.jetbrains.annotations.ApiStatus; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * 有返回值远程调用的请求包:携带 {@code callId}、方法索引与实参。接收端执行方法后,回复一个携带相同 + * {@code callId} 的 {@link RpcResponsePayload}。 + * + * @see RPC#invoke + */ +@ApiStatus.Internal +public class RpcRequestPayload implements IRpcPayload, IInsensitiveBiPacket { + public static final Type TYPE = IPacket.type(AnvilLibRpc.mod("rpc_request")); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.BYTE_ARRAY, + RpcRequestPayload::data, + RpcRequestPayload::new + ); + + private final byte[] data; + + RpcRequestPayload(byte[] data) { + this.data = data; + } + + /** + * 在发送侧编码请求。 + * + * @param registry 发送侧索引表 + * @param registryAccess 发送侧注册表访问器 + * @param callId 调用 id + * @param method 目标方法 + * @param args 实参 + * @return 编码后的请求包 + */ + static RpcRequestPayload encode( + RpcRegistry registry, RegistryAccess registryAccess, int callId, Method method, Object[] args + ) { + RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf(Unpooled.buffer(), registryAccess, ConnectionType.NEOFORGE); + buf.writeVarInt(callId); + byte[] data = IRpcPayload.encodeParams(registry, method, args, buf); + return new RpcRequestPayload(data); + } + + byte[] data() { + return this.data; + } + + @Override + public void bidirectionalHandler(IPayloadContext ctx) { + ctx.enqueueWork(() -> this.handle(ctx)); + } + + @Override + public void handleOnBothSide(Player player) { + // 实际处理在 handle(ctx) 中完成 + } + + private void handle(IPayloadContext ctx) { + // 收到 clientbound 请求表示本侧为客户端,使用已采纳的客户端索引表;否则为服务端权威表 + boolean onClient = ctx.flow().isClientbound(); + RpcRegistry registry = onClient ? AnvilLibRpcClient.REGISTRY : AnvilLibRpc.REGISTRY; + RegistryAccess registryAccess = ctx.player().registryAccess(); + RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf( + Unpooled.wrappedBuffer(this.data), registryAccess, ConnectionType.NEOFORGE + ); + int callId = buf.readVarInt(); + Method method = registry.byIndex(buf.readVarInt()); + Object[] args = IRpcPayload.decodeParams(method, buf); + if (!RpcMethods.validate(method, ctx, args)) { + ctx.reply(RpcResponsePayload.failure(callId, "RPC call rejected by validator: " + method)); + return; + } + + Object result; + try { + result = method.invoke(null, args); + } catch (IllegalAccessException e) { + ctx.reply(RpcResponsePayload.failure(callId, "Cannot invoke RPC method " + method)); + throw new IllegalStateException("Cannot invoke RPC method " + method, e); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + ctx.reply(RpcResponsePayload.failure(callId, String.valueOf(cause))); + throw new RuntimeException("RPC method " + method + " threw an exception", cause); + } + ctx.reply(RpcResponsePayload.success(registry, registryAccess, callId, method, result)); + } + + @Override + public CustomPacketPayload.Type type() { + return RpcRequestPayload.TYPE; + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcResponsePayload.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcResponsePayload.java new file mode 100644 index 00000000..a9400137 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcResponsePayload.java @@ -0,0 +1,113 @@ +package dev.anvilcraft.lib.v2.rpc; + +import dev.anvilcraft.lib.v2.network.packet.IInsensitiveBiPacket; +import dev.anvilcraft.lib.v2.network.packet.IPacket; +import dev.anvilcraft.lib.v2.rpc.client.AnvilLibRpcClient; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import net.minecraft.core.RegistryAccess; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.world.entity.player.Player; +import net.neoforged.neoforge.network.connection.ConnectionType; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import org.jetbrains.annotations.ApiStatus; + +import java.lang.reflect.Method; +import java.util.concurrent.CompletionException; + +/** + * 有返回值远程调用的响应包:携带请求的 {@code callId}、成功标志,以及编码后的返回值或错误信息。 + * + *

    caller 侧据 {@code callId} 在 {@link RpcPendingCalls} 中找到对应 future 与目标方法,用方法的返回值 + * 编解码器({@link RpcMethods#returnCodec})解码返回值并完成 future。

    + * + * @see RPC#invoke + */ +@ApiStatus.Internal +public class RpcResponsePayload implements IInsensitiveBiPacket { + public static final Type TYPE = IPacket.type(AnvilLibRpc.mod("rpc_response")); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.BYTE_ARRAY, + RpcResponsePayload::data, + RpcResponsePayload::new + ); + + private final byte[] data; + + RpcResponsePayload(byte[] data) { + this.data = data; + } + + /** + * 编码一个成功响应。 + */ + static RpcResponsePayload success(RpcRegistry registry, RegistryAccess registryAccess, int callId, Method method, Object result) { + RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf(Unpooled.buffer(), registryAccess, ConnectionType.NEOFORGE); + buf.writeVarInt(callId); + buf.writeBoolean(true); + RpcMethods.returnCodec(method).encode(buf, result); + return read(buf); + } + + /** + * 编码一个失败响应,仅携带错误信息文本。 + */ + static RpcResponsePayload failure(int callId, String message) { + RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf(Unpooled.buffer(), RegistryAccess.EMPTY, ConnectionType.NEOFORGE); + buf.writeVarInt(callId); + buf.writeBoolean(false); + buf.writeUtf(message); + return read(buf); + } + + private static RpcResponsePayload read(RegistryFriendlyByteBuf buf) { + byte[] data = new byte[buf.readableBytes()]; + buf.readBytes(data); + return new RpcResponsePayload(data); + } + + byte[] data() { + return this.data; + } + + @Override + public void bidirectionalHandler(IPayloadContext ctx) { + ctx.enqueueWork(() -> this.handle(ctx)); + } + + @Override + public void handleOnBothSide(Player player) { + // 实际处理在 handle(ctx) 中完成 + } + + private void handle(IPayloadContext ctx) { + // 收到 clientbound 响应表示本侧为发起 invoke 的客户端 + RpcPendingCalls pending = ctx.flow().isClientbound() ? AnvilLibRpcClient.PENDING : AnvilLibRpc.PENDING; + RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf( + Unpooled.wrappedBuffer(this.data), + ctx.player().registryAccess(), + ConnectionType.NEOFORGE + ); + int callId = buf.readVarInt(); + RpcPendingCalls.Pending entry = pending.remove(callId); + if (entry == null) { + // 调用方未登记该 callId(可能已超时移除),忽略 + return; + } + boolean ok = buf.readBoolean(); + if (!ok) { + entry.future().completeExceptionally(new CompletionException("Remote RPC failed: " + buf.readUtf(), null)); + return; + } + Object result = RpcMethods.returnCodec(entry.method()).decode(buf); + entry.future().complete(result); + } + + @Override + public CustomPacketPayload.Type type() { + return RpcResponsePayload.TYPE; + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcTarget.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcTarget.java new file mode 100644 index 00000000..1f3aa2ee --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/RpcTarget.java @@ -0,0 +1,81 @@ +package dev.anvilcraft.lib.v2.rpc; + +import net.minecraft.core.RegistryAccess; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.neoforge.network.PacketDistributor; + +/** + * 一次远程调用的目标端,决定 {@link RPC#call} 产生的网络包发往何处,并提供对应发送方向的编码上下文。 + * + * @see #player(ServerPlayer) + * @see #server() + */ +public interface RpcTarget { + /** + * 目标端为指定客户端玩家(服务端 -> 客户端)。 + * + * @param player 接收远程调用的服务端玩家 + * @return 指向该玩家的目标端 + */ + static RpcTarget player(ServerPlayer player) { + return new RpcTarget() { + @Override + public void send(CustomPacketPayload payload) { + PacketDistributor.sendToPlayer(player, payload); + } + + @Override + public RpcRegistry registry() { + return AnvilLibRpc.REGISTRY; + } + + @Override + public RegistryAccess registryAccess() { + return player.registryAccess(); + } + + @Override + public RpcPendingCalls pending() { + return AnvilLibRpc.PENDING; + } + }; + } + + /** + * 目标端为服务端(客户端 -> 服务端)。仅可在客户端调用。 + * + * @return 指向服务端的目标端 + */ + static RpcTarget server() { + return ClientRpcTargets.server(); + } + + /** + * 将网络包发送至该目标端。 + * + * @param payload 远程调用网络包 + */ + void send(CustomPacketPayload payload); + + /** + * 编码本次调用应使用的索引表(由发送方向决定:服务端发送用权威表,客户端发送用已采纳表)。 + * + * @return 发送侧索引表 + */ + RpcRegistry registry(); + + /** + * 编码所用的注册表访问器,用于构造可承载注册表对象的缓冲区。 + * + * @return 发送侧注册表访问器 + */ + RegistryAccess registryAccess(); + + /** + * 发起方(本侧)待响应调用登记表,用于 {@link RPC#invoke} 关联响应。 + * + * @return 发送侧的 pending 表 + */ + RpcPendingCalls pending(); +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/client/AnvilLibRpcClient.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/client/AnvilLibRpcClient.java new file mode 100644 index 00000000..44127fdd --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/client/AnvilLibRpcClient.java @@ -0,0 +1,55 @@ +package dev.anvilcraft.lib.v2.rpc.client; + +import dev.anvilcraft.lib.v2.rpc.AnvilLibRpc; +import dev.anvilcraft.lib.v2.rpc.RpcPendingCalls; +import dev.anvilcraft.lib.v2.rpc.RpcRegistry; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.common.NeoForge; +import org.jetbrains.annotations.ApiStatus; + +/** + * 客户端侧入口,持有仅采纳服务端下发映射的客户端索引表。 + * + *

    与权威实例 {@link AnvilLibRpc#REGISTRY} 分离,避免客户端连接某服务器后采纳的映射污染本地权威映射—— + * 从而保证客户端断开后开启局域网时,集成服务端仍下发其本地扫描得到的正确映射。

    + */ +@Mod(value = AnvilLibRpc.MOD_ID, dist = Dist.CLIENT) +@ApiStatus.Internal +public class AnvilLibRpcClient { + /** + * 客户端索引表:仅通过 {@link RpcRegistry#adopt} 采纳服务端下发的映射。 + */ + public static final RpcRegistry REGISTRY = new RpcRegistry(false); + + /** + * 客户端侧待响应的 {@link dev.anvilcraft.lib.v2.rpc.RPC#invoke} 调用登记表。 + */ + public static final RpcPendingCalls PENDING = new RpcPendingCalls(); + + public AnvilLibRpcClient(IEventBus modEventBus, ModContainer modContainer) { + NeoForge.EVENT_BUS.addListener(this::onClientTick); + NeoForge.EVENT_BUS.addListener(this::onLoggingOut); + } + + /** + * 驱动客户端侧 {@link dev.anvilcraft.lib.v2.rpc.RPC#invoke} 调用的超时检查。 + */ + @SubscribeEvent + public void onClientTick(ClientTickEvent.Post event) { + AnvilLibRpcClient.PENDING.tick(); + } + + /** + * 客户端登出时清理客户端侧未完成的 {@link dev.anvilcraft.lib.v2.rpc.RPC#invoke} 调用。 + */ + @SubscribeEvent + public void onLoggingOut(ClientPlayerNetworkEvent.LoggingOut event) { + AnvilLibRpcClient.PENDING.clear(); + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/client/package-info.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/client/package-info.java new file mode 100644 index 00000000..e76b5e46 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/client/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.rpc.client; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/config/RpcConfigurationFinishPayload.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/config/RpcConfigurationFinishPayload.java new file mode 100644 index 00000000..d6d31005 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/config/RpcConfigurationFinishPayload.java @@ -0,0 +1,34 @@ +package dev.anvilcraft.lib.v2.rpc.config; + +import dev.anvilcraft.lib.v2.network.packet.IPacket; +import dev.anvilcraft.lib.v2.network.packet.IServerboundPacket; +import dev.anvilcraft.lib.v2.rpc.AnvilLibRpc; +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.world.entity.player.Player; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import org.jetbrains.annotations.ApiStatus; + +/** + * 客户端在采纳服务端 RPC 索引映射后回复的结束包,用于标记 {@link RpcConfigurationTask} 完成。 + */ +@ApiStatus.Internal +public class RpcConfigurationFinishPayload implements IServerboundPacket { + public static final RpcConfigurationFinishPayload INSTANCE = new RpcConfigurationFinishPayload(); + public static final Type TYPE = IPacket.type(AnvilLibRpc.mod("rpc_config_finish")); + public static final StreamCodec STREAM_CODEC = StreamCodec.unit(INSTANCE); + + @Override + public void serverHandler(IPayloadContext ctx) { + ctx.finishCurrentTask(RpcConfigurationTask.TYPE); + } + + @Override + public void handleOnServer(Player player) { + } + + @Override + public Type type() { + return RpcConfigurationFinishPayload.TYPE; + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/config/RpcConfigurationPayload.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/config/RpcConfigurationPayload.java new file mode 100644 index 00000000..9a101d52 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/config/RpcConfigurationPayload.java @@ -0,0 +1,47 @@ +package dev.anvilcraft.lib.v2.rpc.config; + +import dev.anvilcraft.lib.v2.network.packet.IClientboundPacket; +import dev.anvilcraft.lib.v2.network.packet.IPacket; +import dev.anvilcraft.lib.v2.rpc.AnvilLibRpc; +import dev.anvilcraft.lib.v2.rpc.client.AnvilLibRpcClient; +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.world.entity.player.Player; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import org.jetbrains.annotations.ApiStatus; + +import java.util.HashMap; +import java.util.Map; + +/** + * Configuration 阶段由服务端下发给客户端的 RPC 索引映射包。 + * + *

    客户端收到后用其覆盖本地索引表({@link dev.anvilcraft.lib.v2.rpc.RpcRegistry#adopt(Map)}),从而与服务端共享同一套 + * {@code 索引 -> 规范键} 映射,保证双向 RPC 的索引一致;随后回复 + * {@link RpcConfigurationFinishPayload} 以结束该配置任务。

    + */ +@ApiStatus.Internal +public record RpcConfigurationPayload(Map indexMap) implements IClientboundPacket { + public static final Type TYPE = IPacket.type(AnvilLibRpc.mod("rpc_config")); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.map(HashMap::new, ByteBufCodecs.VAR_INT, ByteBufCodecs.STRING_UTF8), + RpcConfigurationPayload::indexMap, + RpcConfigurationPayload::new + ); + + @Override + public void clientHandler(IPayloadContext ctx) { + ctx.enqueueWork(() -> AnvilLibRpcClient.REGISTRY.adopt(this.indexMap())); + ctx.reply(RpcConfigurationFinishPayload.INSTANCE); + } + + @Override + public void handleOnClient(Player player) { + } + + @Override + public Type type() { + return RpcConfigurationPayload.TYPE; + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/config/RpcConfigurationTask.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/config/RpcConfigurationTask.java new file mode 100644 index 00000000..f234807e --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/config/RpcConfigurationTask.java @@ -0,0 +1,26 @@ +package dev.anvilcraft.lib.v2.rpc.config; + +import dev.anvilcraft.lib.v2.rpc.AnvilLibRpc; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.neoforged.neoforge.network.configuration.ICustomConfigurationTask; +import org.jetbrains.annotations.ApiStatus; + +import java.util.function.Consumer; + +/** + * 服务端配置任务:在 Configuration 阶段向客户端下发权威的 RPC 索引映射。 + */ +@ApiStatus.Internal +public record RpcConfigurationTask() implements ICustomConfigurationTask { + public static final Type TYPE = new Type(AnvilLibRpc.mod("rpc_config")); + + @Override + public void run(Consumer sender) { + sender.accept(new RpcConfigurationPayload(AnvilLibRpc.REGISTRY.snapshot())); + } + + @Override + public Type type() { + return RpcConfigurationTask.TYPE; + } +} diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/config/package-info.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/config/package-info.java new file mode 100644 index 00000000..05bb04d9 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/config/package-info.java @@ -0,0 +1,12 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +@Network(protocol = PacketProtocol.CONFIGURATION) +package dev.anvilcraft.lib.v2.rpc.config; + +import dev.anvilcraft.lib.v2.network.register.Network; +import dev.anvilcraft.lib.v2.network.register.PacketProtocol; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/package-info.java b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/package-info.java new file mode 100644 index 00000000..1ea2c6e7 --- /dev/null +++ b/module.rpc/src/main/java/dev/anvilcraft/lib/v2/rpc/package-info.java @@ -0,0 +1,12 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +@Network(protocol = PacketProtocol.PLAY) +package dev.anvilcraft.lib.v2.rpc; + +import dev.anvilcraft.lib.v2.network.register.Network; +import dev.anvilcraft.lib.v2.network.register.PacketProtocol; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.rpc/src/main/resources/META-INF/neoforge.mods.toml b/module.rpc/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 00000000..70445b6c --- /dev/null +++ b/module.rpc/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,86 @@ +# This is an example mods.toml file. It contains the data relating to the loading mods. +# There are several mandatory fields (#mandatory), and many more that are optional (#optional). +# The overall format is standard TOML format, v0.5.0. +# Note that there are a couple of TOML lists in this file. +# Find more information on toml format here: https://github.com/toml-lang/toml +# The name of the mod loader type to load - for regular FML @Mod mods it should be javafml +modLoader = "javafml" #mandatory +# A version range to match for said mod loader - for regular FML @Mod it will be the the FML version. This is currently 47. +loaderVersion = "${loader_version_range}" #mandatory +# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties. +# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here. +license = "${mod_license}" +# A URL to refer people to when problems occur with this mod +#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional +# A list of mods - how many allowed here is determined by the individual mod loader +[[mods]] #mandatory +# The modid of the mod +modId = "${mod_id}" #mandatory +# The version number of the mod +version = "${mod_version}" #mandatory +# A display name for the mod +displayName = "${mod_name}" #mandatory +# A URL to query for updates for this mod. See the JSON update specification https://docs.neoforge.net/docs/misc/updatechecker/ +#updateJSONURL="https://change.me.example.invalid/updates.json" #optional +# A URL for the "homepage" for this mod, displayed in the mod UI +displayURL = "https://github.com/Anvil-Dev/AnvilLib" +# A file name (in the root of the mod JAR) containing a logo for display +logoFile = "icon.png" #optional +# A text field displayed in the mod UI +#credits="" #optional +# A text field displayed in the mod UI +authors = "${mod_authors}" #optional +# Display Test controls the display for your mod in the server connection screen +# MATCH_VERSION means that your mod will cause a red X if the versions on client and server differ. This is the default behaviour and should be what you choose if you have server and client elements to your mod. +# IGNORE_SERVER_VERSION means that your mod will not cause a red X if it's present on the server but not on the client. This is what you should use if you're a server only mod. +# IGNORE_ALL_VERSION means that your mod will not cause a red X if it's present on the client or the server. This is a special case and should only be used if your mod has no server component. +# NONE means that no display test is set on your mod. You need to do this yourself, see IExtensionPoint.DisplayTest for more information. You can define any scheme you wish with this value. +# IMPORTANT NOTE: this is NOT an instruction as to which environments (CLIENT or DEDICATED SERVER) your mod loads on. Your mod should load (and maybe do nothing!) whereever it finds itself. +#displayTest="MATCH_VERSION" # MATCH_VERSION is the default if nothing is specified (#optional) + +# The description text for the mod (multi line!) (#mandatory) +description = '''${mod_description}''' + +# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded. +[[mixins]] +config = "${mod_id}.mixins.json" + +# The [[accessTransformers]] block allows you to declare where your AT file is. +# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg +#[[accessTransformers]] +#file="META-INF/accesstransformer.cfg" + +# The coremods config file path is not configurable and is always loaded from META-INF/coremods.json + +# A dependency - use the . to indicate dependency for a specific modid. Dependencies are optional. +[[dependencies."${mod_id}"]] #optional +# the modid of the dependency +modId = "neoforge" #mandatory +# The type of the dependency. Can be one of "required", "optional", "incompatible" or "discouraged" (case insensitive). +# 'required' requires the mod to exist, 'optional' does not +# 'incompatible' will prevent the game from loading when the mod exists, and 'discouraged' will show a warning +type = "required" #mandatory +# Optional field describing why the dependency is required or why it is incompatible +# reason="..." +# The version range of the dependency +versionRange = "${neo_version_range}" #mandatory +# An ordering relationship for the dependency. +# BEFORE - This mod is loaded BEFORE the dependency +# AFTER - This mod is loaded AFTER the dependency +ordering = "NONE" +# Side this dependency is applied on - BOTH, CLIENT, or SERVER +side = "BOTH" +# Here's another dependency +[[dependencies."${mod_id}"]] +modId = "minecraft" +type = "required" +# This version range declares a minimum of the current minecraft version up to but not including the next major version +versionRange = "${minecraft_version_range}" +ordering = "NONE" +side = "BOTH" + +# Features are specific properties of the game environment, that you may want to declare you require. This example declares +# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't +# stop your mod loading on the server for example. +#[features."${mod_id}"] +#openGLVersion="[3.2,)" diff --git a/module.rpc/src/main/resources/anvillib_rpc.mixins.json b/module.rpc/src/main/resources/anvillib_rpc.mixins.json new file mode 100644 index 00000000..a7fbf534 --- /dev/null +++ b/module.rpc/src/main/resources/anvillib_rpc.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "dev.anvilcraft.lib.v2.rpc.mixin", + "compatibilityLevel": "JAVA_8", + "refmap": "anvillib.refmap.json", + "mixins": [ + ], + "client": [ + ], + "injectors": { + "defaultRequire": 1 + } +} \ No newline at end of file diff --git a/module.rpc/src/main/resources/icon.png b/module.rpc/src/main/resources/icon.png new file mode 100644 index 00000000..15304bd5 Binary files /dev/null and b/module.rpc/src/main/resources/icon.png differ diff --git a/module.space-select/build.gradle b/module.space-select/build.gradle new file mode 100644 index 00000000..ee7f4f39 --- /dev/null +++ b/module.space-select/build.gradle @@ -0,0 +1,276 @@ +plugins { + id 'java-library' + id 'eclipse' + id 'idea' + id 'maven-publish' + alias libs.plugins.modDevGradle + alias libs.plugins.lombok + alias libs.plugins.machete + id 'org.jreleaser' version '1.23.0' + id "me.modmuss50.mod-publish-plugin" version "1.1.0" +} + +String buildType = 'snapshot' +String buildNumber +if (System.getenv("CI_BUILD") == 'false') buildNumber = null +else { + if (System.getenv("PR_BUILD") != 'false') buildType = 'pr' + buildNumber = System.getenv("GITHUB_RUN_NUMBER") +} +version = "${mod_version}" + (buildNumber != null ? "+${buildType}.${buildNumber}" : "") +group = mod_group_id + +base { + archivesName = "${project.mod_id}-neoforge-${libs.versions.minecraft.get()}" +} + +repositories { + maven { url = "https://server.cjsah.net:1002/maven/" } + mavenLocal() + mavenCentral() +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +neoForge { + // Specify the version of NeoForge to use. + version = libs.versions.neoForge.get() + + parchment { + mappingsVersion = project.parchment_mappings_version + minecraftVersion = project.parchment_minecraft_version + } + + // This line is optional. Access Transformers are automatically detected + // accessTransformers.add('src/main/resources/META-INF/accesstransformer.cfg') + + interfaceInjectionData { + // from 'src/main/resources/interface_injections.json' + // publish file('src/main/resources/interface_injections.json') + } + + // Default run configurations. + // These can be tweaked, removed, or duplicated as needed. + runs { + client { + client() + + // Comma-separated list of namespaces to load gametests from. Empty = all namespaces. + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + server { + server() + programArgument '--nogui' + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + // This run config launches GameTestServer and runs all registered gametests, then exits. + // By default, the server will crash when no gametests are provided. + // The gametest system is also enabled by default for other run configs under the /test command. + gameTestServer { + type = "gameTestServer" + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + data { + data() + + // example of overriding the workingDirectory set in configureEach above, uncomment if you want to use it + // gameDirectory = project.file('run-data') + + // Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources. + programArguments.addAll '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/').getAbsolutePath(), '--existing', file('src/main/resources/').getAbsolutePath() + } + + // applies to all the run configs above + configureEach { + // Recommended logging data for a userdev environment + // The markers can be added/remove as needed separated by commas. + // "SCAN": For mods scan. + // "REGISTRIES": For firing of registry events. + // "REGISTRYDUMP": For getting the contents of all registries. + systemProperty 'forge.logging.markers', 'REGISTRIES' + + // Recommended logging level for the console + // You can set various levels here. + // Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels + logLevel = org.slf4j.event.Level.DEBUG + } + } + + mods { + // define mod <-> source bindings + // these are used to tell the game which sources are for which mod + // mostly optional in a single mod project + // but multi mod projects should define one per mod + "${mod_id}" { + sourceSet(sourceSets.main) + } + } +} + +// Include resources generated by data generators. +sourceSets.main.resources { srcDir 'src/generated/resources' } + + +dependencies { + + if (System.getenv("NOT_DEV") == 'true') { + jarJar(api("dev.anvilcraft.lib:anvillib-network-neoforge-1.21.1:latest.release")) + } else { + jarJar(implementation project(":anvillib-network-neoforge-1.21.1")) + } +} + +// This block of code expands all declared replace properties in the specified resource targets. +// A missing property will result in an error. Properties are expanded using ${} Groovy notation. +// When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments. +// See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html +tasks.withType(ProcessResources).configureEach { + var replaceProperties = [minecraft_version : libs.versions.minecraft.get(), + minecraft_version_range: minecraft_version_range, + neo_version : libs.versions.neoForge.get(), + neo_version_range : neo_version_range, + loader_version_range : loader_version_range, + mod_id : mod_id, + mod_name : mod_name, + mod_license : mod_license, + mod_version : version, + mod_authors : mod_authors, + mod_description : mod_description] + inputs.properties replaceProperties + + filesMatching(['META-INF/neoforge.mods.toml']) { + expand replaceProperties + } +} + +tasks.register('sourcesJar', Jar) { + from delombok.outputs.files + dependsOn delombok + archiveClassifier = "sources" +} + +tasks.register('javadocJar', Jar) { + from javadoc.destinationDir + dependsOn javadoc + archiveClassifier = "javadoc" +} + +javadoc { + options.encoding = 'UTF-8' + options.addStringOption('Xdoclint:none', '-quiet') +} + +jar { + from(rootProject.file("LICENSE")) + from(rootProject.file("ASSETS_LICENSE")) + from(rootProject.file("README.md")) + from(rootProject.file("README.en.md")) +} + +artifacts { + archives tasks.jar + archives tasks.sourcesJar + archives tasks.javadocJar +} + +def this_name = base.archivesName.get() +def this_description = mod_description + +publishing { + publications { + register('mavenJava', MavenPublication) { + from components.java + + artifact sourcesJar + artifact javadocJar + + versionMapping { + allVariants { + fromResolutionResult() + } + } + + pom { + name = this_name + description = this_description + url = 'https://github.com/Anvil-Dev/AnvilLib' + + licenses { + license { + name = 'MIT License' + url = 'https://opensource.org/license/mit' + } + } + developers { + developer { + id = 'Anvil-Dev' + name = 'AnvilCraft Dev' + email = 'admin@anvilcraft.dev' + } + } + scm { + url = 'https://github.com/Anvil-Dev/AnvilLib' + connection = 'https://github.com/Anvil-Dev/AnvilLib.git' + developerConnection = 'https://github.com/Anvil-Dev/AnvilLib.git' + } + } + } + } + repositories { + // Add repositories to publish to here. + def MAVEN_URL = System.getenv("MAVEN_URL") + if (MAVEN_URL != null) { + maven { + url MAVEN_URL + credentials { + username System.getenv("MAVEN_USERNAME") + password System.getenv("MAVEN_PASSWORD") + } + } + } + mavenLocal() + maven { + name = "staging" + url = layout.buildDirectory.dir("staging-deploy") + } + } +} + +jreleaser { + signing { + active = 'ALWAYS' + armored = true + } + + deploy { + maven { + mavenCentral { + sonatype { + active = 'ALWAYS' + url = 'https://central.sonatype.com/api/v1/publisher' + stagingRepository('build/staging-deploy') + } + } + } + } +} + +// IDEA no longer automatically downloads sources/javadoc jars for dependencies, so we need to explicitly enable the behavior. +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} + +machete { + // disable machete locally for faster builds + enabled = false +} + +lombok { + version = "1.18.34" +} diff --git a/module.space-select/gradle.properties b/module.space-select/gradle.properties new file mode 100644 index 00000000..74e9f41b --- /dev/null +++ b/module.space-select/gradle.properties @@ -0,0 +1,4 @@ +## Mod Properties +mod_id=anvillib_space_select +mod_name=AnvilLib-Space-Select +mod_description=Space selection utilities diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/AnvilLibSpaceSelect.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/AnvilLibSpaceSelect.java new file mode 100644 index 00000000..abb491d5 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/AnvilLibSpaceSelect.java @@ -0,0 +1,18 @@ +package dev.anvilcraft.lib.v2.space_select; + +import net.minecraft.resources.ResourceLocation; +import net.neoforged.fml.common.Mod; +import org.jetbrains.annotations.ApiStatus; + +@Mod(AnvilLibSpaceSelect.MOD_ID) +public class AnvilLibSpaceSelect { + @ApiStatus.Internal + public AnvilLibSpaceSelect() { + } + + public static final String MOD_ID = "anvillib_space_select"; + + public static ResourceLocation of(String path) { + return ResourceLocation.fromNamespaceAndPath(AnvilLibSpaceSelect.MOD_ID, path); + } +} diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/District.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/District.java new file mode 100644 index 00000000..82866929 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/District.java @@ -0,0 +1,163 @@ +package dev.anvilcraft.lib.v2.space_select; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; + +import java.util.Random; + +public record District( + BlockPos.MutableBlockPos start, + BlockPos.MutableBlockPos end +) { + public static District create(BlockPos start, BlockPos end) { + BlockPos.MutableBlockPos _start = new BlockPos.MutableBlockPos( + Math.min(start.getX(), end.getX()), + Math.min(start.getY(), end.getY()), + Math.min(start.getZ(), end.getZ()) + ); + BlockPos.MutableBlockPos _end = new BlockPos.MutableBlockPos( + Math.max(start.getX(), end.getX()), + Math.max(start.getY(), end.getY()), + Math.max(start.getZ(), end.getZ()) + ); + return new District(_start, _end); + } + + public void expand(Direction direction, int size) { + int stepX = direction.getStepX() * size; + int stepY = direction.getStepY() * size; + int stepZ = direction.getStepZ() * size; + if (stepX < 0) { + this.start().setX(this.start().getX() + stepX); + } else { + this.end().setX(this.end().getX() + stepX); + } + if (stepY < 0) { + this.start().setY(this.start().getY() + stepY); + } else { + this.end().setY(this.end().getY() + stepY); + } + if (stepZ < 0) { + this.start().setZ(this.start().getZ() + stepZ); + } else { + this.end().setZ(this.end().getZ() + stepZ); + } + } + + public void contraction(Direction direction, int size) { + int stepX = direction.getStepX() * size; + int stepY = direction.getStepY() * size; + int stepZ = direction.getStepZ() * size; + if (stepX < 0) { + this.start().setX(this.start().getX() - stepX); + } else { + this.end().setX(this.end().getX() - stepX); + } + if (stepY < 0) { + this.start().setY(this.start().getY() - stepY); + } else { + this.end().setY(this.end().getY() - stepY); + } + if (stepZ < 0) { + this.start().setZ(this.start().getZ() - stepZ); + } else { + this.end().setZ(this.end().getZ() - stepZ); + } + } + + public void move(Direction direction, int step) { + int stepX = direction.getStepX() * step; + int stepY = direction.getStepY() * step; + int stepZ = direction.getStepZ() * step; + this.start().set( + this.start().getX() + stepX, + this.start().getY() + stepY, + this.start().getZ() + stepZ + ); + this.end().set( + this.end().getX() + stepX, + this.end().getY() + stepY, + this.end().getZ() + stepZ + ); + } + + public boolean contains(double x, double y, double z) { + return x >= this.start.getX() && x <= this.end.getX() + && y >= this.start.getY() && y <= this.end.getY() + && z >= this.start.getZ() && z <= this.end.getZ(); + } + + public static Direction.Axis getPrimaryAxis(Vec3 lookAngle) { + double absX = Math.abs(lookAngle.x); + double absY = Math.abs(lookAngle.y); + double absZ = Math.abs(lookAngle.z); + if (absX >= absY && absX >= absZ) return Direction.Axis.X; + if (absY >= absX && absY >= absZ) return Direction.Axis.Y; + return Direction.Axis.Z; + } + + public void scaleOnAxis(Direction.Axis axis, int scrollAmount, Vec3 playerPos, AABB boundingBox, Vec3 lookAngle) { + double playerCoord = axis.choose(playerPos.x, playerPos.y, playerPos.z); + double minPlayerCoord = axis.choose(boundingBox.minX, boundingBox.minY, boundingBox.minZ); + double maxPlayerCoord = axis.choose(boundingBox.maxX, boundingBox.maxY, boundingBox.maxZ); + double minCoord = axis.choose(this.start.getX(), this.start.getY(), this.start.getZ()); + double maxCoord = axis.choose(this.end.getX(), this.end.getY(), this.end.getZ()); + double lookComp = axis.choose(lookAngle.x, lookAngle.y, lookAngle.z); + + boolean inside = maxPlayerCoord >= minCoord && minPlayerCoord <= maxCoord + 1; + + int faceSign; + if (inside) { + faceSign = lookComp > 0 ? 1 : -1; + } else { + double distToMin = Math.abs(playerCoord - minCoord); + double distToMax = Math.abs(playerCoord - maxCoord + 1); + faceSign = distToMin < distToMax ? -1 : 1; + } + + int delta = (lookComp > 0 ? 1 : -1) * scrollAmount; + + if (faceSign > 0) { + int newEnd = (int) maxCoord + delta; + if (newEnd >= (int) minCoord) { + setAxisCoord(this.end, axis, newEnd); + } + } else { + int newStart = (int) minCoord + delta; + if (newStart <= (int) maxCoord) { + setAxisCoord(this.start, axis, newStart); + } + } + } + + private static void setAxisCoord(BlockPos.MutableBlockPos pos, Direction.Axis axis, int value) { + switch (axis) { + case X -> pos.setX(value); + case Y -> pos.setY(value); + case Z -> pos.setZ(value); + } + } + + public VoxelShape shape() { + return Shapes.create( + 0, + 0, + 0, + this.end().getX() - this.start().getX() + 1, + this.end().getY() - this.start().getY() + 1, + this.end().getZ() - this.start().getZ() + 1 + ); + } + + public int color() { + Random random = new Random(this.hashCode()); + int r = random.nextInt(256); + int g = random.nextInt(256); + int b = random.nextInt(256); + return (102 << 24) | (r << 16) | (g << 8) | b; + } +} diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/DistrictManager.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/DistrictManager.java new file mode 100644 index 00000000..0eb13a80 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/DistrictManager.java @@ -0,0 +1,33 @@ +package dev.anvilcraft.lib.v2.space_select; + +import lombok.Getter; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import javax.annotation.Nullable; + +import java.util.HashMap; +import java.util.Map; + +@Getter +public class DistrictManager { + private final Map districtMap = new HashMap<>(); + + public void select(DistrictKey districtKey, District district) { + this.districtMap.put(districtKey, district); + } + + public void clear(DistrictKey districtKey) { + this.districtMap.remove(districtKey); + } + + public record DistrictKey(int slot, boolean offhand, Item item) { + public boolean check(@Nullable Player player) { + if (player == null) return false; + ItemStack offStack = player.getOffhandItem(); + if (this.offhand()) return offStack.is(this.item()) && offStack.getItem() instanceof SpaceSelectItem; + ItemStack mainStack = player.getInventory().getItem(this.slot()); + return mainStack.is(this.item()) && mainStack.getItem() instanceof SpaceSelectItem; + } + } +} diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/SpaceSelectItem.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/SpaceSelectItem.java new file mode 100644 index 00000000..ba1ea516 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/SpaceSelectItem.java @@ -0,0 +1,40 @@ +package dev.anvilcraft.lib.v2.space_select; + +import dev.anvilcraft.lib.v2.space_select.client.AnvilLibSpaceSelectClient; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +public interface SpaceSelectItem { + default void select(Player player, BlockPos pos) { + DistrictManager.DistrictKey mainHand = new DistrictManager.DistrictKey( + player.getInventory().selected, + false, + player.getMainHandItem().getItem() + ); + DistrictManager.DistrictKey offHand = new DistrictManager.DistrictKey(-1, true, player.getOffhandItem().getItem()); + if (mainHand.check(player) && !AnvilLibSpaceSelectClient.MANAGER.isSelecting(mainHand)) { + AnvilLibSpaceSelectClient.MANAGER.startSelect(mainHand, pos); + } else if (mainHand.check(player)) { + AnvilLibSpaceSelectClient.MANAGER.endSelect(mainHand, pos); + } else if (offHand.check(player) && !AnvilLibSpaceSelectClient.MANAGER.isSelecting(offHand)) { + AnvilLibSpaceSelectClient.MANAGER.startSelect(offHand, pos); + } else if (offHand.check(player)) { + AnvilLibSpaceSelectClient.MANAGER.endSelect(offHand, pos); + } + } + + default void cancel(Player player) { + DistrictManager.DistrictKey mainHand = new DistrictManager.DistrictKey( + player.getInventory().selected, + false, + player.getMainHandItem().getItem() + ); + DistrictManager.DistrictKey offHand = new DistrictManager.DistrictKey(-1, true, player.getOffhandItem().getItem()); + AnvilLibSpaceSelectClient.MANAGER.clear(mainHand); + AnvilLibSpaceSelectClient.MANAGER.clear(offHand); + } + + default void onCreateDistrict(Player player, ItemStack itemStack, BlockPos start, BlockPos end) { + } +} diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/AnvilLibSpaceSelectClient.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/AnvilLibSpaceSelectClient.java new file mode 100644 index 00000000..c9bb4dec --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/AnvilLibSpaceSelectClient.java @@ -0,0 +1,15 @@ +package dev.anvilcraft.lib.v2.space_select.client; + +import dev.anvilcraft.lib.v2.space_select.AnvilLibSpaceSelect; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.fml.common.Mod; +import org.jetbrains.annotations.ApiStatus; + +@Mod(value = AnvilLibSpaceSelect.MOD_ID, dist = Dist.CLIENT) +public class AnvilLibSpaceSelectClient { + @ApiStatus.Internal + public AnvilLibSpaceSelectClient() { + } + + public static final ClientDistrictManager MANAGER = new ClientDistrictManager(); +} diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/ClientDistrictManager.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/ClientDistrictManager.java new file mode 100644 index 00000000..3c7f3eb6 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/ClientDistrictManager.java @@ -0,0 +1,87 @@ +package dev.anvilcraft.lib.v2.space_select.client; + +import dev.anvilcraft.lib.v2.space_select.District; +import dev.anvilcraft.lib.v2.space_select.DistrictManager; +import dev.anvilcraft.lib.v2.space_select.SpaceSelectItem; +import dev.anvilcraft.lib.v2.space_select.network.payload.SpaceSelectPayload; +import lombok.Getter; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.neoforged.neoforge.network.PacketDistributor; +import javax.annotation.Nullable; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@Getter +public class ClientDistrictManager extends DistrictManager { + private final District tempDistrict = new District(new BlockPos.MutableBlockPos(), new BlockPos.MutableBlockPos()); + private final Map selectingMap = new HashMap<>(); + + public boolean isSelecting(DistrictKey districtKey) { + return this.selectingMap.containsKey(districtKey); + } + + public void startSelect(DistrictKey districtKey, BlockPos blockPos) { + this.selectingMap.put(districtKey, blockPos); + } + + public void endSelect(DistrictKey districtKey, BlockPos blockPos) { + BlockPos start = this.selectingMap.remove(districtKey); + if (start != null) { + this.select(districtKey, District.create(start, blockPos)); + SpaceSelectPayload payload = new SpaceSelectPayload( + !(Objects.requireNonNull(Minecraft.getInstance().player).getMainHandItem().getItem() instanceof SpaceSelectItem), + start, + blockPos + ); + PacketDistributor.sendToServer(payload); + } + } + + @Override + public void clear(DistrictKey districtKey) { + super.clear(districtKey); + this.selectingMap.remove(districtKey); + } + + public @Nullable District getTempDistrict() { + Minecraft minecraft = Minecraft.getInstance(); + LocalPlayer player = minecraft.player; + if (player == null) return null; + HitResult hitResult = minecraft.hitResult; + if (hitResult == null || hitResult.getType() != HitResult.Type.BLOCK) return null; + BlockPos hitPos = ((BlockHitResult) hitResult).getBlockPos(); + ItemStack mainHandItem = player.getMainHandItem(); + ItemStack offhandItem = player.getOffhandItem(); + if ( + !(mainHandItem.getItem() instanceof SpaceSelectItem) + && !(offhandItem.getItem() instanceof SpaceSelectItem) + ) { + return null; + } + BlockPos pos; + DistrictManager.DistrictKey mainHand = new DistrictManager.DistrictKey( + player.getInventory().selected, + false, + player.getMainHandItem().getItem() + ); + DistrictManager.DistrictKey offHand = new DistrictManager.DistrictKey(-1, true, player.getOffhandItem().getItem()); + if (this.isSelecting(mainHand)) { + pos = this.getSelectingMap().get(mainHand); + } else { + pos = this.getSelectingMap().get(offHand); + } + if (pos == null) return null; + this.tempDistrict.start() + .set(Math.min(pos.getX(), hitPos.getX()), Math.min(pos.getY(), hitPos.getY()), Math.min(pos.getZ(), hitPos.getZ())); + this.tempDistrict.end() + .set(Math.max(pos.getX(), hitPos.getX()), Math.max(pos.getY(), hitPos.getY()), Math.max(pos.getZ(), hitPos.getZ())); + return this.tempDistrict; + } +} diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/DistrictRenderer.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/DistrictRenderer.java new file mode 100644 index 00000000..20213a47 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/DistrictRenderer.java @@ -0,0 +1,63 @@ +package dev.anvilcraft.lib.v2.space_select.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import dev.anvilcraft.lib.v2.space_select.AnvilLibSpaceSelect; +import dev.anvilcraft.lib.v2.space_select.District; +import net.minecraft.SharedConstants; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.core.BlockPos; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.event.RenderLevelStageEvent; +import org.jetbrains.annotations.ApiStatus; + +import java.util.ArrayList; +import java.util.List; + +@EventBusSubscriber(modid = AnvilLibSpaceSelect.MOD_ID, value = Dist.CLIENT) +@ApiStatus.Internal +public class DistrictRenderer { + @SubscribeEvent + public static void addLevelRenderMainPass(RenderLevelStageEvent event) { + if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_PARTICLES) return; + PoseStack poseStack = event.getPoseStack(); + MultiBufferSource.BufferSource bufferSource = Minecraft.getInstance().renderBuffers().bufferSource(); + renderDistrict(bufferSource, poseStack, event.getCamera().getPosition()); + } + + public static void renderDistrict( + MultiBufferSource.BufferSource bufferSource, + PoseStack poseStack, + Vec3 cameraPos + ) { + List districts = new ArrayList<>(AnvilLibSpaceSelectClient.MANAGER.getDistrictMap().values()); + District tempDistrict = AnvilLibSpaceSelectClient.MANAGER.getTempDistrict(); + if (tempDistrict != null) districts.add(tempDistrict); + VertexConsumer buffer = bufferSource.getBuffer(RenderType.lines()); + for (District district : districts) { + BlockPos pos = district.start(); + double dx = (double) pos.getX() - cameraPos.x; + double dy = (double) pos.getY() - cameraPos.y; + double dz = (double) pos.getZ() - cameraPos.z; + if (SharedConstants.DEBUG_SHAPES) { + AABB box = district.shape().bounds().move(dx, dy, dz); + LevelRenderer.renderLineBox(poseStack, buffer, box, 1.0F, 1.0F, 1.0F, 1.0F); + } else { + int color = district.color(); + float r = (float) (color >> 16 & 0xFF) / 255.0F; + float g = (float) (color >> 8 & 0xFF) / 255.0F; + float b = (float) (color & 0xFF) / 255.0F; + float a = (float) (color >> 24 & 0xFF) / 255.0F; + LevelRenderer.renderVoxelShape(poseStack, buffer, district.shape(), dx, dy, dz, r, g, b, a, true); + } + } + bufferSource.endLastBatch(); + } +} diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/SpaceSelectScrollHandler.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/SpaceSelectScrollHandler.java new file mode 100644 index 00000000..c16fcdb5 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/SpaceSelectScrollHandler.java @@ -0,0 +1,69 @@ +package dev.anvilcraft.lib.v2.space_select.client; + +import dev.anvilcraft.lib.v2.space_select.AnvilLibSpaceSelect; +import dev.anvilcraft.lib.v2.space_select.District; +import dev.anvilcraft.lib.v2.space_select.DistrictManager; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.core.Direction; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.event.InputEvent; +import org.jetbrains.annotations.ApiStatus; + +@EventBusSubscriber(modid = AnvilLibSpaceSelect.MOD_ID, value = Dist.CLIENT) +@ApiStatus.Internal +public final class SpaceSelectScrollHandler { + + private SpaceSelectScrollHandler() { + } + + @SubscribeEvent + public static void onMouseScroll(InputEvent.MouseScrollingEvent event) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.screen != null) return; + + boolean ctrlDown = Screen.hasControlDown(); + boolean altDown = Screen.hasAltDown(); + if (!ctrlDown && !altDown) return; + + DistrictManager.DistrictKey mainHand = new DistrictManager.DistrictKey( + mc.player.getInventory().selected, + false, + mc.player.getMainHandItem().getItem() + ); + DistrictManager.DistrictKey offHand = new DistrictManager.DistrictKey(-1, true, mc.player.getOffhandItem().getItem()); + DistrictManager.DistrictKey districtKey; + if (mainHand.check(mc.player)) { + districtKey = mainHand; + } else if (offHand.check(mc.player)) { + districtKey = offHand; + } else { + return; + } + + District district = AnvilLibSpaceSelectClient.MANAGER.getDistrictMap().get(districtKey); + if (district == null) return; + + double scrollY = event.getScrollDeltaY(); + if (scrollY == 0) return; + int scrollAmount = (int) Math.signum(scrollY); + + Vec3 lookAngle = mc.player.getViewVector(1.0F); + Vec3 playerPos = mc.player.position(); + AABB boundingBox = mc.player.getBoundingBox(); + + if (ctrlDown) { + Direction.Axis axis = District.getPrimaryAxis(lookAngle); + district.scaleOnAxis(axis, scrollAmount, playerPos, boundingBox, lookAngle); + event.setCanceled(true); + } else { + Direction dir = Direction.getNearest(lookAngle.x, lookAngle.y, lookAngle.z); + district.move(dir, scrollAmount); + event.setCanceled(true); + } + } +} diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/package-info.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/package-info.java new file mode 100644 index 00000000..bb728588 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/client/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.space_select.client; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/event/PlayerCreateDistrictEvent.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/event/PlayerCreateDistrictEvent.java new file mode 100644 index 00000000..075de525 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/event/PlayerCreateDistrictEvent.java @@ -0,0 +1,21 @@ +package dev.anvilcraft.lib.v2.space_select.event; + +import lombok.Getter; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; + +@Getter +public class PlayerCreateDistrictEvent extends PlayerEvent { + private final ItemStack itemStack; + private final BlockPos start; + private final BlockPos end; + + public PlayerCreateDistrictEvent(Player player, ItemStack itemStack, BlockPos start, BlockPos end) { + super(player); + this.itemStack = itemStack; + this.start = start; + this.end = end; + } +} diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/event/handler/PlayerCreateDistrictEventHandler.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/event/handler/PlayerCreateDistrictEventHandler.java new file mode 100644 index 00000000..d888d059 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/event/handler/PlayerCreateDistrictEventHandler.java @@ -0,0 +1,20 @@ +package dev.anvilcraft.lib.v2.space_select.event.handler; + +import dev.anvilcraft.lib.v2.space_select.AnvilLibSpaceSelect; +import dev.anvilcraft.lib.v2.space_select.SpaceSelectItem; +import dev.anvilcraft.lib.v2.space_select.event.PlayerCreateDistrictEvent; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import org.jetbrains.annotations.ApiStatus; + +@EventBusSubscriber(modid = AnvilLibSpaceSelect.MOD_ID) +@ApiStatus.Internal +public class PlayerCreateDistrictEventHandler { + @SubscribeEvent + public static void onPlayerCreateDistrict(PlayerCreateDistrictEvent event) { + if (!(event.getItemStack().getItem() instanceof SpaceSelectItem item)) { + return; + } + item.onCreateDistrict(event.getEntity(), event.getItemStack(), event.getStart(), event.getEnd()); + } +} diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/event/handler/package-info.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/event/handler/package-info.java new file mode 100644 index 00000000..b702fac6 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/event/handler/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.space_select.event.handler; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/event/package-info.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/event/package-info.java new file mode 100644 index 00000000..435b5a93 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/event/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.space_select.event; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/network/AnvilLibSpaceSelectNetwork.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/network/AnvilLibSpaceSelectNetwork.java new file mode 100644 index 00000000..c813bb0d --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/network/AnvilLibSpaceSelectNetwork.java @@ -0,0 +1,21 @@ +package dev.anvilcraft.lib.v2.space_select.network; + +import dev.anvilcraft.lib.v2.network.register.NetworkRegistrar; +import dev.anvilcraft.lib.v2.space_select.AnvilLibSpaceSelect; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; +import net.neoforged.neoforge.network.registration.PayloadRegistrar; +import org.jetbrains.annotations.ApiStatus; + +@EventBusSubscriber(modid = AnvilLibSpaceSelect.MOD_ID) +@ApiStatus.Internal +public class AnvilLibSpaceSelectNetwork { + public static final String NETWORK_VERSION = "1"; + + @SubscribeEvent + public static void onPayloadRegister(RegisterPayloadHandlersEvent event) { + PayloadRegistrar registrar = event.registrar(NETWORK_VERSION); + NetworkRegistrar.register(registrar, AnvilLibSpaceSelect.MOD_ID); + } +} diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/network/package-info.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/network/package-info.java new file mode 100644 index 00000000..9d3893e1 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/network/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.space_select.network; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/network/payload/SpaceSelectPayload.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/network/payload/SpaceSelectPayload.java new file mode 100644 index 00000000..5a82f000 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/network/payload/SpaceSelectPayload.java @@ -0,0 +1,47 @@ +package dev.anvilcraft.lib.v2.space_select.network.payload; + +import dev.anvilcraft.lib.v2.network.packet.IServerboundPacket; +import dev.anvilcraft.lib.v2.space_select.AnvilLibSpaceSelect; +import dev.anvilcraft.lib.v2.space_select.event.PlayerCreateDistrictEvent; +import io.netty.buffer.ByteBuf; +import net.minecraft.core.BlockPos; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.neoforged.neoforge.common.NeoForge; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public record SpaceSelectPayload( + boolean offhand, + BlockPos start, + BlockPos end +) implements IServerboundPacket { + public static final Type TYPE = new Type<>(AnvilLibSpaceSelect.of("space_select")); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.BOOL, + SpaceSelectPayload::offhand, + BlockPos.STREAM_CODEC, + SpaceSelectPayload::start, + BlockPos.STREAM_CODEC, + SpaceSelectPayload::end, + SpaceSelectPayload::new + ); + + @Override + public void handleOnServer(Player player) { + if (!(player instanceof ServerPlayer serverPlayer)) return; + NeoForge.EVENT_BUS.post(new PlayerCreateDistrictEvent( + serverPlayer, + this.offhand() ? serverPlayer.getOffhandItem() : serverPlayer.getMainHandItem(), + this.start(), + this.end() + )); + } + + @Override + public Type type() { + return SpaceSelectPayload.TYPE; + } +} diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/network/payload/package-info.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/network/payload/package-info.java new file mode 100644 index 00000000..4e728284 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/network/payload/package-info.java @@ -0,0 +1,11 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +@Network +package dev.anvilcraft.lib.v2.space_select.network.payload; + +import dev.anvilcraft.lib.v2.network.register.Network; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/package-info.java b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/package-info.java new file mode 100644 index 00000000..5f622c79 --- /dev/null +++ b/module.space-select/src/main/java/dev/anvilcraft/lib/v2/space_select/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.space_select; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.space-select/src/main/resources/META-INF/neoforge.mods.toml b/module.space-select/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 00000000..27f7ba75 --- /dev/null +++ b/module.space-select/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,86 @@ +# This is an example mods.toml file. It contains the data relating to the loading mods. +# There are several mandatory fields (#mandatory), and many more that are optional (#optional). +# The overall format is standard TOML format, v0.5.0. +# Note that there are a couple of TOML lists in this file. +# Find more information on toml format here: https://github.com/toml-lang/toml +# The name of the mod loader type to load - for regular FML @Mod mods it should be javafml +modLoader = "javafml" #mandatory +# A version range to match for said mod loader - for regular FML @Mod it will be the the FML version. This is currently 47. +loaderVersion = "${loader_version_range}" #mandatory +# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties. +# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here. +license = "${mod_license}" +# A URL to refer people to when problems occur with this mod +#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional +# A list of mods - how many allowed here is determined by the individual mod loader +[[mods]] #mandatory +# The modid of the mod +modId = "${mod_id}" #mandatory +# The version number of the mod +version = "${mod_version}" #mandatory +# A display name for the mod +displayName = "${mod_name}" #mandatory +# A URL to query for updates for this mod. See the JSON update specification https://docs.neoforge.net/docs/misc/updatechecker/ +#updateJSONURL="https://change.me.example.invalid/updates.json" #optional +# A URL for the "homepage" for this mod, displayed in the mod UI +displayURL="https://github.com/Anvil-Dev/AnvilLib" +# A file name (in the root of the mod JAR) containing a logo for display +logoFile="icon.png" #optional +# A text field displayed in the mod UI +#credits="" #optional +# A text field displayed in the mod UI +authors = "${mod_authors}" #optional +# Display Test controls the display for your mod in the server connection screen +# MATCH_VERSION means that your mod will cause a red X if the versions on client and server differ. This is the default behaviour and should be what you choose if you have server and client elements to your mod. +# IGNORE_SERVER_VERSION means that your mod will not cause a red X if it's present on the server but not on the client. This is what you should use if you're a server only mod. +# IGNORE_ALL_VERSION means that your mod will not cause a red X if it's present on the client or the server. This is a special case and should only be used if your mod has no server component. +# NONE means that no display test is set on your mod. You need to do this yourself, see IExtensionPoint.DisplayTest for more information. You can define any scheme you wish with this value. +# IMPORTANT NOTE: this is NOT an instruction as to which environments (CLIENT or DEDICATED SERVER) your mod loads on. Your mod should load (and maybe do nothing!) whereever it finds itself. +#displayTest="MATCH_VERSION" # MATCH_VERSION is the default if nothing is specified (#optional) + +# The description text for the mod (multi line!) (#mandatory) +description = '''${mod_description}''' + +# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded. +[[mixins]] +config = "${mod_id}.mixins.json" + +# The [[accessTransformers]] block allows you to declare where your AT file is. +# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg +#[[accessTransformers]] +#file="META-INF/accesstransformer.cfg" + +# The coremods config file path is not configurable and is always loaded from META-INF/coremods.json + +# A dependency - use the . to indicate dependency for a specific modid. Dependencies are optional. +[[dependencies."${mod_id}"]] #optional +# the modid of the dependency +modId = "neoforge" #mandatory +# The type of the dependency. Can be one of "required", "optional", "incompatible" or "discouraged" (case insensitive). +# 'required' requires the mod to exist, 'optional' does not +# 'incompatible' will prevent the game from loading when the mod exists, and 'discouraged' will show a warning +type = "required" #mandatory +# Optional field describing why the dependency is required or why it is incompatible +# reason="..." +# The version range of the dependency +versionRange = "${neo_version_range}" #mandatory +# An ordering relationship for the dependency. +# BEFORE - This mod is loaded BEFORE the dependency +# AFTER - This mod is loaded AFTER the dependency +ordering = "NONE" +# Side this dependency is applied on - BOTH, CLIENT, or SERVER +side = "BOTH" +# Here's another dependency +[[dependencies."${mod_id}"]] +modId = "minecraft" +type = "required" +# This version range declares a minimum of the current minecraft version up to but not including the next major version +versionRange = "${minecraft_version_range}" +ordering = "NONE" +side = "BOTH" + +# Features are specific properties of the game environment, that you may want to declare you require. This example declares +# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't +# stop your mod loading on the server for example. +#[features."${mod_id}"] +#openGLVersion="[3.2,)" diff --git a/module.space-select/src/main/resources/anvillib_space_select.mixins.json b/module.space-select/src/main/resources/anvillib_space_select.mixins.json new file mode 100644 index 00000000..bac42af7 --- /dev/null +++ b/module.space-select/src/main/resources/anvillib_space_select.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "dev.anvilcraft.lib.v2.space_select.mixin", + "compatibilityLevel": "JAVA_8", + "refmap": "anvillib.refmap.json", + "mixins": [ + ], + "client": [ + ], + "injectors": { + "defaultRequire": 1 + } +} \ No newline at end of file diff --git a/module.space-select/src/main/resources/icon.png b/module.space-select/src/main/resources/icon.png new file mode 100644 index 00000000..15304bd5 Binary files /dev/null and b/module.space-select/src/main/resources/icon.png differ diff --git a/module.sync/build.gradle b/module.sync/build.gradle new file mode 100644 index 00000000..c85dca6c --- /dev/null +++ b/module.sync/build.gradle @@ -0,0 +1,282 @@ +plugins { + id 'java-library' + id 'eclipse' + id 'idea' + id 'maven-publish' + alias libs.plugins.modDevGradle + alias libs.plugins.lombok + alias libs.plugins.machete + id 'org.jreleaser' version '1.23.0' + id "me.modmuss50.mod-publish-plugin" version "1.1.0" +} + +String buildType = 'snapshot' +String buildNumber +if (System.getenv("CI_BUILD") == 'false') buildNumber = null +else { + if (System.getenv("PR_BUILD") != 'false') buildType = 'pr' + buildNumber = System.getenv("GITHUB_RUN_NUMBER") +} +version = "${mod_version}" + (buildNumber != null ? "+${buildType}.${buildNumber}" : "") +group = mod_group_id + +base { + archivesName = "${project.mod_id}-neoforge-${libs.versions.minecraft.get()}" +} + +repositories { + maven { url = "https://server.cjsah.net:1002/maven/" } + mavenLocal() + mavenCentral() +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) + +neoForge { + // Specify the version of NeoForge to use. + version = libs.versions.neoForge.get() + + parchment { + mappingsVersion = project.parchment_mappings_version + minecraftVersion = project.parchment_minecraft_version + } + + // This line is optional. Access Transformers are automatically detected + // accessTransformers.add('src/main/resources/META-INF/accesstransformer.cfg') + + interfaceInjectionData { + // from 'src/main/resources/interface_injections.json' + // publish file('src/main/resources/interface_injections.json') + } + + // Default run configurations. + // These can be tweaked, removed, or duplicated as needed. + runs { + client { + client() + + // Comma-separated list of namespaces to load gametests from. Empty = all namespaces. + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + server { + server() + programArgument '--nogui' + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + // This run config launches GameTestServer and runs all registered gametests, then exits. + // By default, the server will crash when no gametests are provided. + // The gametest system is also enabled by default for other run configs under the /test command. + gameTestServer { + type = "gameTestServer" + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + data { + data() + + // example of overriding the workingDirectory set in configureEach above, uncomment if you want to use it + // gameDirectory = project.file('run-data') + + // Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources. + programArguments.addAll '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/').getAbsolutePath(), '--existing', file('src/main/resources/').getAbsolutePath() + } + + // applies to all the run configs above + configureEach { + // Recommended logging data for a userdev environment + // The markers can be added/remove as needed separated by commas. + // "SCAN": For mods scan. + // "REGISTRIES": For firing of registry events. + // "REGISTRYDUMP": For getting the contents of all registries. + systemProperty 'forge.logging.markers', 'REGISTRIES' + + // Recommended logging level for the console + // You can set various levels here. + // Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels + logLevel = org.slf4j.event.Level.DEBUG + } + } + + mods { + // define mod <-> source bindings + // these are used to tell the game which sources are for which mod + // mostly optional in a single mod project + // but multi mod projects should define one per mod + "${mod_id}" { + sourceSet(sourceSets.main) + } + } +} + +// Include resources generated by data generators. +sourceSets.main.resources { srcDir 'src/generated/resources' } + + +dependencies { + + if (System.getenv("NOT_DEV") == 'true') { + jarJar(api("dev.anvilcraft.lib:anvillib-sync-processor-neoforge-1.21.1:latest.release")) + jarJar(api("dev.anvilcraft.lib:anvillib-codec-neoforge-1.21.1:latest.release")) + jarJar(api("dev.anvilcraft.lib:anvillib-network-neoforge-1.21.1:latest.release")) + jarJar(api("dev.anvilcraft.lib:anvillib-util-neoforge-1.21.1:latest.release")) + } else { + jarJar(implementation project(":anvillib-sync-neoforge-1.21.1:anvillib-sync-processor-neoforge-1.21.1")) + jarJar(implementation project(":anvillib-codec-neoforge-1.21.1")) + jarJar(implementation project(":anvillib-network-neoforge-1.21.1")) + jarJar(implementation project(":anvillib-util-neoforge-1.21.1")) + } +} + +// This block of code expands all declared replace properties in the specified resource targets. +// A missing property will result in an error. Properties are expanded using ${} Groovy notation. +// When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments. +// See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html +tasks.withType(ProcessResources).configureEach { + var replaceProperties = [minecraft_version : libs.versions.minecraft.get(), + minecraft_version_range: minecraft_version_range, + neo_version : libs.versions.neoForge.get(), + neo_version_range : neo_version_range, + loader_version_range : loader_version_range, + mod_id : mod_id, + mod_name : mod_name, + mod_license : mod_license, + mod_version : version, + mod_authors : mod_authors, + mod_description : mod_description] + inputs.properties replaceProperties + + filesMatching(['META-INF/neoforge.mods.toml']) { + expand replaceProperties + } +} + +tasks.register('sourcesJar', Jar) { + from delombok.outputs.files + dependsOn delombok + archiveClassifier = "sources" +} + +tasks.register('javadocJar', Jar) { + from javadoc.destinationDir + dependsOn javadoc + archiveClassifier = "javadoc" +} + +javadoc { + options.encoding = 'UTF-8' + options.addStringOption('Xdoclint:none', '-quiet') +} + +jar { + from(rootProject.file("LICENSE")) + from(rootProject.file("ASSETS_LICENSE")) + from(rootProject.file("README.md")) + from(rootProject.file("README.en.md")) +} + +artifacts { + archives tasks.jar + archives tasks.sourcesJar + archives tasks.javadocJar +} + +def this_name = base.archivesName.get() +def this_description = mod_description + +publishing { + publications { + register('mavenJava', MavenPublication) { + from components.java + + artifact sourcesJar + artifact javadocJar + + versionMapping { + allVariants { + fromResolutionResult() + } + } + + pom { + name = this_name + description = this_description + url = 'https://github.com/Anvil-Dev/AnvilLib' + + licenses { + license { + name = 'MIT License' + url = 'https://opensource.org/license/mit' + } + } + developers { + developer { + id = 'Anvil-Dev' + name = 'AnvilCraft Dev' + email = 'admin@anvilcraft.dev' + } + } + scm { + url = 'https://github.com/Anvil-Dev/AnvilLib' + connection = 'https://github.com/Anvil-Dev/AnvilLib.git' + developerConnection = 'https://github.com/Anvil-Dev/AnvilLib.git' + } + } + } + } + repositories { + // Add repositories to publish to here. + def MAVEN_URL = System.getenv("MAVEN_URL") + if (MAVEN_URL != null) { + maven { + url MAVEN_URL + credentials { + username System.getenv("MAVEN_USERNAME") + password System.getenv("MAVEN_PASSWORD") + } + } + } + mavenLocal() + maven { + name = "staging" + url = layout.buildDirectory.dir("staging-deploy") + } + } +} + +jreleaser { + signing { + active = 'ALWAYS' + armored = true + } + + deploy { + maven { + mavenCentral { + sonatype { + active = 'ALWAYS' + url = 'https://central.sonatype.com/api/v1/publisher' + stagingRepository('build/staging-deploy') + } + } + } + } +} + +// IDEA no longer automatically downloads sources/javadoc jars for dependencies, so we need to explicitly enable the behavior. +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} + +machete { + // disable machete locally for faster builds + enabled = false +} + +lombok { + version = "1.18.34" +} diff --git a/module.sync/gradle.properties b/module.sync/gradle.properties new file mode 100644 index 00000000..d3c2b041 --- /dev/null +++ b/module.sync/gradle.properties @@ -0,0 +1,4 @@ +## Mod Properties +mod_id=anvillib_sync +mod_name=AnvilLib-Sync +mod_description=A library for syncing data between the server and client in AnvilLib. diff --git a/module.sync/processor/build.gradle b/module.sync/processor/build.gradle new file mode 100644 index 00000000..6f2f8be6 --- /dev/null +++ b/module.sync/processor/build.gradle @@ -0,0 +1,155 @@ +plugins { + id 'java' + id 'maven-publish' + alias libs.plugins.lombok + alias libs.plugins.modDevGradle + id 'org.jreleaser' version '1.23.0' +} + +String buildType = 'snapshot' +String buildNumber +if (System.getenv("CI_BUILD") == 'false') buildNumber = null else { + if (System.getenv("PR_BUILD") != 'false') buildType = 'pr' + buildNumber = System.getenv("GITHUB_RUN_NUMBER") +} +version = "${mod_version}" + (buildNumber != null ? "+${buildType}.${buildNumber}" : "") +group = mod_group_id + +base { + archivesName = "${project.mod_id}-neoforge-${libs.versions.minecraft.get()}" +} + +repositories { + mavenCentral() +} + +neoForge { + version = libs.versions.neoForge.get() +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +tasks.register('sourcesJar', Jar) { + from delombok.outputs.files + dependsOn delombok + archiveClassifier = "sources" +} + +tasks.register('javadocJar', Jar) { + from javadoc.destinationDir + dependsOn javadoc + archiveClassifier = "javadoc" +} + +javadoc { + // options.encoding = 'UTF-8' + options { + encoding = 'UTF-8' + addStringOption 'Xdoclint:none', '-quiet' + addStringOption 'tag', 'apiNote:a:API Note:' + addStringOption 'tag', 'implSpec:a:Implementation Requirement:' + addStringOption 'tag', 'implNote:a:Implementation Note:' + } +} + +jar { + manifest { + attributes( + 'Can-Redefine-Classes': 'false', + 'Can-Retransform-Classes': 'false', + 'Can-Set-Native-Method-Prefix': 'false' + ) + } +} + +artifacts { + archives tasks.jar + archives tasks.sourcesJar + archives tasks.javadocJar +} + +def this_name = base.archivesName.get() +def this_description = mod_description + +publishing { + publications { + register('mavenJava', MavenPublication) { + from components.java + + artifact sourcesJar + artifact javadocJar + + pom { + name = this_name + description = this_description + url = 'https://github.com/Anvil-Dev/AnvilLib' + + licenses { + license { + name = 'MIT License' + url = 'https://opensource.org/license/mit' + } + } + developers { + developer { + id = 'Anvil-Dev' + name = 'AnvilCraft Dev' + email = 'admin@anvilcraft.dev' + } + } + scm { + url = 'https://github.com/Anvil-Dev/AnvilLib' + connection = 'https://github.com/Anvil-Dev/AnvilLib.git' + developerConnection = 'https://github.com/Anvil-Dev/AnvilLib.git' + } + } + } + } + repositories { + // Add repositories to publish to here. + def MAVEN_URL = System.getenv("MAVEN_URL") + if (MAVEN_URL != null) { + maven { + url MAVEN_URL + credentials { + username System.getenv("MAVEN_USERNAME") + password System.getenv("MAVEN_PASSWORD") + } + } + } + mavenLocal() + maven { + name = "staging" + url = layout.buildDirectory.dir("staging-deploy") + } + } +} + +jreleaser { + signing { + active = 'ALWAYS' + armored = true + } + + deploy { + maven { + mavenCentral { + sonatype { + active = 'ALWAYS' + url = 'https://central.sonatype.com/api/v1/publisher' + stagingRepository('build/staging-deploy') + } + } + } + } +} + +lombok { + version = "1.18.34" +} diff --git a/module.sync/processor/gradle.properties b/module.sync/processor/gradle.properties new file mode 100644 index 00000000..4b8c4db1 --- /dev/null +++ b/module.sync/processor/gradle.properties @@ -0,0 +1,4 @@ +## Mod Properties +mod_id=anvillib_sync_processor +mod_name=AnvilLib-Sync-Processor +mod_description=AnvilLib Sync Processor NeoForge 1.21.1 diff --git a/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/LazySyncBytecodeInjector.java b/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/LazySyncBytecodeInjector.java new file mode 100644 index 00000000..c0f5fb7f --- /dev/null +++ b/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/LazySyncBytecodeInjector.java @@ -0,0 +1,141 @@ +package dev.anvilcraft.lib.v2.sync.transform; + +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.ApiStatus; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.AnnotationNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.FieldNode; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.VarInsnNode; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +/** + * 为含 {@link dev.anvilcraft.lib.v2.sync.annotation.LazySync} 字段的类注入跟踪登记调用: + *
      + *
    • 含实例 {@code @LazySync} 字段 → 在每个 {@code } 末尾注入 + * {@code AnvilLibSync.LAZY_SYNC_MANAGER.track(this)};
    • + *
    • 含静态 {@code @LazySync} 字段 → 在 {@code }(不存在则创建)末尾注入 + * {@code AnvilLibSync.LAZY_SYNC_MANAGER.trackStatic(Owner.class)}。
    • + *
    + * + *

    登记后由 {@link dev.anvilcraft.lib.v2.sync.management.LazySyncManager} + * 每 tick 扫描差分,故此处无需注入任何字段写入拦截。

    + */ +@Slf4j +@ApiStatus.Internal +public class LazySyncBytecodeInjector { + private static final String LAZY_SYNC_DESC = "Ldev/anvilcraft/lib/v2/sync/annotation/LazySync;"; + private static final String ANVIL_LIB_SYNC_INTERNAL = "dev/anvilcraft/lib/v2/sync/AnvilLibSync"; + private static final String MANAGER_FIELD_NAME = "LAZY_SYNC_MANAGER"; + private static final String MANAGER_DESC = "Ldev/anvilcraft/lib/v2/sync/management/LazySyncManager;"; + private static final String MANAGER_INTERNAL = "dev/anvilcraft/lib/v2/sync/management/LazySyncManager"; + private static final String TRACK_NAME = "track"; + private static final String TRACK_DESC = "(Ljava/lang/Object;)V"; + private static final String TRACK_STATIC_NAME = "trackStatic"; + private static final String TRACK_STATIC_DESC = "(Ljava/lang/Class;)V"; + + private LazySyncBytecodeInjector() { + } + + /** + * 扫描类中带 {@code @LazySync} 的字段并注入对应的跟踪登记调用。 + * + * @return 是否对类进行了修改 + */ + public static boolean inject(ClassNode classNode) { + boolean hasInstance = false; + boolean hasStatic = false; + for (FieldNode field : classNode.fields) { + if (!hasLazySync(field)) continue; + if ((field.access & Opcodes.ACC_STATIC) != 0) { + hasStatic = true; + } else { + hasInstance = true; + } + } + + if (!hasInstance && !hasStatic) { + return false; + } + + if (hasInstance) { + for (MethodNode method : classNode.methods) { + if ("".equals(method.name)) { + injectBeforeReturn(method, LazySyncBytecodeInjector::buildInstanceInstructions); + } + } + } + if (hasStatic) { + MethodNode clinit = findOrCreateClinit(classNode); + injectBeforeReturn(clinit, () -> buildStaticInstructions(classNode.name)); + } + + log.debug("Injected LazySync tracking into {} (instance={}, static={})", classNode.name, hasInstance, hasStatic); + return true; + } + + private static boolean hasLazySync(FieldNode field) { + if (field.visibleAnnotations == null) return false; + for (AnnotationNode annotation : field.visibleAnnotations) { + if (LAZY_SYNC_DESC.equals(annotation.desc)) return true; + } + return false; + } + + private static MethodNode findOrCreateClinit(ClassNode classNode) { + for (MethodNode method : classNode.methods) { + if ("".equals(method.name)) { + return method; + } + } + MethodNode clinit = new MethodNode(Opcodes.ACC_STATIC, "", "()V", null, null); + clinit.instructions.add(new InsnNode(Opcodes.RETURN)); + classNode.methods.add(clinit); + return clinit; + } + + private static void injectBeforeReturn(MethodNode method, Supplier snippet) { + List returns = new ArrayList<>(); + for (AbstractInsnNode insn : method.instructions) { + if (insn.getOpcode() == Opcodes.RETURN) { + returns.add(insn); + } + } + for (AbstractInsnNode ret : returns) { + method.instructions.insertBefore(ret, snippet.get()); + } + } + + /** + * {@code AnvilLibSync.LAZY_SYNC_MANAGER.track(this);} + */ + private static InsnList buildInstanceInstructions() { + InsnList list = new InsnList(); + list.add(new FieldInsnNode(Opcodes.GETSTATIC, ANVIL_LIB_SYNC_INTERNAL, MANAGER_FIELD_NAME, MANAGER_DESC)); + list.add(new VarInsnNode(Opcodes.ALOAD, 0)); + list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, MANAGER_INTERNAL, TRACK_NAME, TRACK_DESC, false)); + return list; + } + + /** + * {@code AnvilLibSync.LAZY_SYNC_MANAGER.trackStatic(Owner.class);} + */ + private static InsnList buildStaticInstructions(String owner) { + InsnList list = new InsnList(); + list.add(new FieldInsnNode(Opcodes.GETSTATIC, ANVIL_LIB_SYNC_INTERNAL, MANAGER_FIELD_NAME, MANAGER_DESC)); + list.add(new LdcInsnNode(Type.getObjectType(owner))); + list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, MANAGER_INTERNAL, TRACK_STATIC_NAME, TRACK_STATIC_DESC, false)); + return list; + } +} diff --git a/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/LazySyncTargetIndex.java b/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/LazySyncTargetIndex.java new file mode 100644 index 00000000..3bb63378 --- /dev/null +++ b/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/LazySyncTargetIndex.java @@ -0,0 +1,59 @@ +package dev.anvilcraft.lib.v2.sync.transform; + +import lombok.extern.slf4j.Slf4j; +import net.neoforged.fml.loading.LoadingModList; +import net.neoforged.fml.loading.moddiscovery.ModFileInfo; +import net.neoforged.neoforgespi.language.ModFileScanData; +import org.jetbrains.annotations.ApiStatus; + +import java.lang.annotation.ElementType; +import java.util.HashSet; +import java.util.Set; + +/** + * 在类加载早期扫描所有使用 {@link dev.anvilcraft.lib.v2.sync.annotation.LazySync} + * 注解的字段,记录其所属类的内部名。 + * + *

    {@link SyncClassProcessor} 依此判断哪些类需要注入实例/静态跟踪调用。 + * 字段级的方向、编解码器等信息在运行时由 + * {@link dev.anvilcraft.lib.v2.sync.management.LazySyncManager} + * 通过反射读取,故此处仅需记录类的集合。

    + */ +@Slf4j +@ApiStatus.Internal +public class LazySyncTargetIndex { + public static final String LAZY_SYNC_DESCRIPTOR = "Ldev/anvilcraft/lib/v2/sync/annotation/LazySync;"; + private static final Set TARGETS = new HashSet<>(); + + static { + LazySyncTargetIndex.onLoad(); + } + + private LazySyncTargetIndex() { + } + + @SuppressWarnings("UnstableApiUsage") + private static void onLoad() { + int count = 0; + for (ModFileInfo fileInfo : LoadingModList.get().getModFiles()) { + for (ModFileScanData.AnnotationData annotation : fileInfo.getFile().getScanResult().getAnnotations()) { + if (!annotation.annotationType().getDescriptor().equals(LAZY_SYNC_DESCRIPTOR)) continue; + if (annotation.targetType() != ElementType.FIELD) continue; + String internalName = annotation.clazz().getInternalName(); + if (TARGETS.add(internalName)) { + log.info("Registered @LazySync target: {}", internalName); + } + count++; + } + } + log.info("Scan complete – {} @LazySync field(s) across {} class(es).", count, TARGETS.size()); + } + + public static boolean contains(String internalName) { + return TARGETS.contains(internalName); + } + + public static Set allTargets() { + return TARGETS; + } +} diff --git a/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/SyncBytecodeInjector.java b/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/SyncBytecodeInjector.java new file mode 100644 index 00000000..dd223e1b --- /dev/null +++ b/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/SyncBytecodeInjector.java @@ -0,0 +1,241 @@ +package dev.anvilcraft.lib.v2.sync.transform; + +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.ApiStatus; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.FieldNode; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.JumpInsnNode; +import org.objectweb.asm.tree.LabelNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.VarInsnNode; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +/** + * Injects SyncProxy#setParent(...) calls at the end of and methods + * for all final/static-final SyncProxy fields found in the target class. + *

    + * Instance fields → proxy.setParent(this) in every + * Static fields → proxy.setParent(Owner.class) in (created if absent) + */ +@Slf4j +@ApiStatus.Internal +public class SyncBytecodeInjector { + private static final String SYNC_PROXY_DESC = "Ldev/anvilcraft/lib/v2/sync/management/SyncProxy;"; + private static final String SYNC_PROXY_INTERNAL = "dev/anvilcraft/lib/v2/sync/management/SyncProxy"; + private static final String SET_PARENT_NAME = "setParent"; + private static final String SET_PARENT_DESC = "(Ljava/lang/Object;)V"; + private static final String SET_FIELD_NAME_NAME = "setFieldName"; + private static final String SET_FIELD_NAME_DESC = "(Ljava/lang/String;)V"; + private static final String SET_DIRECTION_NAME = "setDirection"; + private static final String SET_DIRECTION_DESC = "(Ldev/anvilcraft/lib/v2/sync/util/SyncDirection;)V"; + private static final String SYNC_DIRECTION_INTERNAL = "dev/anvilcraft/lib/v2/sync/util/SyncDirection"; + private static final String SYNC_DIRECTION_DESC = "Ldev/anvilcraft/lib/v2/sync/util/SyncDirection;"; + + private SyncBytecodeInjector() { + } + + /** + * Entry point. Scans the class for qualifying SyncProxy fields and injects + * setParent calls in the appropriate constructor/static initializer. + */ + public static boolean inject(ClassNode classNode) { + String owner = classNode.name; + + List instanceFields = new ArrayList<>(); + List staticFields = new ArrayList<>(); + + for (FieldNode field : classNode.fields) { + if (!field.desc.equals(SYNC_PROXY_DESC)) continue; + if ((field.access & Opcodes.ACC_FINAL) == 0) { + log.warn("SyncProxy field {}.{} is not final, skipping", owner, field.name); + continue; + } + + if ((field.access & Opcodes.ACC_STATIC) != 0) { + staticFields.add(field); + } else { + instanceFields.add(field); + } + } + + if (instanceFields.isEmpty() && staticFields.isEmpty()) { + log.info("No qualifying SyncProxy fields found in {}, skipping injection", owner); + return false; + } + + if (!instanceFields.isEmpty()) { + injectIntoInit(classNode, owner, instanceFields); + } + if (!staticFields.isEmpty()) { + injectIntoClinit(classNode, owner, staticFields); + } + + log.debug( + "Injected setParent into {} ({} instance, {} static field(s))", + owner, + instanceFields.size(), + staticFields.size() + ); + return true; + } + + // ----------------------------------------------------------------------- + // Instance constructor injection + // ----------------------------------------------------------------------- + + private static void injectIntoInit(ClassNode classNode, String owner, List fields) { + String direction = SyncTargetIndex.getDirection(classNode.name); + for (MethodNode method : classNode.methods) { + if ("".equals(method.name)) { + injectBeforeReturn(method, () -> buildInstanceInstructions(owner, fields, direction)); + } + } + } + + // ----------------------------------------------------------------------- + // Static initializer injection + // ----------------------------------------------------------------------- + + private static void injectIntoClinit(ClassNode classNode, String owner, List fields) { + String direction = SyncTargetIndex.getDirection(classNode.name); + MethodNode clinit = findOrCreateClinit(classNode); + injectBeforeReturn(clinit, () -> buildStaticInstructions(owner, fields, direction)); + } + + private static MethodNode findOrCreateClinit(ClassNode classNode) { + for (MethodNode method : classNode.methods) { + if ("".equals(method.name)) { + return method; + } + } + // No yet – create a minimal one and append it + MethodNode clinit = new MethodNode(Opcodes.ACC_STATIC, "", "()V", null, null); + clinit.instructions.add(new InsnNode(Opcodes.RETURN)); + classNode.methods.add(clinit); + return clinit; + } + + // ----------------------------------------------------------------------- + // Generic "insert before every RETURN" helper + // ----------------------------------------------------------------------- + + private static void injectBeforeReturn(MethodNode method, Supplier snippet) { + // Collect RETURN nodes first to avoid ConcurrentModificationException + List returns = new ArrayList<>(); + for (AbstractInsnNode insn : method.instructions) { + if (insn.getOpcode() == Opcodes.RETURN) { + returns.add(insn); + } + } + for (AbstractInsnNode ret : returns) { + method.instructions.insertBefore(ret, snippet.get()); + } + } + + // ----------------------------------------------------------------------- + // Bytecode snippets + // ----------------------------------------------------------------------- + + /** + * For each field: + *

    +     * if (this.field != null) this.field.setParent(this);
    +     * 
    + */ + private static InsnList buildInstanceInstructions(String owner, List fields, String direction) { + InsnList list = new InsnList(); + String directionName = toDirectionName(direction); + for (FieldNode field : fields) { + LabelNode skip = new LabelNode(); + + // null check: if (this.field == null) goto skip + list.add(new VarInsnNode(Opcodes.ALOAD, 0)); + list.add(new FieldInsnNode(Opcodes.GETFIELD, owner, field.name, SYNC_PROXY_DESC)); + list.add(new JumpInsnNode(Opcodes.IFNULL, skip)); + + // this.field.setParent(this) + list.add(new VarInsnNode(Opcodes.ALOAD, 0)); + list.add(new FieldInsnNode(Opcodes.GETFIELD, owner, field.name, SYNC_PROXY_DESC)); + list.add(new VarInsnNode(Opcodes.ALOAD, 0)); // "this" as the parent Object + list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, SYNC_PROXY_INTERNAL, SET_PARENT_NAME, SET_PARENT_DESC, false)); + + // this.field.setFieldName("fieldName") + list.add(new VarInsnNode(Opcodes.ALOAD, 0)); + list.add(new FieldInsnNode(Opcodes.GETFIELD, owner, field.name, SYNC_PROXY_DESC)); + list.add(new LdcInsnNode(field.name)); + list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, SYNC_PROXY_INTERNAL, SET_FIELD_NAME_NAME, SET_FIELD_NAME_DESC, false)); + + // this.field.setDirection(direction) + list.add(new VarInsnNode(Opcodes.ALOAD, 0)); + list.add(new FieldInsnNode(Opcodes.GETFIELD, owner, field.name, SYNC_PROXY_DESC)); + list.add(new FieldInsnNode(Opcodes.GETSTATIC, SYNC_DIRECTION_INTERNAL, directionName, SYNC_DIRECTION_DESC)); + list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, SYNC_PROXY_INTERNAL, SET_DIRECTION_NAME, SET_DIRECTION_DESC, false)); + list.add(skip); + } + return list; + } + + /** + * For each field: + *
    +     * if (Owner.field != null) Owner.field.setParent(Owner.class);
    +     * 
    + */ + private static InsnList buildStaticInstructions(String owner, List fields, String direction) { + InsnList list = new InsnList(); + String directionName = toDirectionName(direction); + for (FieldNode field : fields) { + LabelNode skip = new LabelNode(); + + // null check: if (Owner.field == null) goto skip + list.add(new FieldInsnNode(Opcodes.GETSTATIC, owner, field.name, SYNC_PROXY_DESC)); + list.add(new JumpInsnNode(Opcodes.IFNULL, skip)); + + // Owner.field.setParent(Owner.class) + list.add(new FieldInsnNode(Opcodes.GETSTATIC, owner, field.name, SYNC_PROXY_DESC)); + list.add(new LdcInsnNode(Type.getObjectType(owner))); + list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, SYNC_PROXY_INTERNAL, SET_PARENT_NAME, SET_PARENT_DESC, false)); + + // Owner.field.setFieldName("fieldName") + list.add(new FieldInsnNode(Opcodes.GETSTATIC, owner, field.name, SYNC_PROXY_DESC)); + list.add(new LdcInsnNode(field.name)); + list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, SYNC_PROXY_INTERNAL, SET_FIELD_NAME_NAME, SET_FIELD_NAME_DESC, false)); + + // Owner.field.setDirection(direction) + list.add(new FieldInsnNode(Opcodes.GETSTATIC, owner, field.name, SYNC_PROXY_DESC)); + list.add(new FieldInsnNode(Opcodes.GETSTATIC, SYNC_DIRECTION_INTERNAL, directionName, SYNC_DIRECTION_DESC)); + list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, SYNC_PROXY_INTERNAL, SET_DIRECTION_NAME, SET_DIRECTION_DESC, false)); + + list.add(skip); + } + return list; + } + + private static String toDirectionName(String direction) { + if (direction.isBlank()) { + return "BOTH"; + } + String value = direction; + int dot = value.lastIndexOf('.'); + if (dot >= 0 && dot + 1 < value.length()) { + value = value.substring(dot + 1); + } + value = value.toUpperCase(); + if (!"C2S".equals(value) && !"S2C".equals(value) && !"BOTH".equals(value)) { + return "BOTH"; + } + return value; + } +} + diff --git a/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/SyncClassProcessor.java b/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/SyncClassProcessor.java new file mode 100644 index 00000000..ee7d098e --- /dev/null +++ b/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/SyncClassProcessor.java @@ -0,0 +1,77 @@ +package dev.anvilcraft.lib.v2.sync.transform; + +import cpw.mods.modlauncher.api.ITransformer; +import cpw.mods.modlauncher.api.ITransformerVotingContext; +import cpw.mods.modlauncher.api.TargetType; +import cpw.mods.modlauncher.api.TransformerVoteResult; +import lombok.extern.slf4j.Slf4j; +import net.neoforged.neoforgespi.coremod.ICoreMod; +import org.jetbrains.annotations.ApiStatus; +import org.objectweb.asm.tree.ClassNode; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 以 CoreMod({@link ICoreMod} + {@link ITransformer}<{@link ClassNode}>)方式实现 + * 原 26.1 的 {@code neoforgespi.transformation.ClassProcessor} SPI 字节码注入能力。 + * + *

    1.21.1 的 fancymodloader loader 4.0.x 不含 transformation SPI 包, + * 等价能力为 {@code META-INF/services/net.neoforged.neoforgespi.coremod.ICoreMod} + * 注册的 CoreMod。注入器({@link SyncBytecodeInjector} / + * {@link LazySyncBytecodeInjector})为纯 ASM {@link ClassNode} 操作,两侧复用。

    + * + *

    时序说明:{@link SyncTargetIndex} / {@link LazySyncTargetIndex} 的静态扫描 + * 依赖 {@code LoadingModList.get()}(mod 发现完成后就绪);CoreMod 的 + * {@link #getTransformers()} 在 FMLServiceProvider.launch()(mod 扫描之后)被调用, + * 故此时索引已填充,{@link SyncTransformer#targets()} 可精确列出目标类。

    + */ +@Slf4j +@ApiStatus.Internal +public class SyncClassProcessor implements ICoreMod { + @Override + public Iterable> getTransformers() { + return List.of(new SyncTransformer()); + } + + private static class SyncTransformer implements ITransformer { + @Override + public ClassNode transform(ClassNode input, ITransformerVotingContext context) { + String internalName = input.name; + boolean modified = false; + if (SyncTargetIndex.contains(internalName)) { + modified |= SyncBytecodeInjector.inject(input); + } + if (LazySyncTargetIndex.contains(internalName)) { + modified |= LazySyncBytecodeInjector.inject(input); + } + if (modified) { + log.debug("Injected sync calls into {}", internalName); + } + return input; + } + + @Override + public TransformerVoteResult castVote(ITransformerVotingContext context) { + return TransformerVoteResult.YES; + } + + @Override + public Set> targets() { + Set> targets = new HashSet<>(); + for (String internalName : SyncTargetIndex.allTargets()) { + targets.add(Target.targetClass(internalName.replace('/', '.'))); + } + for (String internalName : LazySyncTargetIndex.allTargets()) { + targets.add(Target.targetClass(internalName.replace('/', '.'))); + } + return targets; + } + + @Override + public TargetType getTargetType() { + return TargetType.CLASS; + } + } +} diff --git a/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/SyncTargetIndex.java b/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/SyncTargetIndex.java new file mode 100644 index 00000000..a02714dd --- /dev/null +++ b/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/SyncTargetIndex.java @@ -0,0 +1,68 @@ +package dev.anvilcraft.lib.v2.sync.transform; + +import lombok.extern.slf4j.Slf4j; +import net.neoforged.fml.loading.LoadingModList; +import net.neoforged.fml.loading.moddiscovery.ModFileInfo; +import net.neoforged.neoforgespi.language.ModFileScanData; +import org.jetbrains.annotations.ApiStatus; + +import java.lang.annotation.ElementType; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +@Slf4j +@ApiStatus.Internal +public class SyncTargetIndex { + public static final String SYNC_DESCRIPTOR = "Ldev/anvilcraft/lib/v2/sync/annotation/Sync;"; + private static final Map TARGETS = new HashMap<>(); + + static { + SyncTargetIndex.onLoad(); + } + + private SyncTargetIndex() { + } + + @SuppressWarnings("UnstableApiUsage") + private static void onLoad() { + int count = 0; + for (ModFileInfo fileInfo : LoadingModList.get().getModFiles()) { + for (ModFileScanData.AnnotationData annotation : fileInfo.getFile().getScanResult().getAnnotations()) { + if (!annotation.annotationType().getDescriptor().equals(SYNC_DESCRIPTOR)) continue; + if (annotation.targetType() != ElementType.TYPE) continue; + Object object = annotation.annotationData().get("value"); + String direction = "BOTH"; + if (object != null) { + direction = object.toString(); + } + String internalName = annotation.clazz().getInternalName(); + SyncTargetIndex.add(internalName, direction); + log.info("Registered @Sync target: {}", internalName); + count++; + } + } + log.info("Scan complete – {} @Sync target(s) registered.", count); + } + + private static void add(String internalName, String direction) { + TARGETS.put(internalName, direction); + } + + public static boolean contains(String internalName) { + boolean contains = TARGETS.containsKey(internalName); + if (contains) { + log.info("Checking if {} is a @Sync target", internalName); + } + return contains; + } + + public static String getDirection(String internalName) { + return TARGETS.get(internalName); + } + + public static Set allTargets() { + return TARGETS.keySet(); + } +} + diff --git a/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/package-info.java b/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/package-info.java new file mode 100644 index 00000000..a94f8298 --- /dev/null +++ b/module.sync/processor/src/main/java/dev/anvilcraft/lib/v2/sync/transform/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.sync.transform; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.sync/processor/src/main/resources/META-INF/services/net.neoforged.neoforgespi.coremod.ICoreMod b/module.sync/processor/src/main/resources/META-INF/services/net.neoforged.neoforgespi.coremod.ICoreMod new file mode 100644 index 00000000..c5e2e099 --- /dev/null +++ b/module.sync/processor/src/main/resources/META-INF/services/net.neoforged.neoforgespi.coremod.ICoreMod @@ -0,0 +1 @@ +dev.anvilcraft.lib.v2.sync.transform.SyncClassProcessor diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/AnvilLibSync.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/AnvilLibSync.java new file mode 100644 index 00000000..89d6398b --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/AnvilLibSync.java @@ -0,0 +1,91 @@ +package dev.anvilcraft.lib.v2.sync; + +import dev.anvilcraft.lib.v2.sync.init.AnvilLibSyncEntries; +import dev.anvilcraft.lib.v2.sync.init.AnvilLibSyncRegistries; +import dev.anvilcraft.lib.v2.sync.management.LazySyncManager; +import dev.anvilcraft.lib.v2.sync.management.SyncConfigManager; +import dev.anvilcraft.lib.v2.sync.management.SyncManager; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.network.protocol.configuration.ServerConfigurationPacketListener; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.bus.api.EventPriority; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.common.Mod; +import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.server.ServerStoppedEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; +import net.neoforged.neoforge.network.configuration.ICustomConfigurationTask; +import net.neoforged.neoforge.network.event.RegisterConfigurationTasksEvent; +import net.neoforged.neoforge.registries.RegisterEvent; +import org.jetbrains.annotations.ApiStatus; + +import java.util.Objects; +import java.util.function.Consumer; + +@Slf4j +@Mod(AnvilLibSync.MOD_ID) +@ApiStatus.Internal +public class AnvilLibSync { + public static final String MOD_ID = "anvillib_sync"; + public static final SyncManager SYNC_MANAGER = new SyncManager(); + public static final SyncConfigManager SYNC_CONFIG_MANAGER = new SyncConfigManager(); + public static final LazySyncManager LAZY_SYNC_MANAGER = new LazySyncManager(); + + public AnvilLibSync(IEventBus modEventBus, ModContainer modContainer) { + AnvilLibSyncEntries.SYNC_ENTRY.register(modEventBus); + modEventBus.addListener(EventPriority.LOWEST, this::onRegister); + modEventBus.addListener(this::onFMLCommonSetup); + modEventBus.addListener(this::onRegisterConfigurationTasks); + NeoForge.EVENT_BUS.addListener(this::onServerTick); + NeoForge.EVENT_BUS.addListener(this::onServerStopped); + } + + public void onFMLCommonSetup(FMLCommonSetupEvent event) { + SyncConfigManager.compileContent(); + } + + public static ResourceLocation of(String path) { + return ResourceLocation.fromNamespaceAndPath(MOD_ID, path); + } + + /** + * 每服务端 tick 结束时扫描惰性同步目标并下发变更。 + */ + public void onServerTick(ServerTickEvent.Post event) { + AnvilLibSync.LAZY_SYNC_MANAGER.tickServer(); + } + + /** + * 服务器停止时清理服务端侧惰性同步跟踪状态。 + */ + public void onServerStopped(ServerStoppedEvent event) { + AnvilLibSync.LAZY_SYNC_MANAGER.clearServer(); + } + + public void onRegister(RegisterEvent event) { + if (!Objects.equals(event.getRegistryKey(), AnvilLibSyncRegistries.SYNC_ENTRY)) return; + AnvilLibSync.SYNC_MANAGER.compileContent(); + } + + public void onRegisterConfigurationTasks(RegisterConfigurationTasksEvent event) { + event.register(new SyncConfig(event.getListener())); + } + + public record SyncConfig(ServerConfigurationPacketListener listener) implements ICustomConfigurationTask { + public static Type TYPE = new Type(AnvilLibSync.of("sync_config")); + + @Override + public void run(Consumer sender) { + sender.accept(AnvilLibSync.SYNC_CONFIG_MANAGER.createPyload()); + } + + @Override + public Type type() { + return SyncConfig.TYPE; + } + } +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/annotation/LazySync.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/annotation/LazySync.java new file mode 100644 index 00000000..5bfece31 --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/annotation/LazySync.java @@ -0,0 +1,31 @@ +package dev.anvilcraft.lib.v2.sync.annotation; + +import dev.anvilcraft.lib.v2.sync.util.SyncDirection; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 标注于字段,表示该字段以「惰性」方式同步。 + * + *

    与 {@link Sync} 不同,被 {@code @LazySync} 标注的字段无需使用 + * {@link dev.anvilcraft.lib.v2.sync.management.SyncProxy SyncProxy} 包装, + * 而是由 {@link dev.anvilcraft.lib.v2.sync.management.LazySyncManager LazySyncManager} + * 在每 tick 结束时扫描其所属对象、对比快照检测变更,并按对象分组同步 + * (即每个对象在同一 tick 内仅发送一个网络包)。

    + * + *

    所属对象需可被注册的 {@link dev.anvilcraft.lib.v2.sync.management.SyncRegisterEntry + * SyncRegisterEntry} 解析(如实体、方块实体或静态字段)。

    + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface LazySync { + /** + * 同步方向。 + * + * @return 同步方向,默认 {@link SyncDirection#BOTH} + */ + SyncDirection value() default SyncDirection.BOTH; +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/annotation/Sync.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/annotation/Sync.java new file mode 100644 index 00000000..a8f1c89d --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/annotation/Sync.java @@ -0,0 +1,11 @@ +package dev.anvilcraft.lib.v2.sync.annotation; + +import dev.anvilcraft.lib.v2.sync.util.SyncDirection; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +public @interface Sync { + SyncDirection value() default SyncDirection.BOTH; +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/annotation/package-info.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/annotation/package-info.java new file mode 100644 index 00000000..192d799a --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/annotation/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.sync.annotation; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/client/AnvilLibSyncClient.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/client/AnvilLibSyncClient.java new file mode 100644 index 00000000..e47b82f6 --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/client/AnvilLibSyncClient.java @@ -0,0 +1,38 @@ +package dev.anvilcraft.lib.v2.sync.client; + +import dev.anvilcraft.lib.v2.sync.AnvilLibSync; +import dev.anvilcraft.lib.v2.sync.management.SyncConfigManager; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.common.NeoForge; +import org.jetbrains.annotations.ApiStatus; + +@Mod(value = AnvilLibSync.MOD_ID, dist = Dist.CLIENT) +@ApiStatus.Internal +public class AnvilLibSyncClient { + public static final SyncConfigManager SYNC_CONFIG_MANAGER = new SyncConfigManager(); + + public AnvilLibSyncClient(IEventBus modEventBus, ModContainer modContainer) { + NeoForge.EVENT_BUS.addListener(this::onClientTick); + NeoForge.EVENT_BUS.addListener(this::onLoggingOut); + } + + /** + * 每客户端 tick 结束时扫描惰性同步目标并上行变更(受字段方向限制)。 + */ + public void onClientTick(ClientTickEvent.Post event) { + AnvilLibSync.LAZY_SYNC_MANAGER.tickClient(); + } + + /** + * 客户端登出时清理客户端侧惰性同步跟踪状态,避免跨存档 / 跨服残留。 + */ + public void onLoggingOut(ClientPlayerNetworkEvent.LoggingOut event) { + AnvilLibSync.LAZY_SYNC_MANAGER.clearClient(); + } +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/init/AnvilLibSyncEntries.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/init/AnvilLibSyncEntries.java new file mode 100644 index 00000000..0d891f11 --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/init/AnvilLibSyncEntries.java @@ -0,0 +1,56 @@ +package dev.anvilcraft.lib.v2.sync.init; + +import dev.anvilcraft.lib.v2.sync.AnvilLibSync; +import dev.anvilcraft.lib.v2.sync.management.SyncRegisterEntry; +import dev.anvilcraft.lib.v2.sync.util.SideUtil; +import dev.anvilcraft.lib.v2.util.Util; +import net.minecraft.core.BlockPos; +import net.minecraft.core.UUIDUtil; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.DeferredRegister; + +import java.util.UUID; + +public class AnvilLibSyncEntries { + public static final DeferredRegister> SYNC_ENTRY = DeferredRegister.create( + AnvilLibSyncRegistries.SYNC_ENTRY, + AnvilLibSync.MOD_ID + ); + + public static final DeferredHolder, SyncRegisterEntry, String>> STATIC = SYNC_ENTRY.register( + "static", + () -> SyncRegisterEntry.create( + Util.cast(Class.class), + ByteBufCodecs.STRING_UTF8, + Class::getName, + (ignored, className) -> Class.forName(className), + false, + null + ) + ); + + public static final DeferredHolder, SyncRegisterEntry> ENTITY = SYNC_ENTRY.register( + "entity", + () -> SyncRegisterEntry.create( + Entity.class, + UUIDUtil.STREAM_CODEC, + Entity::getUUID, + SideUtil::entityFinder, + Entity::level + ) + ); + + public static final DeferredHolder, SyncRegisterEntry> BLOCK_ENTITY = SYNC_ENTRY.register( + "block_entity", + () -> SyncRegisterEntry.create( + BlockEntity.class, + BlockPos.STREAM_CODEC, + BlockEntity::getBlockPos, + SideUtil::blockEntityFinder, + BlockEntity::getLevel + ) + ); +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/init/AnvilLibSyncRegistries.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/init/AnvilLibSyncRegistries.java new file mode 100644 index 00000000..92139d1c --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/init/AnvilLibSyncRegistries.java @@ -0,0 +1,27 @@ +package dev.anvilcraft.lib.v2.sync.init; + +import dev.anvilcraft.lib.v2.sync.AnvilLibSync; +import dev.anvilcraft.lib.v2.sync.management.SyncRegisterEntry; +import net.minecraft.core.Registry; +import net.minecraft.resources.ResourceKey; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.registries.NewRegistryEvent; +import net.neoforged.neoforge.registries.RegistryBuilder; +import org.jetbrains.annotations.ApiStatus; + +@EventBusSubscriber(modid = AnvilLibSync.MOD_ID) +public class AnvilLibSyncRegistries { + public static final ResourceKey>> SYNC_ENTRY = ResourceKey.createRegistryKey( + AnvilLibSync.of("sync_entry") + ); + public static final Registry> SYNC_ENTRY_REGISTRY = new RegistryBuilder<>(SYNC_ENTRY) + .maxId(512) + .create(); + + @ApiStatus.Internal + @SubscribeEvent + public static void registerRegistries(NewRegistryEvent event) { + event.register(SYNC_ENTRY_REGISTRY); + } +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/init/package-info.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/init/package-info.java new file mode 100644 index 00000000..fac333b3 --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/init/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.sync.init; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/LazySyncManager.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/LazySyncManager.java new file mode 100644 index 00000000..a6a696c5 --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/LazySyncManager.java @@ -0,0 +1,295 @@ +package dev.anvilcraft.lib.v2.sync.management; + +import com.google.common.collect.MapMaker; +import dev.anvilcraft.lib.v2.sync.AnvilLibSync; +import dev.anvilcraft.lib.v2.sync.annotation.LazySync; +import dev.anvilcraft.lib.v2.sync.network.payload.LazySyncPayload; +import dev.anvilcraft.lib.v2.sync.network.payload.LazySyncPayload.FieldChange; +import dev.anvilcraft.lib.v2.sync.util.SideUtil; +import dev.anvilcraft.lib.v2.sync.util.SyncDirection; +import dev.anvilcraft.lib.v2.util.Util; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import org.jetbrains.annotations.ApiStatus; +import javax.annotation.Nullable; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 惰性同步管理器。 + * + *

    负责被 {@link LazySync} 标注字段的「扫描 - 差分 - 分组同步」:

    + *
      + *
    • 实例 / 静态目标由字节码注入在 {@code } / {@code } 中登记 + * (见 {@link dev.anvilcraft.lib.v2.sync.transform.LazySyncBytecodeInjector})。
    • + *
    • 每 tick 结束时({@code ServerTickEvent.Post} / {@code ClientTickEvent.Post}) + * 遍历目标,将每个字段的当前值用其编解码器编码为字节,与上一快照对比;变更的字段 + * 按所属对象分组,每个对象仅发送一个 {@link LazySyncPayload}。
    • + *
    • 收到分组包时按对象写回各字段,并同步更新本侧快照以抑制回环。
    • + *
    + * + *

    采用「编码为字节再比较」的方式检测变更,可统一处理可变值(如 {@code CompoundTag}、 + * {@code ItemStack})的原地修改,且复用网络线格式,代价是每 tick 对每个被跟踪字段编码一次。

    + */ +@Slf4j +@ApiStatus.Internal +public class LazySyncManager { + /** + * 单侧状态:被跟踪的实例 / 静态目标及其字段快照。 + * + *

    跟踪集合使用弱引用 + 标识比较({@link MapMaker#weakKeys()}),使卸载的对象可被 GC, + * 无需显式反注册。

    + */ + private static final class Side { + final Set instances = Collections.newSetFromMap(new MapMaker().weakKeys().makeMap()); + final Set> statics = ConcurrentHashMap.newKeySet(); + /** object -> (configKey -> 上一次编码的字节快照) */ + final Map> snapshots = new MapMaker().weakKeys().makeMap(); + } + + private final Side server = new Side(); + private final Side client = new Side(); + + /** runtime class -> 实例 {@link LazySync} 字段元数据(含继承)。 */ + private final Map, List> instanceMetaCache = new ConcurrentHashMap<>(); + /** owner class -> 静态 {@link LazySync} 字段元数据(含继承)。 */ + private final Map, List> staticMetaCache = new ConcurrentHashMap<>(); + + /** + * 单个 {@link LazySync} 字段的运行时元数据。 + * + * @param field 反射字段(已 setAccessible) + * @param codec 字段值的编解码器 + * @param direction 同步方向 + * @param configKey 配置键 {@code DeclaringClassName#fieldName} + */ + private record LazyFieldMeta( + Field field, + StreamCodec codec, + SyncDirection direction, + String configKey + ) { + } + + /** + * 一个待写回的字段,由 {@link LazySyncPayload} 解包后传入 {@link #applyGrouped}。 + * + * @param configKey 配置键 {@code DeclaringClassName#fieldName} + * @param blob 字段值负载 + */ + public record AppliedField(String configKey, byte[] blob) { + } + + private Side side() { + return Util.isServer() ? this.server : this.client; + } + + /** + * 登记一个实例目标。由字节码注入在每个 {@code } 末尾调用。 + * + * @param object 被 {@link LazySync} 标注字段所属的实例 + */ + @ApiStatus.Internal + public void track(@Nullable Object object) { + if (object == null) return; + this.side().instances.add(object); + } + + /** + * 登记一个静态目标。由字节码注入在 {@code } 末尾调用。 + * + * @param owner 含静态 {@link LazySync} 字段的类 + */ + @ApiStatus.Internal + public void trackStatic(@Nullable Class owner) { + if (owner == null) return; + this.side().statics.add(owner); + } + + /** + * 服务端 tick:扫描所有服务端跟踪的目标并下发变更。由 {@code ServerTickEvent.Post} 驱动。 + */ + public void tickServer() { + this.tickAll(this.server, true); + } + + /** + * 客户端 tick:扫描所有客户端跟踪的目标并上行变更。由 {@code ClientTickEvent.Post} 驱动。 + */ + public void tickClient() { + this.tickAll(this.client, false); + } + + private void tickAll(Side side, boolean serverSide) { + if (!side.instances.isEmpty()) { + // 复制以避免在 tick 期间新对象登记导致的并发修改 + for (Object obj : List.copyOf(side.instances)) { + List metas = this.resolveInstanceMeta(obj.getClass()); + if (metas.isEmpty()) continue; + this.scanTarget(side, serverSide, obj, metas); + } + } + for (Class owner : side.statics) { + List metas = this.resolveStaticMeta(owner); + if (metas.isEmpty()) continue; + this.scanTarget(side, serverSide, owner, metas); + } + } + + /** + * 扫描单个目标的全部 {@link LazySync} 字段,编码后与快照对比,将本侧可发送的变更分组发送。 + * + *

    首次观测某字段时仅建立基线、不发送(与 {@link dev.anvilcraft.lib.v2.sync.annotation.Sync @Sync} + * 一致:初始值在两端构造时即相同,只有后续变更才需要同步)。

    + */ + private void scanTarget(Side side, boolean serverSide, Object target, List metas) { + Map snap = side.snapshots.computeIfAbsent(target, k -> new HashMap<>()); + List changes = new ArrayList<>(); + for (LazyFieldMeta meta : metas) { + boolean canSend = serverSide ? meta.direction().isCreateByServer() : meta.direction().isCreateByClient(); + if (!canSend) continue; + Object value; + try { + value = meta.field().get(Modifier.isStatic(meta.field().getModifiers()) ? null : target); + } catch (IllegalAccessException e) { + log.warn("Cannot read @LazySync field {}", meta.configKey(), e); + continue; + } + byte[] encoded = this.encodeField(meta, value); + byte[] prev = snap.get(meta.configKey()); + if (prev != null && Arrays.equals(prev, encoded)) continue; + snap.put(meta.configKey(), encoded); + if (prev == null) continue; // 首次观测:仅建立基线 + changes.add(new FieldChange(meta.configKey(), encoded)); + } + if (!changes.isEmpty()) { + this.sendGrouped(target, changes); + } + } + + private void sendGrouped(Object target, List changes) { + SyncRegisterEntry entry = AnvilLibSync.SYNC_MANAGER.contains(target.getClass()); + if (entry == null) { + log.warn("LazySync target {} is not registered for syncing", target.getClass().getName()); + return; + } + Object id = entry.idGetter().apply(target); + String lookupClassName = target.getClass().getName(); + SideUtil.send( + SyncDirection.BOTH, // 字段已按方向预过滤,此处恒从当前侧发送 + entry.dimension(), + entry.dimensionGetter(), + target, + id, + (flow) -> LazySyncPayload.create(entry.idCodec(), id, lookupClassName, changes, flow) + ); + } + + /** + * 写回一个对象的分组变更,并同步更新本侧快照以抑制回环。由 {@link LazySyncPayload} 收包后调用。 + * + * @param object 解析出的目标对象(静态字段时为所属 {@link Class}) + * @param fields 待写回的字段 + */ + @ApiStatus.Internal + public void applyGrouped(Object object, List fields) { + boolean isStatic = object instanceof Class; + List metas = isStatic + ? this.resolveStaticMeta((Class) object) + : this.resolveInstanceMeta(object.getClass()); + Map byKey = new HashMap<>(); + for (LazyFieldMeta meta : metas) byKey.put(meta.configKey(), meta); + + Map snap = this.side().snapshots.computeIfAbsent(object, k -> new HashMap<>()); + for (AppliedField af : fields) { + LazyFieldMeta meta = byKey.get(af.configKey()); + if (meta == null) { + log.warn("Received @LazySync field {} not found on {}", af.configKey(), object); + continue; + } + Object value = this.decodeField(meta, af.blob()); + try { + meta.field().set(isStatic ? null : object, value); + } catch (IllegalAccessException e) { + log.warn("Cannot write @LazySync field {}", af.configKey(), e); + continue; + } + // 以写回值重新编码作为快照,保证下一 tick 差分不会把这次写回当作本侧变更重新上行/下发 + snap.put(af.configKey(), this.encodeField(meta, value)); + } + } + + private byte[] encodeField(LazyFieldMeta meta, @Nullable Object value) { + FriendlyByteBuf buf = SideUtil.createFriendlyByteBuf(Unpooled.buffer()); + buf.writeBoolean(value == null); + if (value != null) { + Util.>cast(meta.codec()).encode(buf, value); + } + byte[] array = new byte[buf.readableBytes()]; + buf.readBytes(array); + return array; + } + + private @Nullable Object decodeField(LazyFieldMeta meta, byte[] blob) { + FriendlyByteBuf buf = SideUtil.createFriendlyByteBuf(Unpooled.buffer()); + buf.writeBytes(blob); + boolean isNull = buf.readBoolean(); + if (isNull) return null; + return Util.>cast(meta.codec()).decode(buf); + } + + private List resolveInstanceMeta(Class clazz) { + return this.instanceMetaCache.computeIfAbsent(clazz, c -> this.buildMeta(c, false)); + } + + private List resolveStaticMeta(Class clazz) { + return this.staticMetaCache.computeIfAbsent(clazz, c -> this.buildMeta(c, true)); + } + + private List buildMeta(Class clazz, boolean staticFields) { + List metas = new ArrayList<>(); + for (Class c = clazz; c != null && c != Object.class; c = c.getSuperclass()) { + for (Field field : c.getDeclaredFields()) { + LazySync annotation = field.getAnnotation(LazySync.class); + if (annotation == null) continue; + if (Modifier.isStatic(field.getModifiers()) != staticFields) continue; + StreamCodec rawCodec = SyncProxy.defaultCodec(Util.cast(field.getType())); + if (rawCodec == null) { + log.warn( + "No default codec for @LazySync field {}.{} of type {}, skipping", + c.getName(), field.getName(), field.getType().getName() + ); + continue; + } + StreamCodec codec = Util.cast(rawCodec); + field.setAccessible(true); + metas.add(new LazyFieldMeta(field, codec, annotation.value(), "%s#%s".formatted(c.getName(), field.getName()))); + } + } + return Collections.unmodifiableList(metas); + } + + public void clearServer() { + this.server.instances.clear(); + this.server.statics.clear(); + this.server.snapshots.clear(); + } + + public void clearClient() { + this.client.instances.clear(); + this.client.statics.clear(); + this.client.snapshots.clear(); + } +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/SyncConfigManager.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/SyncConfigManager.java new file mode 100644 index 00000000..cfc2a4ff --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/SyncConfigManager.java @@ -0,0 +1,129 @@ +package dev.anvilcraft.lib.v2.sync.management; + +import dev.anvilcraft.lib.v2.sync.AnvilLibSync; +import dev.anvilcraft.lib.v2.sync.network.payload.SyncConfigurationPayload; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import net.neoforged.fml.loading.LoadingModList; +import net.neoforged.fml.loading.moddiscovery.ModFileInfo; +import net.neoforged.neoforgespi.language.ModFileScanData; +import net.neoforged.neoforgespi.locating.IModFile; +import org.jetbrains.annotations.ApiStatus; +import javax.annotation.Nullable; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.FieldVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.InputStream; +import java.lang.annotation.ElementType; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@Slf4j +@ApiStatus.Internal +public class SyncConfigManager { + public static final String SYNC_DESCRIPTOR = "Ldev/anvilcraft/lib/v2/sync/annotation/Sync;"; + public static final String LAZY_SYNC_DESCRIPTOR = "Ldev/anvilcraft/lib/v2/sync/annotation/LazySync;"; + private static final String SYNC_PROXY_DESC = "Ldev/anvilcraft/lib/v2/sync/management/SyncProxy;"; + public int usedId = 0; + public final Map syncConfigs = new HashMap<>(); + public final Map syncConfigById = new HashMap<>(); + + + @ApiStatus.Internal + @SneakyThrows + @SuppressWarnings("UnstableApiUsage") + public static void compileContent() { + for (ModFileInfo fileInfo : LoadingModList.get().getModFiles()) { + for (ModFileScanData.AnnotationData annotation : fileInfo.getFile().getScanResult().getAnnotations()) { + if (annotation.annotationType().getDescriptor().equals(SyncConfigManager.SYNC_DESCRIPTOR)) { + if (annotation.targetType() != ElementType.TYPE) continue; + compileSyncType(fileInfo, annotation.clazz().getClassName()); + } else if (annotation.annotationType().getDescriptor().equals(SyncConfigManager.LAZY_SYNC_DESCRIPTOR)) { + if (annotation.targetType() != ElementType.FIELD) continue; + // FIELD 目标的 memberName 即字段名 + String key = "%s#%s".formatted(annotation.clazz().getClassName(), annotation.memberName()); + log.info("Loading LazySyncConfig: {}", key); + AnvilLibSync.SYNC_CONFIG_MANAGER.register(key); + } + } + } + } + + @SuppressWarnings("UnstableApiUsage") + private static void compileSyncType(ModFileInfo fileInfo, String className) { + IModFile modFile = fileInfo.getFile(); + String classPath = className.replace('.', '/') + ".class"; + log.info("Loading SyncConfig: {}", className); + // 1.21.1 的 IModFile 无 getContents()(26.1 新增),改用 SecureJar.getPath 读取 + Path classFilePath = modFile.getSecureJar().getPath(classPath); + if (Files.exists(classFilePath)) { + try (InputStream inputStream = Files.newInputStream(classFilePath)) { + if (inputStream != null) { + ClassReader classReader = new ClassReader(inputStream); + FieldListingVisitor fieldListingVisitor = new FieldListingVisitor(className, null); + classReader.accept(fieldListingVisitor, 0); + } + } catch (Exception e) { + log.error("Error while reading class file: {}", classPath, e); + } + } + } + + public static class FieldListingVisitor extends ClassVisitor { + private final String className; + + FieldListingVisitor(String className, @Nullable ClassVisitor classVisitor) { + super(Opcodes.ASM9, classVisitor); + this.className = className; + } + + @Override + public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { + if (Objects.equals(descriptor, SyncConfigManager.SYNC_PROXY_DESC) && (access & Opcodes.ACC_FINAL) != 0) { + AnvilLibSync.SYNC_CONFIG_MANAGER.register("%s#%s".formatted(this.className, name)); + } + return super.visitField(access, name, descriptor, signature, value); + } + } + + public void register(String syncConfig) { + this.syncConfigs.put(syncConfig, this.usedId); + this.syncConfigById.put(this.usedId, syncConfig); + this.usedId++; + } + + public int getId(String syncConfig) { + return this.syncConfigs.get(syncConfig); + } + + public String getById(int id) { + return this.syncConfigById.get(id); + } + + public void clear() { + this.syncConfigs.clear(); + this.syncConfigById.clear(); + this.usedId = 0; + } + + + public void registerAll(Map syncConfigById) { + this.clear(); + syncConfigById.forEach((id, syncConfig) -> { + this.syncConfigs.put(syncConfig, id); + this.syncConfigById.put(id, syncConfig); + if (id >= this.usedId) { + this.usedId = id + 1; + } + }); + } + + public SyncConfigurationPayload createPyload() { + return new SyncConfigurationPayload(this.syncConfigById); + } +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/SyncManager.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/SyncManager.java new file mode 100644 index 00000000..e2fe6abb --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/SyncManager.java @@ -0,0 +1,66 @@ +package dev.anvilcraft.lib.v2.sync.management; + +import dev.anvilcraft.lib.v2.sync.init.AnvilLibSyncRegistries; +import dev.anvilcraft.lib.v2.sync.network.payload.SyncPayload; +import dev.anvilcraft.lib.v2.sync.util.SideUtil; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.resources.ResourceKey; +import org.jetbrains.annotations.ApiStatus; +import javax.annotation.Nullable; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +@Slf4j +@ApiStatus.Internal +public class SyncManager { + private final Map, SyncRegisterEntry> syncRegisterEntryMap = new HashMap<>(); + + @ApiStatus.Internal + public void compileContent() { + Set>, SyncRegisterEntry>> syncEntries = AnvilLibSyncRegistries.SYNC_ENTRY_REGISTRY.entrySet(); + log.info("SYNC_ENTRY_REGISTRY Size: {}", syncEntries.size()); + syncEntries.forEach(entry -> this.syncRegisterEntryMap.put(entry.getValue().clazz(), entry.getValue())); + } + + void setValue(@Nullable P parent, SyncProxy proxy, T oldValue, T newValue) { + Objects.requireNonNull(parent, "Parent cannot be null when setting value"); + SyncRegisterEntry entry = checkParent(parent); + ID id = entry.idGetter().apply(parent); + SideUtil.send( + proxy.getDirection(), + entry.dimension(), + entry.dimensionGetter(), + parent, + id, + (flow) -> SyncPayload.create(parent, entry.idCodec(), id, proxy, flow) + ); + } + + void getValue(@Nullable Object parent, SyncProxy proxy, T value) { + } + + SyncRegisterEntry checkParent(Object parent) { + return Objects.requireNonNull( + this.contains(parent.getClass()), + "Parent class " + parent.getClass().getName() + " is not registered for syncing" + ); + } + + @Nullable + @ApiStatus.Internal + @SuppressWarnings("unchecked") + public SyncRegisterEntry contains(Class clazz) { + boolean containsKey = syncRegisterEntryMap.containsKey(clazz); + if (containsKey) return (SyncRegisterEntry) syncRegisterEntryMap.get(clazz); + Set> classes = syncRegisterEntryMap.keySet(); + for (Class classKey : classes) { + if (classKey.isAssignableFrom(clazz)) { + return (SyncRegisterEntry) syncRegisterEntryMap.get(classKey); + } + } + return null; + } +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/SyncProxy.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/SyncProxy.java new file mode 100644 index 00000000..170ebb12 --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/SyncProxy.java @@ -0,0 +1,244 @@ +package dev.anvilcraft.lib.v2.sync.management; + +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.PropertyMap; +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import dev.anvilcraft.lib.v2.sync.AnvilLibSync; +import dev.anvilcraft.lib.v2.sync.util.SyncDirection; +import dev.anvilcraft.lib.v2.util.Util; +import io.netty.buffer.ByteBuf; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ComponentSerialization; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.VarInt; +import net.minecraft.network.VarLong; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.ApiStatus; +import org.joml.Quaternionfc; +import org.joml.Vector3fc; +import javax.annotation.Nullable; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.util.Objects; + +@Slf4j +public class SyncProxy { + @Getter + @ApiStatus.Internal + private final StreamCodec codec; + @Setter + @ApiStatus.Internal + private @Nullable SyncManager manager; + @Setter + @ApiStatus.Internal + private @Nullable Object parent; + @Setter + @Getter + @ApiStatus.Internal + private @Nullable String fieldName; + private volatile @Nullable T value; + @Getter + private SyncDirection direction = SyncDirection.BOTH; + + public SyncProxy(T value) { + this.value = value; + this.codec = Objects.requireNonNull( + SyncProxy.defaultCodec(Util.cast(value.getClass())), + "No default codec for type: " + value.getClass().getName() + ); + } + + public SyncProxy(T value, StreamCodec codec) { + this.value = value; + this.codec = codec; + } + + public SyncProxy(Class type) { + this.value = null; + this.codec = Objects.requireNonNull(SyncProxy.defaultCodec(type), "No default codec for type: " + type.getName()); + } + + public SyncProxy(StreamCodec codec) { + this.value = null; + this.codec = codec; + } + + public SyncProxy direction(SyncDirection direction) { + this.direction = direction; + return this; + } + + @ApiStatus.Internal + public void setDirection(SyncDirection direction) { + if (this.direction == SyncDirection.BOTH) this.direction = direction; + } + + public void setValue(B buf, boolean serverbound) { + T oldValue = this.value; + boolean isNull = buf.readBoolean(); + T newValue; + if (isNull) { + newValue = null; + } else { + newValue = Util.>cast(this.getCodec()).decode(buf); + } + this.value = newValue; + if (!serverbound) return; + if (this.manager == null) { + this.manager = AnvilLibSync.SYNC_MANAGER; + } + this.manager.setValue(parent, this, oldValue, newValue); + } + + public @Nullable T getValue() { + if (this.manager == null) { + this.manager = AnvilLibSync.SYNC_MANAGER; + } + this.manager.getValue(parent, this, this.value); + return this.value; + } + + public void setValue(@Nullable T value) { + T old = this.value; + this.value = value; + if (this.manager == null) { + this.manager = AnvilLibSync.SYNC_MANAGER; + } + this.manager.setValue(parent, this, old, this.value); + } + + public void encode(B byteBuf) { + byteBuf.writeBoolean(this.getValue() == null); + if (this.getValue() != null) { + Util.>cast(this.getCodec()).encode(byteBuf, this.getValue()); + } + } + + @SuppressWarnings("unchecked") + @Nullable + static StreamCodec defaultCodec(Class type) { + if (type == CompoundTag.class) { + return (StreamCodec) ByteBufCodecs.TAG; + } + if (Tag.class.isAssignableFrom(type)) { + return (StreamCodec) ByteBufCodecs.TAG; + } + if (Vector3fc.class.isAssignableFrom(type)) { + return (StreamCodec) ByteBufCodecs.VECTOR3F; + } + if (Quaternionfc.class.isAssignableFrom(type)) { + return (StreamCodec) ByteBufCodecs.QUATERNIONF; + } + if (type == PropertyMap.class) { + return (StreamCodec) ByteBufCodecs.GAME_PROFILE_PROPERTIES; + } + if (type == GameProfile.class) { + return (StreamCodec) ByteBufCodecs.GAME_PROFILE; + } + if (type == BlockPos.class) { + return (StreamCodec) BlockPos.STREAM_CODEC; + } + if (Component.class.isAssignableFrom(type)) { + return (StreamCodec) ComponentSerialization.STREAM_CODEC; + } + if (type == ItemStack.class) { + return (StreamCodec) ItemStack.OPTIONAL_STREAM_CODEC; + } + if (type == String.class) { + return (StreamCodec) ByteBufCodecs.STRING_UTF8; + } + if (type == Boolean.class || type == boolean.class) { + return (StreamCodec) ByteBufCodecs.BOOL; + } + if (type == Double.class || type == double.class) { + return (StreamCodec) ByteBufCodecs.DOUBLE; + } + if (type == Float.class || type == float.class) { + return (StreamCodec) ByteBufCodecs.FLOAT; + } + if (type == Long.class || type == long.class) { + return (StreamCodec) ByteBufCodecs.VAR_LONG; + } + if (type == Integer.class || type == int.class) { + return (StreamCodec) ByteBufCodecs.INT; + } + if (type == Short.class || type == short.class) { + return (StreamCodec) ByteBufCodecs.SHORT; + } + if (type == Byte.class || type == byte.class) { + return (StreamCodec) ByteBufCodecs.BYTE; + } + // 1.21.1 的 ByteBufCodecs 无 LONG_ARRAY(26.1 新增),手写等价编解码:VarInt 长度 + VarLong 元素 + if (type == Long[].class || type == long[].class) { + return (StreamCodec) StreamCodec.of( + (ByteBuf buf, long[] arr) -> { + VarInt.write(buf, arr.length); + for (long v : arr) VarLong.write(buf, v); + }, + buf -> { + long[] arr = new long[VarInt.read(buf)]; + for (int i = 0; i < arr.length; i++) arr[i] = VarLong.read(buf); + return arr; + } + ); + } + if (type == Byte[].class || type == byte[].class) { + return (StreamCodec) ByteBufCodecs.BYTE_ARRAY; + } + return SyncProxy.codecFromType(type); + } + + /** + * 在目标类型自身声明的 {@code public static final} 字段中查找可用的 + * {@link Codec} 或 {@link MapCodec},并转换为 {@link StreamCodec}。 + * + *

    查找规则:

    + *
      + *
    • 仅扫描 {@code type} 自身声明的字段(不含父类),取 {@code public static final} 字段;
    • + *
    • 字段类型为 {@link Codec Codec<T>} 或 {@link MapCodec MapCodec<T>}, + * 且其类型实参与 {@code type} 兼容;{@link MapCodec} 经 {@link MapCodec#codec()} 转为 {@link Codec};
    • + *
    • 经 {@link ByteBufCodecs#fromCodecWithRegistries(Codec)} 包装为注册表感知的 + * {@link StreamCodec}(本库的缓冲区在具备注册表访问时即为 + * {@link net.minecraft.network.RegistryFriendlyByteBuf RegistryFriendlyByteBuf})。
    • + *
    + * + * @return 找到的 {@link StreamCodec},未找到时返回 {@code null} + */ + @SuppressWarnings("unchecked") + @Nullable + private static StreamCodec codecFromType(Class type) { + for (Field field : type.getDeclaredFields()) { + int mods = field.getModifiers(); + if (!Modifier.isStatic(mods) || !Modifier.isFinal(mods) || !Modifier.isPublic(mods)) continue; + + Class fieldType = field.getType(); + boolean isCodec = Codec.class == fieldType; + boolean isMapCodec = MapCodec.class == fieldType; + if (!isCodec && !isMapCodec) continue; + if (!(field.getGenericType() instanceof ParameterizedType pt)) continue; + if (pt.getActualTypeArguments().length != 1) continue; + if (!(pt.getActualTypeArguments()[0] instanceof Class arg)) continue; + if (!type.isAssignableFrom(arg)) continue; + + try { + Object value = field.get(null); + if (value == null) continue; + Codec codec = isMapCodec ? ((MapCodec) value).codec() : (Codec) value; + return ByteBufCodecs.fromCodecWithRegistries(codec); + } catch (IllegalAccessException e) { + log.warn("Cannot access codec field {}.{}", type.getName(), field.getName(), e); + } + } + return null; + } +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/SyncRegisterEntry.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/SyncRegisterEntry.java new file mode 100644 index 00000000..21a53ff4 --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/SyncRegisterEntry.java @@ -0,0 +1,62 @@ +package dev.anvilcraft.lib.v2.sync.management; + +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import javax.annotation.Nullable; + +import java.util.function.Function; + +public record SyncRegisterEntry( + Class clazz, + StreamCodec idCodec, + Function idGetter, + Finder finder, + boolean dimension, + @Nullable Function> dimensionGetter +) { + public static SyncRegisterEntry create( + Class type, + StreamCodec idCodec, + Function idGetter, + Finder finder + ) { + return SyncRegisterEntry.create(type, idCodec, idGetter, finder, false, null); + } + + @SuppressWarnings("resource") + public static SyncRegisterEntry create( + Class type, + StreamCodec idCodec, + Function idGetter, + Finder finder, + Function dimensionGetter + ) { + return SyncRegisterEntry.create( + type, idCodec, idGetter, finder, true, t -> { + Level apply = dimensionGetter.apply(t); + if (apply == null) return null; + return apply.dimension(); + } + ); + } + + public static SyncRegisterEntry create( + Class type, + StreamCodec idCodec, + Function idGetter, + Finder finder, + boolean dimension, + @Nullable Function> dimensionGetter + ) { + return new SyncRegisterEntry<>(type, idCodec, idGetter, finder, dimension, dimensionGetter); + } + + @FunctionalInterface + public interface Finder { + @Nullable + T apply(IPayloadContext context, ID id) throws Exception; + } +} \ No newline at end of file diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/package-info.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/package-info.java new file mode 100644 index 00000000..3cbce42f --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/management/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.sync.management; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/AnvilLibSyncNetwork.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/AnvilLibSyncNetwork.java new file mode 100644 index 00000000..476387bc --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/AnvilLibSyncNetwork.java @@ -0,0 +1,21 @@ +package dev.anvilcraft.lib.v2.sync.network; + +import dev.anvilcraft.lib.v2.network.register.NetworkRegistrar; +import dev.anvilcraft.lib.v2.sync.AnvilLibSync; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; +import net.neoforged.neoforge.network.registration.PayloadRegistrar; +import org.jetbrains.annotations.ApiStatus; + +@EventBusSubscriber(modid = AnvilLibSync.MOD_ID) +@ApiStatus.Internal +public class AnvilLibSyncNetwork { + public static final String NETWORK_VERSION = "1"; + + @SubscribeEvent + public static void register(RegisterPayloadHandlersEvent event) { + PayloadRegistrar registrar = event.registrar(NETWORK_VERSION); + NetworkRegistrar.register(registrar, AnvilLibSync.MOD_ID); + } +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/package-info.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/package-info.java new file mode 100644 index 00000000..3ee2150a --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.sync.network; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/LazySyncPayload.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/LazySyncPayload.java new file mode 100644 index 00000000..2bf7c0d5 --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/LazySyncPayload.java @@ -0,0 +1,141 @@ +package dev.anvilcraft.lib.v2.sync.network.payload; + +import dev.anvilcraft.lib.v2.network.packet.IInsensitiveBiPacket; +import dev.anvilcraft.lib.v2.network.packet.IPacket; +import dev.anvilcraft.lib.v2.sync.AnvilLibSync; +import dev.anvilcraft.lib.v2.sync.client.AnvilLibSyncClient; +import dev.anvilcraft.lib.v2.sync.management.LazySyncManager; +import dev.anvilcraft.lib.v2.sync.management.SyncRegisterEntry; +import dev.anvilcraft.lib.v2.sync.util.SideUtil; +import dev.anvilcraft.lib.v2.util.Util; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.world.entity.player.Player; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import org.jetbrains.annotations.ApiStatus; + +import java.util.ArrayList; +import java.util.List; + +/** + * 惰性同步网络包。 + * + *

    每个网络包对应「一个对象」在某 tick 内发生变更的「全部字段」,从而实现按对象分组、 + * 每对象每 tick 仅一个网络包。与 {@link SyncPayload} 逐字段发送不同。

    + * + *

    负载布局(写入单个 {@code byte[]}):

    + *
    + * String  lookupClassName   // 解析 SyncRegisterEntry 所用的运行时类名(静态字段为 java.lang.Class)
    + * (idCodec) objectId        // 对象 id,由对应 entry 的 idCodec 编解码
    + * VarInt  fieldCount        // 变更字段数量
    + * repeat:
    + *   VarInt syncConfigId     // 字段配置 id(ClassName#fieldName 的压缩 id)
    + *   VarInt blobLength
    + *   byte[] blob             // 字段值负载:boolean(isNull) + (codec 编码值)
    + * 
    + */ +@Slf4j +@ApiStatus.Internal +public record LazySyncPayload( + byte[] array +) implements IInsensitiveBiPacket { + public static final Type TYPE = IPacket.type(AnvilLibSync.of("lazy_sync")); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.BYTE_ARRAY, + LazySyncPayload::array, + LazySyncPayload::new + ); + + /** + * 一个变更字段。 + * + * @param configKey 字段的配置键,形如 {@code DeclaringClassName#fieldName} + * @param blob 字段值负载(boolean(isNull) + codec 编码值) + */ + public record FieldChange(String configKey, byte[] blob) { + } + + public static LazySyncPayload create( + StreamCodec idCodec, + ID id, + String lookupClassName, + List changes, + PacketFlow flow + ) { + FriendlyByteBuf buf = SideUtil.createFriendlyByteBuf(Unpooled.buffer()); + buf.writeUtf(lookupClassName); + Util.>cast(idCodec).encode(buf, id); + buf.writeVarInt(changes.size()); + for (FieldChange change : changes) { + int syncConfigId = flow == PacketFlow.CLIENTBOUND + ? AnvilLibSync.SYNC_CONFIG_MANAGER.getId(change.configKey()) + : AnvilLibSyncClient.SYNC_CONFIG_MANAGER.getId(change.configKey()); + buf.writeVarInt(syncConfigId); + buf.writeVarInt(change.blob().length); + buf.writeBytes(change.blob()); + } + byte[] array = new byte[buf.readableBytes()]; + buf.readBytes(array); + return new LazySyncPayload(array); + } + + private void handler(IPayloadContext ctx) { + FriendlyByteBuf buf = SideUtil.createFriendlyByteBuf(Unpooled.buffer()); + buf.writeBytes(this.array()); + + Object object; + List applied; + try { + String lookupClassName = buf.readUtf(); + Class lookupClass = Class.forName(lookupClassName); + SyncRegisterEntry entry = AnvilLibSync.SYNC_MANAGER.contains(lookupClass); + if (entry == null) { + // 两端注册不一致时静默丢弃,避免抛异常传播到 Netty 导致连接断开 + log.warn("Received LazySync packet for unregistered class {}, dropping", lookupClassName); + return; + } + Object id = entry.idCodec().decode(buf); + object = entry.finder().apply(ctx, Util.cast(id)); + + int fieldCount = buf.readVarInt(); + boolean isServer = SideUtil.isServer(); + applied = new ArrayList<>(fieldCount); + for (int i = 0; i < fieldCount; i++) { + int syncConfigId = buf.readVarInt(); + int blobLength = buf.readVarInt(); + byte[] blob = new byte[blobLength]; + buf.readBytes(blob); + String configKey = isServer + ? AnvilLibSync.SYNC_CONFIG_MANAGER.getById(syncConfigId) + : AnvilLibSyncClient.SYNC_CONFIG_MANAGER.getById(syncConfigId); + applied.add(new LazySyncManager.AppliedField(configKey, blob)); + } + } catch (Exception e) { + log.warn("Failed to parse LazySync packet, dropping", e); + return; + } + if (object == null) return; + AnvilLibSync.LAZY_SYNC_MANAGER.applyGrouped(object, applied); + } + + @Override + public void bidirectionalHandler(IPayloadContext ctx) { + IInsensitiveBiPacket.super.bidirectionalHandler(ctx); + ctx.enqueueWork(() -> this.handler(ctx)); + } + + @Override + public void handleOnBothSide(Player player) { + } + + @Override + public Type type() { + return LazySyncPayload.TYPE; + } +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/SyncConfigurationFinishPayload.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/SyncConfigurationFinishPayload.java new file mode 100644 index 00000000..4f11fd5f --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/SyncConfigurationFinishPayload.java @@ -0,0 +1,31 @@ +package dev.anvilcraft.lib.v2.sync.network.payload; + +import dev.anvilcraft.lib.v2.network.packet.IPacket; +import dev.anvilcraft.lib.v2.network.packet.IServerboundPacket; +import dev.anvilcraft.lib.v2.sync.AnvilLibSync; +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.world.entity.player.Player; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public class SyncConfigurationFinishPayload implements IServerboundPacket { + public static final SyncConfigurationFinishPayload INSTANCE = new SyncConfigurationFinishPayload(); + public static final Type TYPE = IPacket.type(AnvilLibSync.of("sync_config_finish")); + public static final StreamCodec STREAM_CODEC = StreamCodec.unit(INSTANCE); + + @Override + public void handleOnServer(Player player) { + } + + @Override + public void serverHandler(IPayloadContext ctx) { + ctx.finishCurrentTask(AnvilLibSync.SyncConfig.TYPE); + } + + @Override + public Type type() { + return SyncConfigurationFinishPayload.TYPE; + } +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/SyncConfigurationPayload.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/SyncConfigurationPayload.java new file mode 100644 index 00000000..4ac65ad4 --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/SyncConfigurationPayload.java @@ -0,0 +1,42 @@ +package dev.anvilcraft.lib.v2.sync.network.payload; + +import dev.anvilcraft.lib.v2.network.packet.IClientboundPacket; +import dev.anvilcraft.lib.v2.network.packet.IPacket; +import dev.anvilcraft.lib.v2.sync.AnvilLibSync; +import dev.anvilcraft.lib.v2.sync.client.AnvilLibSyncClient; +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.world.entity.player.Player; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import org.jetbrains.annotations.ApiStatus; + +import java.util.HashMap; +import java.util.Map; + +@ApiStatus.Internal +public record SyncConfigurationPayload( + Map syncMap +) implements IClientboundPacket { + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.map(HashMap::new, ByteBufCodecs.VAR_INT, ByteBufCodecs.STRING_UTF8), + SyncConfigurationPayload::syncMap, + SyncConfigurationPayload::new + ); + public static final Type TYPE = IPacket.type(AnvilLibSync.of("sync_config")); + + @Override + public void handleOnClient(Player player) { + } + + @Override + public void clientHandler(IPayloadContext ctx) { + ctx.enqueueWork(() -> AnvilLibSyncClient.SYNC_CONFIG_MANAGER.registerAll(this.syncMap())); + ctx.reply(SyncConfigurationFinishPayload.INSTANCE); + } + + @Override + public Type type() { + return SyncConfigurationPayload.TYPE; + } +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/SyncPayload.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/SyncPayload.java new file mode 100644 index 00000000..28af73ab --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/SyncPayload.java @@ -0,0 +1,99 @@ +package dev.anvilcraft.lib.v2.sync.network.payload; + +import dev.anvilcraft.lib.v2.network.packet.IInsensitiveBiPacket; +import dev.anvilcraft.lib.v2.network.packet.IPacket; +import dev.anvilcraft.lib.v2.sync.AnvilLibSync; +import dev.anvilcraft.lib.v2.sync.client.AnvilLibSyncClient; +import dev.anvilcraft.lib.v2.sync.management.SyncProxy; +import dev.anvilcraft.lib.v2.sync.management.SyncRegisterEntry; +import dev.anvilcraft.lib.v2.sync.util.SideUtil; +import dev.anvilcraft.lib.v2.util.Util; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import lombok.SneakyThrows; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.world.entity.player.Player; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import org.jetbrains.annotations.ApiStatus; + +import java.lang.reflect.Field; +import java.util.Objects; + +@ApiStatus.Internal +public record SyncPayload( + byte[] array +) implements IInsensitiveBiPacket { + public static final Type TYPE = IPacket.type(AnvilLibSync.of("sync")); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.BYTE_ARRAY, + SyncPayload::array, + SyncPayload::new + ); + + public static SyncPayload create( + T parent, + StreamCodec idCodec, + ID id, + SyncProxy field, + PacketFlow flow + ) { + FriendlyByteBuf buf = SideUtil.createFriendlyByteBuf(Unpooled.buffer()); + String syncConfig = "%s#%s".formatted( + parent.getClass().getName(), // ClassName + Objects.requireNonNull(field.getFieldName()) // FieldName + ); + int syncConfigId = flow == PacketFlow.CLIENTBOUND + ? AnvilLibSync.SYNC_CONFIG_MANAGER.getId(syncConfig) + : AnvilLibSyncClient.SYNC_CONFIG_MANAGER.getId(syncConfig); + buf.writeVarInt(syncConfigId); // SyncConfigId + Util.>cast(idCodec).encode(buf, id); // ObjectId + field.encode(buf); // FieldValue + byte[] array = new byte[buf.readableBytes()]; + buf.readBytes(array); + return new SyncPayload(array); + } + + @SneakyThrows + private void handler(IPayloadContext ctx) { + FriendlyByteBuf buf = SideUtil.createFriendlyByteBuf(Unpooled.buffer()); + buf.writeBytes(this.array()); + int syncConfigId = buf.readVarInt(); + String syncConfig = SideUtil.isServer() + ? AnvilLibSync.SYNC_CONFIG_MANAGER.getById(syncConfigId) + : AnvilLibSyncClient.SYNC_CONFIG_MANAGER.getById(syncConfigId); + String[] syncConfigSplit = syncConfig.split("#"); + String className = syncConfigSplit[0]; // ClassName + String fieldName = syncConfigSplit[1]; // FieldName + Class clazz = Class.forName(className); + SyncRegisterEntry entry = Objects.requireNonNull( + AnvilLibSync.SYNC_MANAGER.contains(clazz), + "Class " + className + " is not registered for syncing" + ); + Object id = entry.idCodec().decode(buf); // ObjectId + Object object = entry.finder().apply(ctx, Util.cast(id)); + boolean isStatic = object instanceof Class; + if (isStatic) clazz = (Class) object; + Field field = (clazz).getField(fieldName); + SyncProxy syncProxy = Util.cast(isStatic ? field.get(null) : field.get(object)); + syncProxy.setValue(buf, PacketFlow.SERVERBOUND.equals(ctx.flow())); // FieldValue + } + + @Override + public void bidirectionalHandler(IPayloadContext ctx) { + IInsensitiveBiPacket.super.bidirectionalHandler(ctx); + ctx.enqueueWork(() -> this.handler(ctx)); + } + + @Override + public void handleOnBothSide(Player player) { + } + + @Override + public Type type() { + return SyncPayload.TYPE; + } +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/package-info.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/package-info.java new file mode 100644 index 00000000..595fd528 --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/network/payload/package-info.java @@ -0,0 +1,12 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +@Network(protocol = PacketProtocol.COMMON) +package dev.anvilcraft.lib.v2.sync.network.payload; + +import dev.anvilcraft.lib.v2.network.register.Network; +import dev.anvilcraft.lib.v2.network.register.PacketProtocol; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/package-info.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/package-info.java new file mode 100644 index 00000000..e5521346 --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.sync; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/util/SideUtil.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/util/SideUtil.java new file mode 100644 index 00000000..23e4ae3d --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/util/SideUtil.java @@ -0,0 +1,165 @@ +package dev.anvilcraft.lib.v2.sync.util; + +import dev.anvilcraft.lib.v2.network.packet.IPacket; +import dev.anvilcraft.lib.v2.util.Util; +import io.netty.buffer.ByteBuf; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.core.BlockPos; +import net.minecraft.core.RegistryAccess; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.neoforged.neoforge.network.PacketDistributor; +import net.neoforged.neoforge.network.connection.ConnectionType; +import net.neoforged.neoforge.network.handling.IPayloadContext; +import net.neoforged.neoforge.server.ServerLifecycleHooks; +import javax.annotation.Nullable; + +import java.util.UUID; +import java.util.function.Function; + +public class SideUtil { + public static boolean isServer() { + if (Util.isServer()) { + return true; + } else if (Util.isClient()) { + return false; + } + throw new IllegalStateException("Cannot determine side: not client or server"); + } + + public static @Nullable RegistryAccess registryAccess() { + if (Util.isServer()) { + return SideUtil.serverRegistryAccess(); + } else if (Util.isClient()) { + return SideUtil.clientRegistryAccess(); + } + return null; + } + + private static @Nullable RegistryAccess clientRegistryAccess() { + Minecraft minecraft = Minecraft.getInstance(); + ClientPacketListener connection = minecraft.getConnection(); + return connection == null ? null : connection.registryAccess(); + } + + private static @Nullable RegistryAccess serverRegistryAccess() { + MinecraftServer currentServer = ServerLifecycleHooks.getCurrentServer(); + return currentServer == null ? null : currentServer.registryAccess(); + } + + public static @Nullable Entity entityFinder(IPayloadContext context, UUID uuid) { + if (context.flow().equals(PacketFlow.SERVERBOUND)) { + return SideUtil.serverEntityFinder(context.player(), uuid); + } else { + return SideUtil.clientEntityFinder(uuid); + } + } + + private static @Nullable Entity serverEntityFinder(Player player, UUID uuid) { + if (!(player instanceof ServerPlayer serverPlayer)) return null; + ServerLevel level = serverPlayer.serverLevel(); + return level.getEntity(uuid); + } + + private static @Nullable Entity clientEntityFinder(UUID uuid) { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.level == null) { + return null; + } + // 1.21.1 的 ClientLevel.getEntities() 为 protected,且无 getEntity(UUID);遍历渲染实体集合查找 + for (Entity entity : minecraft.level.entitiesForRendering()) { + if (entity.getUUID().equals(uuid)) return entity; + } + return null; + } + + public static @Nullable BlockEntity blockEntityFinder(IPayloadContext context, BlockPos blockPos) { + if (context.flow().equals(PacketFlow.SERVERBOUND)) { + return SideUtil.serverBlockEntityFinder(context.player(), blockPos); + } else { + return SideUtil.clientBlockEntityFinder(blockPos); + } + } + + private static @Nullable BlockEntity serverBlockEntityFinder(Player player, BlockPos blockPos) { + if (!(player instanceof ServerPlayer serverPlayer)) return null; + return serverPlayer.level().getBlockEntity(blockPos); + } + + private static @Nullable BlockEntity clientBlockEntityFinder(BlockPos blockPos) { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.level == null) { + return null; + } + return minecraft.level.getBlockEntity(blockPos); + } + + public static FriendlyByteBuf createFriendlyByteBuf(ByteBuf byteBuf) { + RegistryAccess registryAccess = SideUtil.registryAccess(); + FriendlyByteBuf buf; + if (registryAccess == null) { + buf = new FriendlyByteBuf(byteBuf); + } else { + buf = new RegistryFriendlyByteBuf(byteBuf, registryAccess, ConnectionType.NEOFORGE); + } + return buf; + } + + public static void send( + SyncDirection direction, + boolean dimension, + @Nullable Function> dimensionGetter, + T obj, + ID id, + Function pyload + ) { + if (Util.isServer() && direction.isCreateByServer()) { + SideUtil.serverSend(obj, id, pyload, dimension, dimensionGetter); + } else if (Util.isClient() && direction.isCreateByClient()) { + SideUtil.clientSend(pyload); + } + } + + + public static void serverSend( + T obj, + ID id, + Function pyload, + boolean dimension, + @Nullable Function> dimensionGetter + ) { + IPacket packet = pyload.apply(PacketFlow.CLIENTBOUND); + if (!dimension || dimensionGetter == null) { + PacketDistributor.sendToAllPlayers(packet); + return; + } + MinecraftServer currentServer = ServerLifecycleHooks.getCurrentServer(); + if (currentServer == null) return; + ResourceKey apply = dimensionGetter.apply(obj); + ServerLevel level = currentServer.getLevel(apply); + if (level == null) return; + if (id instanceof BlockPos blockPos) { + PacketDistributor.sendToPlayersTrackingChunk(level, new ChunkPos(blockPos), packet); + return; + } + if (obj instanceof Entity) { + PacketDistributor.sendToPlayersTrackingEntity((Entity) obj, packet); + } + PacketDistributor.sendToPlayersInDimension(level, packet); + } + + public static void clientSend(Function pyload) { + PacketDistributor.sendToServer(pyload.apply(PacketFlow.SERVERBOUND)); + } +} diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/util/SyncDirection.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/util/SyncDirection.java new file mode 100644 index 00000000..6f8ec801 --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/util/SyncDirection.java @@ -0,0 +1,17 @@ +package dev.anvilcraft.lib.v2.sync.util; + +import lombok.Getter; + +@Getter +public enum SyncDirection { + BOTH(true, true), + C2S(true, false), + S2C(false, true); + private final boolean createByClient; + private final boolean createByServer; + + SyncDirection(boolean createByClient, boolean createByServer) { + this.createByClient = createByClient; + this.createByServer = createByServer; + } +} \ No newline at end of file diff --git a/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/util/package-info.java b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/util/package-info.java new file mode 100644 index 00000000..6d866150 --- /dev/null +++ b/module.sync/src/main/java/dev/anvilcraft/lib/v2/sync/util/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.sync.util; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.sync/src/main/resources/META-INF/neoforge.mods.toml b/module.sync/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 00000000..27f7ba75 --- /dev/null +++ b/module.sync/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,86 @@ +# This is an example mods.toml file. It contains the data relating to the loading mods. +# There are several mandatory fields (#mandatory), and many more that are optional (#optional). +# The overall format is standard TOML format, v0.5.0. +# Note that there are a couple of TOML lists in this file. +# Find more information on toml format here: https://github.com/toml-lang/toml +# The name of the mod loader type to load - for regular FML @Mod mods it should be javafml +modLoader = "javafml" #mandatory +# A version range to match for said mod loader - for regular FML @Mod it will be the the FML version. This is currently 47. +loaderVersion = "${loader_version_range}" #mandatory +# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties. +# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here. +license = "${mod_license}" +# A URL to refer people to when problems occur with this mod +#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional +# A list of mods - how many allowed here is determined by the individual mod loader +[[mods]] #mandatory +# The modid of the mod +modId = "${mod_id}" #mandatory +# The version number of the mod +version = "${mod_version}" #mandatory +# A display name for the mod +displayName = "${mod_name}" #mandatory +# A URL to query for updates for this mod. See the JSON update specification https://docs.neoforge.net/docs/misc/updatechecker/ +#updateJSONURL="https://change.me.example.invalid/updates.json" #optional +# A URL for the "homepage" for this mod, displayed in the mod UI +displayURL="https://github.com/Anvil-Dev/AnvilLib" +# A file name (in the root of the mod JAR) containing a logo for display +logoFile="icon.png" #optional +# A text field displayed in the mod UI +#credits="" #optional +# A text field displayed in the mod UI +authors = "${mod_authors}" #optional +# Display Test controls the display for your mod in the server connection screen +# MATCH_VERSION means that your mod will cause a red X if the versions on client and server differ. This is the default behaviour and should be what you choose if you have server and client elements to your mod. +# IGNORE_SERVER_VERSION means that your mod will not cause a red X if it's present on the server but not on the client. This is what you should use if you're a server only mod. +# IGNORE_ALL_VERSION means that your mod will not cause a red X if it's present on the client or the server. This is a special case and should only be used if your mod has no server component. +# NONE means that no display test is set on your mod. You need to do this yourself, see IExtensionPoint.DisplayTest for more information. You can define any scheme you wish with this value. +# IMPORTANT NOTE: this is NOT an instruction as to which environments (CLIENT or DEDICATED SERVER) your mod loads on. Your mod should load (and maybe do nothing!) whereever it finds itself. +#displayTest="MATCH_VERSION" # MATCH_VERSION is the default if nothing is specified (#optional) + +# The description text for the mod (multi line!) (#mandatory) +description = '''${mod_description}''' + +# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded. +[[mixins]] +config = "${mod_id}.mixins.json" + +# The [[accessTransformers]] block allows you to declare where your AT file is. +# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg +#[[accessTransformers]] +#file="META-INF/accesstransformer.cfg" + +# The coremods config file path is not configurable and is always loaded from META-INF/coremods.json + +# A dependency - use the . to indicate dependency for a specific modid. Dependencies are optional. +[[dependencies."${mod_id}"]] #optional +# the modid of the dependency +modId = "neoforge" #mandatory +# The type of the dependency. Can be one of "required", "optional", "incompatible" or "discouraged" (case insensitive). +# 'required' requires the mod to exist, 'optional' does not +# 'incompatible' will prevent the game from loading when the mod exists, and 'discouraged' will show a warning +type = "required" #mandatory +# Optional field describing why the dependency is required or why it is incompatible +# reason="..." +# The version range of the dependency +versionRange = "${neo_version_range}" #mandatory +# An ordering relationship for the dependency. +# BEFORE - This mod is loaded BEFORE the dependency +# AFTER - This mod is loaded AFTER the dependency +ordering = "NONE" +# Side this dependency is applied on - BOTH, CLIENT, or SERVER +side = "BOTH" +# Here's another dependency +[[dependencies."${mod_id}"]] +modId = "minecraft" +type = "required" +# This version range declares a minimum of the current minecraft version up to but not including the next major version +versionRange = "${minecraft_version_range}" +ordering = "NONE" +side = "BOTH" + +# Features are specific properties of the game environment, that you may want to declare you require. This example declares +# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't +# stop your mod loading on the server for example. +#[features."${mod_id}"] +#openGLVersion="[3.2,)" diff --git a/module.sync/src/main/resources/anvillib_sync.mixins.json b/module.sync/src/main/resources/anvillib_sync.mixins.json new file mode 100644 index 00000000..bb8958e1 --- /dev/null +++ b/module.sync/src/main/resources/anvillib_sync.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "dev.anvilcraft.lib.v2.sync.mixin", + "compatibilityLevel": "JAVA_8", + "refmap": "anvillib.refmap.json", + "mixins": [ + ], + "client": [ + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/module.sync/src/main/resources/icon.png b/module.sync/src/main/resources/icon.png new file mode 100644 index 00000000..15304bd5 Binary files /dev/null and b/module.sync/src/main/resources/icon.png differ diff --git a/module.test/build.gradle b/module.test/build.gradle index c22dedb0..0d8bcca4 100644 --- a/module.test/build.gradle +++ b/module.test/build.gradle @@ -111,6 +111,8 @@ sourceSets.main.resources { srcDir 'src/generated/resources' } dependencies { implementation project(":anvillib-config-neoforge-1.21.1") implementation project(":anvillib-codec-neoforge-1.21.1") + implementation project(":anvillib-explosion-neoforge-1.21.1") + implementation project(":anvillib-rpc-neoforge-1.21.1") implementation project(":anvillib-multiblock-neoforge-1.21.1") implementation project(":anvillib-integration-neoforge-1.21.1") interfaceInjectionData(implementation project(":anvillib-moveable-entity-block-neoforge-1.21.1")) diff --git a/module.test/src/generated/resources/assets/anvillib_test/lang/en_ud.json b/module.test/src/generated/resources/assets/anvillib_test/lang/en_ud.json index 38d7e5fb..42aed1a0 100644 --- a/module.test/src/generated/resources/assets/anvillib_test/lang/en_ud.json +++ b/module.test/src/generated/resources/assets/anvillib_test/lang/en_ud.json @@ -2,6 +2,8 @@ "block.anvillib_test.test_controller": "ɹǝꞁꞁoɹʇuoƆ ʇsǝ⟘", "itemGroup.anvillib_test.tab": "qɐ⟘ ʇsǝ⟘", "key.anvillib_test.wheel_hold": "pꞁoH ꞁǝǝɥM", + "key.anvillib_test.wheel_hold_annular": "(ɹɐꞁnuuⱯ) pꞁoH ꞁǝǝɥM", "key.anvillib_test.wheel_tap": "dɐ⟘ ꞁǝǝɥM", + "key.anvillib_test.wheel_tap_annular": "(ɹɐꞁnuuⱯ) dɐ⟘ ꞁǝǝɥM", "key.categories.anvillib_test.wheel": "ꞁǝǝɥM-ʇsǝ⟘ qᴉꞀꞁᴉʌuⱯ" } \ No newline at end of file diff --git a/module.test/src/generated/resources/assets/anvillib_test/lang/en_us.json b/module.test/src/generated/resources/assets/anvillib_test/lang/en_us.json index f7050783..e73b9002 100644 --- a/module.test/src/generated/resources/assets/anvillib_test/lang/en_us.json +++ b/module.test/src/generated/resources/assets/anvillib_test/lang/en_us.json @@ -2,6 +2,8 @@ "block.anvillib_test.test_controller": "Test Controller", "itemGroup.anvillib_test.tab": "Test Tab", "key.anvillib_test.wheel_hold": "Wheel Hold", + "key.anvillib_test.wheel_hold_annular": "Wheel Hold (Annular)", "key.anvillib_test.wheel_tap": "Wheel Tap", + "key.anvillib_test.wheel_tap_annular": "Wheel Tap (Annular)", "key.categories.anvillib_test.wheel": "AnvilLib Test-Wheel" } \ No newline at end of file diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/AnvilLibTest.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/AnvilLibTest.java index 755c1e15..1880d0ee 100644 --- a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/AnvilLibTest.java +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/AnvilLibTest.java @@ -1,5 +1,6 @@ package dev.anvilcraft.lib.v2.test; +import dev.anvilcraft.lib.v2.config.ConfigManager; import dev.anvilcraft.lib.v2.registrum.Registrum; import dev.anvilcraft.lib.v2.test.multiblock.init.LibBlocks; import dev.anvilcraft.lib.v2.test.multiblock.init.LibItemGroups; @@ -13,6 +14,7 @@ public class AnvilLibTest { public static final String MOD_ID = "anvillib_test"; public static final Registrum REGISTRUM = Registrum.create(MOD_ID); + public static final AnvilLibTestConfig CONFIG = ConfigManager.register(MOD_ID, AnvilLibTestConfig::new); public AnvilLibTest(IEventBus bus, ModContainer container) { LibBlocks.init(); diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/AnvilLibTestConfig.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/AnvilLibTestConfig.java new file mode 100644 index 00000000..e5b8f72d --- /dev/null +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/AnvilLibTestConfig.java @@ -0,0 +1,43 @@ +package dev.anvilcraft.lib.v2.test; + +import com.google.gson.annotations.SerializedName; +import dev.anvilcraft.lib.v2.config.CollapsibleObject; +import dev.anvilcraft.lib.v2.config.Comment; +import dev.anvilcraft.lib.v2.config.Config; +import dev.anvilcraft.lib.v2.config.util.TranslatableEnum; + +@Config(name = AnvilLibTest.MOD_ID, group = "anvillib") +public class AnvilLibTestConfig { + public String testString = "test"; + public int testInt = 42; + public boolean testBoolean = true; + public double testDouble = 3.14; + public float testFloat = 3.14f; + public long testLong = 42L; + public short testShort = 42; + public byte testByte = 42; + @CollapsibleObject + public TestObject testObject = new TestObject(); + public TestEnum testEnum = TestEnum.TEST; + @SerializedName("testString2testString2") + @Comment("testString2testString2testString2") + public String testString2 = "test2"; + + public static class TestObject { + public String testString = "test"; + public int testInt = 42; + public boolean testBoolean = true; + public double testDouble = 3.14; + public float testFloat = 3.14f; + public long testLong = 42L; + public short testShort = 42; + public byte testByte = 42; + } + + public enum TestEnum implements TranslatableEnum { + @SerializedName("test") + TEST, + TEST2, + TEST3 + } +} diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/command/TestCommand.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/command/TestCommand.java new file mode 100644 index 00000000..705ac18a --- /dev/null +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/command/TestCommand.java @@ -0,0 +1,51 @@ +package dev.anvilcraft.lib.v2.test.command; + +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.context.CommandContext; +import dev.anvilcraft.lib.v2.explosion.ExplosionExecutor; +import dev.anvilcraft.lib.v2.test.AnvilLibTest; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.core.BlockPos; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.RegisterCommandsEvent; + +@EventBusSubscriber(modid = AnvilLibTest.MOD_ID) +public class TestCommand { + @SubscribeEvent + public static void register(RegisterCommandsEvent event) { + event.getDispatcher().register( + Commands.literal("anvillib").then( + Commands.literal("test").then( + Commands.literal("explosion") + .then( + Commands.argument("radius", IntegerArgumentType.integer(1, 8192)).then( + Commands.argument("breaksPerTick", IntegerArgumentType.integer(1, 8192)).then( + Commands.argument("probabilityRadius", IntegerArgumentType.integer(1, 8192)).then( + Commands.argument("meltingRadius", IntegerArgumentType.integer(1, 8192)).executes(TestCommand::explosionAdvanced) + ) + ) + ) + ) + ) + ) + ); + } + + public static int explosionAdvanced(CommandContext context) { + int radius = IntegerArgumentType.getInteger(context, "radius"); + int breaksPerTick = IntegerArgumentType.getInteger(context, "breaksPerTick"); + int probabilityRadius = IntegerArgumentType.getInteger(context, "probabilityRadius"); + int meltingRadius = IntegerArgumentType.getInteger(context, "meltingRadius"); + + ExplosionExecutor.create() + .radius(radius) + .maxBreakPreTick(breaksPerTick) + .probabilityRadius(probabilityRadius) + .meltingRadius(meltingRadius) + .executor(context.getSource().getEntity()) + .execute(context.getSource().getLevel(), BlockPos.containing(context.getSource().getPosition())); + return 1; + } +} diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/command/package-info.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/command/package-info.java new file mode 100644 index 00000000..787ed01c --- /dev/null +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/command/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.test.command; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/mixin/package-info.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/mixin/package-info.java index 278ef4ed..b9fd8b43 100644 --- a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/mixin/package-info.java +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/mixin/package-info.java @@ -1,4 +1,9 @@ -@NullMarked +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.test.mixin; -import org.jspecify.annotations.NullMarked; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/multiblock/block/package-info.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/multiblock/block/package-info.java index fb443b2f..4d475d12 100644 --- a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/multiblock/block/package-info.java +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/multiblock/block/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.test.multiblock.block; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/multiblock/init/package-info.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/multiblock/init/package-info.java index dd0ee18b..280fd357 100644 --- a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/multiblock/init/package-info.java +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/multiblock/init/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.test.multiblock.init; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/package-info.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/package-info.java index 56c74823..ac8ff0d9 100644 --- a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/package-info.java +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.test; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/RpcClientTest.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/RpcClientTest.java new file mode 100644 index 00000000..2a3216cf --- /dev/null +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/RpcClientTest.java @@ -0,0 +1,72 @@ +package dev.anvilcraft.lib.v2.test.rpc; + +import dev.anvilcraft.lib.v2.rpc.RPC; +import dev.anvilcraft.lib.v2.rpc.RpcTarget; +import net.minecraft.client.player.LocalPlayer; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * RPC 客户端测试。 + * + *

    测试客户端到服务端的远程调用,以及客户端接收来自服务端的调用。

    + */ +@EventBusSubscriber(modid = "anvillib_test", value = Dist.CLIENT) +public class RpcClientTest { + private static final Logger LOGGER = LoggerFactory.getLogger(RpcClientTest.class); + private static final boolean ENABLE_AUTO_TEST = Boolean.getBoolean("anvillib.test.rpc.client.auto"); + + @SubscribeEvent + public static void onClientPlayerLogin(ClientPlayerNetworkEvent.LoggingIn event) { + if (!ENABLE_AUTO_TEST) return; + + LocalPlayer player = event.getPlayer(); + + LOGGER.info("Starting client-side RPC test for player: {}", player.getName().getString()); + + // 延迟 20 tick(约 1 秒)执行,确保连接完全建立。不能用 Thread.sleep 占住客户端主线程—— + // 那会冻结画面,且会阻塞兑现 RPC future 的同一个线程。改用 tick 调度器非阻塞延迟。 + RpcTestScheduler.onClient(20, RpcClientTest::testClientToServer); + } + + private static void testClientToServer() { + LOGGER.info("Testing client -> server RPC calls..."); + RpcTarget serverTarget = RpcTarget.server(); + + // 测试无参调用 + RPC.call(serverTarget, TestRpcMethods::noArgs); + LOGGER.info("Sent noArgs call to server"); + + // 测试带参数调用 + RPC.call(serverTarget, TestRpcMethods::withString, "Hello from client"); + LOGGER.info("Sent withString call to server"); + + // 测试有返回值的调用 - 不能在主线程 future.get(),改用回调 + RPC.invoke(serverTarget, TestRpcMethods::returnString) + .thenAccept(result -> LOGGER.info("Received result from server: {}", result)) + .exceptionally(ex -> { + LOGGER.error("client -> server returnString failed", ex); + return null; + }); + + // 测试计算调用 + RPC.invoke(serverTarget, TestRpcMethods::computeSum, 15, 27) + .thenAccept(sum -> LOGGER.info("Server computed sum: {}", sum)) + .exceptionally(ex -> { + LOGGER.error("client -> server computeSum failed", ex); + return null; + }); + + LOGGER.info("Client -> Server RPC test dispatched (results logged asynchronously)"); + } + + @SubscribeEvent + public static void onClientPlayerLogout(ClientPlayerNetworkEvent.LoggingOut event) { + if (!ENABLE_AUTO_TEST) return; + LOGGER.info("Client RPC test cleanup on logout"); + } +} diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/RpcIntegrationTest.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/RpcIntegrationTest.java new file mode 100644 index 00000000..b27ec608 --- /dev/null +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/RpcIntegrationTest.java @@ -0,0 +1,246 @@ +package dev.anvilcraft.lib.v2.test.rpc; + +import dev.anvilcraft.lib.v2.rpc.RPC; +import dev.anvilcraft.lib.v2.rpc.RpcTarget; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.function.Function; + +/** + * RPC 模块集成测试。 + * + *

    测试在玩家登录时自动执行,覆盖 RPC 的各项功能:

    + *
      + *
    • 基础类型参数的序列化与调用
    • + *
    • 多参数方法调用
    • + *
    • 自定义编解码器
    • + *
    • 有返回值的远程调用(invoke)
    • + *
    • 校验器功能
    • + *
    • 异常处理
    • + *
    • 超时机制
    • + *
    + */ +@EventBusSubscriber(modid = "anvillib_test") +public class RpcIntegrationTest { + private static final Logger LOGGER = LoggerFactory.getLogger(RpcIntegrationTest.class); + private static final boolean ENABLE_AUTO_TEST = Boolean.getBoolean("anvillib.test.rpc.auto"); + + @SubscribeEvent + public static void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) { + if (!ENABLE_AUTO_TEST) return; + if (!(event.getEntity() instanceof ServerPlayer player)) return; + + LOGGER.info("Starting RPC integration test for player: {}", player.getName().getString()); + TestRpcMethods.clearLog(); + + try { + testBasicCalls(player); + testMultipleParams(player); + testCustomCodecs(player); + testReturnValues(player); + testValidators(player); + testExceptions(player); + testEdgeCases(player); + + LOGGER.info("RPC integration test completed successfully. Total calls: {}", + TestRpcMethods.getInvocationCounter().get()); + LOGGER.info("Call log: {}", TestRpcMethods.getCallLog()); + } catch (Exception e) { + LOGGER.error("RPC integration test failed", e); + } + } + + private static void testBasicCalls(ServerPlayer player) { + LOGGER.info("Testing basic RPC calls..."); + RpcTarget target = RpcTarget.player(player); + + RPC.call(target, TestRpcMethods::noArgs); + RPC.call(target, TestRpcMethods::withInt, 42); + RPC.call(target, TestRpcMethods::withLong, 9999999999L); + RPC.call(target, TestRpcMethods::withFloat, 3.14f); + RPC.call(target, TestRpcMethods::withDouble, 2.718281828); + RPC.call(target, TestRpcMethods::withBoolean, true); + RPC.call(target, TestRpcMethods::withByte, (byte) 127); + RPC.call(target, TestRpcMethods::withShort, (short) 32000); + RPC.call(target, TestRpcMethods::withString, "Hello RPC"); + RPC.call(target, TestRpcMethods::withBoxedInt, 100); + RPC.call(target, TestRpcMethods::withBoxedBoolean, false); + RPC.call(target, TestRpcMethods::withByteArray, new byte[]{1, 2, 3, 4, 5}); + RPC.call(target, TestRpcMethods::withLongArray, new long[]{100L, 200L, 300L}); + + LOGGER.info("Basic calls test passed"); + } + + private static void testMultipleParams(ServerPlayer player) { + LOGGER.info("Testing multiple parameter methods..."); + RpcTarget target = RpcTarget.player(player); + + RPC.call(target, TestRpcMethods::twoParams, "test", 5); + RPC.call(target, TestRpcMethods::threeParams, 1, "two", true); + RPC.call(target, TestRpcMethods::fourParams, 1, 2, 3, 4); + RPC.call(target, TestRpcMethods::fiveParams, 1, 2, 3, 4, 5); + RPC.call(target, TestRpcMethods::manyParams, 1, 2, 3, 4, 5, 6, 7, 8); + + LOGGER.info("Multiple params test passed"); + } + + private static void testCustomCodecs(ServerPlayer player) { + LOGGER.info("Testing custom codecs..."); + RpcTarget target = RpcTarget.player(player); + + CompoundTag tag = new CompoundTag(); + tag.putString("key1", "value1"); + tag.putInt("key2", 42); + RPC.call(target, TestRpcMethods::withCompoundTag, tag); + + BlockPos pos = new BlockPos(100, 64, -200); + RPC.call(target, TestRpcMethods::withCustomCodec, pos); + RPC.call(target, TestRpcMethods::withMixedCodecs, "location", pos, 999); + + LOGGER.info("Custom codecs test passed"); + } + + private static void testReturnValues(ServerPlayer player) { + LOGGER.info("Testing methods with return values..."); + RpcTarget target = RpcTarget.player(player); + + // 不能在主线程上 future.get():RPC 响应也在主线程兑现,阻塞会导致必然超时。改用回调校验。 + RPC.invoke(target, TestRpcMethods::returnInt) + .thenAccept(result -> check("returnInt", result == 42, "Expected 42, got " + result)) + .exceptionally(logFailure("returnInt")); + + RPC.invoke(target, TestRpcMethods::returnString) + .thenAccept(result -> check("returnString", "test result".equals(result), "Expected 'test result', got " + result)) + .exceptionally(logFailure("returnString")); + + RPC.invoke(target, TestRpcMethods::returnBoolean) + .thenAccept(result -> check("returnBoolean", result, "Expected true, got false")) + .exceptionally(logFailure("returnBoolean")); + + RPC.invoke(target, TestRpcMethods::computeSum, 10, 32) + .thenAccept(result -> check("computeSum", result == 42, "Expected 42, got " + result)) + .exceptionally(logFailure("computeSum")); + + RPC.invoke(target, TestRpcMethods::concatenate, "Hello", "World") + .thenAccept(result -> check("concatenate", "HelloWorld".equals(result), "Expected 'HelloWorld', got " + result)) + .exceptionally(logFailure("concatenate")); + + RPC.invoke(target, TestRpcMethods::returnByteArray, 10) + .thenAccept(result -> check("returnByteArray", result.length == 10, "Expected array of length 10, got " + result.length)) + .exceptionally(logFailure("returnByteArray")); + + RPC.invoke(target, TestRpcMethods::returnCustomType, 1, 2, 3) + .thenAccept(result -> check("returnCustomType", result.equals(new BlockPos(1, 2, 3)), "Expected BlockPos(1,2,3), got " + result)) + .exceptionally(logFailure("returnCustomType")); + + LOGGER.info("Return values test dispatched (results verified asynchronously)"); + } + + private static void testValidators(ServerPlayer player) { + LOGGER.info("Testing validators..."); + RpcTarget target = RpcTarget.player(player); + + // 始终拒绝的校验器 - 单向调用会静默丢弃 + RPC.call(target, TestRpcMethods::alwaysRejected); + + // 条件校验器 - 正值应通过 + RPC.call(target, TestRpcMethods::conditionalAccept, 100); + + // 条件校验器 - 负值应被拒绝 + RPC.call(target, TestRpcMethods::conditionalAccept, -1); + + // 有返回值的调用 - 正值应通过 + RPC.invoke(target, TestRpcMethods::conditionalReturn, 50) + .thenAccept(result -> check("conditionalReturn(50)", result == 100, "Expected 100, got " + result)) + .exceptionally(logFailure("conditionalReturn(50)")); + + // 有返回值的调用 - 负值应被校验器拒绝,future 以异常失败 + RPC.invoke(target, TestRpcMethods::conditionalReturn, -10) + .handle((result, ex) -> { + if (ex == null) { + LOGGER.error("Validator test FAILED: conditionalReturn(-10) should have been rejected, but returned {}", result); + } else { + LOGGER.info("Validator correctly rejected negative value"); + } + return null; + }); + + LOGGER.info("Validators test dispatched (results verified asynchronously)"); + } + + private static void testExceptions(ServerPlayer player) { + LOGGER.info("Testing exception handling..."); + RpcTarget target = RpcTarget.player(player); + + // 单向调用抛出异常 - 应在接收端记录但不影响发送端 + RPC.call(target, TestRpcMethods::throwsException); + + // 有返回值的调用抛出异常 - future 应完成为异常 + RPC.invoke(target, TestRpcMethods::throwsExceptionWithReturn) + .handle((result, ex) -> { + if (ex == null) { + LOGGER.error("Exception test FAILED: expected exception, but call returned {}", result); + } else { + LOGGER.info("Exception correctly propagated from remote call"); + } + return null; + }); + + LOGGER.info("Exception handling test dispatched (results verified asynchronously)"); + } + + private static void testEdgeCases(ServerPlayer player) { + LOGGER.info("Testing edge cases..."); + RpcTarget target = RpcTarget.player(player); + + // 极值测试 + RPC.call(target, TestRpcMethods::extremeValues, Integer.MAX_VALUE, Long.MAX_VALUE, Double.MAX_VALUE); + RPC.call(target, TestRpcMethods::extremeValues, Integer.MIN_VALUE, Long.MIN_VALUE, Double.MIN_VALUE); + RPC.call(target, TestRpcMethods::extremeValues, 0, 0L, 0.0); + + // 空字符串 + RPC.call(target, TestRpcMethods::emptyString, ""); + + // 特殊字符 + RPC.call(target, TestRpcMethods::withString, "Unicode: 中文 测试"); + RPC.call(target, TestRpcMethods::withString, "Symbols: !@#$%^&*()_+-={}[]|\\:\";<>?,./"); + + // 空数组 + RPC.call(target, TestRpcMethods::withByteArray, new byte[0]); + RPC.call(target, TestRpcMethods::withLongArray, new long[0]); + + // 大数组 + RPC.call(target, TestRpcMethods::withByteArray, new byte[1000]); + + LOGGER.info("Edge cases test passed"); + } + + /** + * 异步校验断言:条件不满足时记录错误日志(而非用默认关闭的 {@code assert},否则会静默跳过)。 + */ + private static void check(String name, boolean condition, String message) { + if (condition) { + LOGGER.info("Assertion passed: {}", name); + } else { + LOGGER.error("Assertion FAILED [{}]: {}", name, message); + } + } + + /** + * future 异常处理器:记录失败并吞掉异常,避免污染后续回调链。 + */ + private static Function logFailure(String name) { + return ex -> { + LOGGER.error("RPC call FAILED [{}]: {}", name, ex.getMessage(), ex); + return null; + }; + } +} diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/RpcTestCommands.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/RpcTestCommands.java new file mode 100644 index 00000000..b6735183 --- /dev/null +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/RpcTestCommands.java @@ -0,0 +1,285 @@ +package dev.anvilcraft.lib.v2.test.rpc; + +import dev.anvilcraft.lib.v2.rpc.RPC; +import dev.anvilcraft.lib.v2.rpc.RpcTarget; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.RegisterCommandsEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CompletableFuture; + +/** + * RPC 测试命令。 + * + *

    提供手动触发 RPC 测试的命令:

    + *
      + *
    • /rpctest basic - 基础调用测试
    • + *
    • /rpctest invoke - 有返回值调用测试
    • + *
    • /rpctest validator - 校验器测试
    • + *
    • /rpctest stress - 压力测试
    • + *
    • /rpctest all - 运行所有测试
    • + *
    + */ +@EventBusSubscriber(modid = "anvillib_test") +public class RpcTestCommands { + private static final Logger LOGGER = LoggerFactory.getLogger(RpcTestCommands.class); + + @SubscribeEvent + public static void registerCommands(RegisterCommandsEvent event) { + event.getDispatcher().register( + Commands.literal("rpctest") + .then(Commands.literal("basic") + .executes(context -> { + runBasicTest(context.getSource()); + return 1; + }) + ) + .then(Commands.literal("invoke") + .executes(context -> { + runInvokeTest(context.getSource()); + return 1; + }) + ) + .then(Commands.literal("validator") + .executes(context -> { + runValidatorTest(context.getSource()); + return 1; + }) + ) + .then(Commands.literal("stress") + .executes(context -> { + runStressTest(context.getSource()); + return 1; + }) + ) + .then(Commands.literal("all") + .executes(context -> { + runAllTests(context.getSource()); + return 1; + }) + ) + .then(Commands.literal("log") + .executes(context -> { + showLog(context.getSource()); + return 1; + }) + ) + .then(Commands.literal("clear") + .executes(context -> { + TestRpcMethods.clearLog(); + context.getSource().sendSuccess(() -> Component.literal("RPC test log cleared"), true); + return 1; + }) + ) + ); + } + + private static void runBasicTest(CommandSourceStack source) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("This command must be run by a player")); + return; + } + + source.sendSuccess(() -> Component.literal("Running basic RPC test..."), true); + TestRpcMethods.clearLog(); + RpcTarget target = RpcTarget.player(player); + + try { + RPC.call(target, TestRpcMethods::noArgs); + RPC.call(target, TestRpcMethods::withInt, 42); + RPC.call(target, TestRpcMethods::withString, "test"); + RPC.call(target, TestRpcMethods::twoParams, "hello", 123); + + CompoundTag tag = new CompoundTag(); + tag.putString("test", "value"); + RPC.call(target, TestRpcMethods::withCompoundTag, tag); + + BlockPos pos = new BlockPos(100, 64, -200); + RPC.call(target, TestRpcMethods::withCustomCodec, pos); + + source.sendSuccess(() -> Component.literal("Basic test completed. Sent 6 calls."), true); + LOGGER.info("Basic test completed. Invocations: {}", TestRpcMethods.getInvocationCounter().get()); + } catch (Exception e) { + source.sendFailure(Component.literal("Basic test failed: " + e.getMessage())); + LOGGER.error("Basic test failed", e); + } + } + + private static void runInvokeTest(CommandSourceStack source) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("This command must be run by a player")); + return; + } + + source.sendSuccess(() -> Component.literal("Running invoke RPC test..."), true); + TestRpcMethods.clearLog(); + RpcTarget target = RpcTarget.player(player); + + try { + CompletableFuture futureInt = RPC.invoke(target, TestRpcMethods::returnInt); + futureInt.thenAccept(result -> source.sendSuccess(() -> Component.literal("Received int: " + result), false)); + + CompletableFuture futureString = RPC.invoke(target, TestRpcMethods::returnString); + futureString.thenAccept(result -> source.sendSuccess(() -> Component.literal("Received string: " + result), false)); + + CompletableFuture futureSum = RPC.invoke(target, TestRpcMethods::computeSum, 10, 32); + futureSum.thenAccept(result -> source.sendSuccess(() -> Component.literal("Computed sum: " + result), false)); + + CompletableFuture futurePos = RPC.invoke(target, TestRpcMethods::returnCustomType, 5, 10, 15); + futurePos.thenAccept(result -> source.sendSuccess(() -> Component.literal("Received BlockPos: " + result), false)); + + source.sendSuccess(() -> Component.literal("Invoke test started. Check results above."), true); + } catch (Exception e) { + source.sendFailure(Component.literal("Invoke test failed: " + e.getMessage())); + LOGGER.error("Invoke test failed", e); + } + } + + private static void runValidatorTest(CommandSourceStack source) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("This command must be run by a player")); + return; + } + + source.sendSuccess(() -> Component.literal("Running validator RPC test..."), true); + TestRpcMethods.clearLog(); + RpcTarget target = RpcTarget.player(player); + + try { + // 这个调用会被拒绝 + RPC.call(target, TestRpcMethods::alwaysRejected); + source.sendSuccess(() -> Component.literal("Sent alwaysRejected (should be rejected)"), false); + + // 正值应该通过 + RPC.call(target, TestRpcMethods::conditionalAccept, 100); + source.sendSuccess(() -> Component.literal("Sent conditionalAccept(100) (should pass)"), false); + + // 负值应该被拒绝 + RPC.call(target, TestRpcMethods::conditionalAccept, -10); + source.sendSuccess(() -> Component.literal("Sent conditionalAccept(-10) (should be rejected)"), false); + + // 有返回值的正值调用 + CompletableFuture futureAccept = RPC.invoke(target, TestRpcMethods::conditionalReturn, 50); + futureAccept + .thenAccept(result -> source.sendSuccess(() -> Component.literal("conditionalReturn(50) = " + result), false)) + .exceptionally(ex -> { + source.sendFailure(Component.literal("conditionalReturn(50) failed: " + ex.getMessage())); + return null; + }); + + // 有返回值的负值调用 + CompletableFuture futureReject = RPC.invoke(target, TestRpcMethods::conditionalReturn, -5); + futureReject.thenAccept(result -> + source.sendFailure(Component.literal("conditionalReturn(-5) should have been rejected!")) + ).exceptionally(ex -> { + source.sendSuccess(() -> Component.literal("conditionalReturn(-5) correctly rejected"), false); + return null; + }); + + source.sendSuccess(() -> Component.literal("Validator test completed."), true); + } catch (Exception e) { + source.sendFailure(Component.literal("Validator test failed: " + e.getMessage())); + LOGGER.error("Validator test failed", e); + } + } + + private static void runStressTest(CommandSourceStack source) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("This command must be run by a player")); + return; + } + + source.sendSuccess(() -> Component.literal("Running stress RPC test..."), true); + TestRpcMethods.clearLog(); + RpcTarget target = RpcTarget.player(player); + + try { + long startTime = System.currentTimeMillis(); + int callCount = 100; + + // 快速发送大量调用 + for (int i = 0; i < callCount; i++) { + RPC.call(target, TestRpcMethods::withInt, i); + } + + // 发送大量有返回值的调用 + for (int i = 0; i < 20; i++) { + RPC.invoke(target, TestRpcMethods::computeSum, i, i + 1); + } + + // 发送大数据 + byte[] largeArray = new byte[5000]; + for (int i = 0; i < 10; i++) { + RPC.call(target, TestRpcMethods::withByteArray, largeArray); + } + + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + + source.sendSuccess(() -> Component.literal( + String.format("Stress test completed: %d calls in %d ms", callCount + 30, duration) + ), true); + LOGGER.info("Stress test: {} calls in {} ms", callCount + 30, duration); + } catch (Exception e) { + source.sendFailure(Component.literal("Stress test failed: " + e.getMessage())); + LOGGER.error("Stress test failed", e); + } + } + + private static void runAllTests(CommandSourceStack source) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("This command must be run by a player")); + return; + } + + source.sendSuccess(() -> Component.literal("Running all RPC tests..."), true); + + scheduleTest(0, () -> runBasicTest(source)); + scheduleTest(20, () -> runInvokeTest(source)); + scheduleTest(40, () -> runValidatorTest(source)); + scheduleTest(60, () -> runStressTest(source)); + scheduleTest(80, () -> + source.sendSuccess(() -> Component.literal("All tests completed!"), true) + ); + } + + private static void scheduleTest(int delayTicks, Runnable test) { + // 非阻塞延迟:挂到服务端 tick 上逐 tick 递减,到点后在主线程执行。 + // 不能用 Thread.sleep 占住主线程——那会冻结服务端,且会阻塞兑现 RPC future 的同一个线程。 + RpcTestScheduler.onServer(delayTicks, test); + } + + private static void showLog(CommandSourceStack source) { + source.sendSuccess(() -> Component.literal("=== RPC Call Log ==="), false); + source.sendSuccess(() -> Component.literal( + "Total invocations: " + TestRpcMethods.getInvocationCounter().get() + ), false); + + var log = TestRpcMethods.getCallLog(); + if (log.isEmpty()) { + source.sendSuccess(() -> Component.literal("(empty)"), false); + } else { + int count = 0; + for (String entry : log) { + if (count >= 20) { + source.sendSuccess(() -> Component.literal( + "... and " + (log.size() - 20) + " more entries" + ), false); + break; + } + final String finalEntry = entry; + source.sendSuccess(() -> Component.literal(" " + finalEntry), false); + count++; + } + } + source.sendSuccess(() -> Component.literal("==================="), false); + } +} diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/RpcTestScheduler.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/RpcTestScheduler.java new file mode 100644 index 00000000..4cab7f8d --- /dev/null +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/RpcTestScheduler.java @@ -0,0 +1,77 @@ +package dev.anvilcraft.lib.v2.test.rpc; + +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Iterator; + +/** + * RPC 测试用的非阻塞延迟调度器。 + * + *

    RPC 的响应在主线程通过 {@code ctx.enqueueWork(...)} 兑现,超时也由主线程的 tick 事件驱动。 + * 因此测试代码绝不能在主线程上 {@code Thread.sleep} 或 {@code future.get()} 阻塞——那会冻结游戏, + * 并且会阻塞兑现 future 的同一个线程,导致 invoke 调用必然超时。本调度器将延迟任务挂到对应侧的 + * tick 事件上逐 tick 递减,到点后在主线程执行,全程不阻塞。

    + */ +public final class RpcTestScheduler { + private RpcTestScheduler() { + } + + private record DelayedTask(int delayTicks, Runnable action) { + DelayedTask withRemaining(int remaining) { + return new DelayedTask(remaining, this.action); + } + } + + private static final Deque SERVER_TASKS = new ArrayDeque<>(); + private static final Deque CLIENT_TASKS = new ArrayDeque<>(); + + /** + * 在服务端主线程延迟 {@code delayTicks} 个 tick 后执行 {@code action}。{@code delayTicks <= 0} 时下一 tick 执行。 + */ + public static void onServer(int delayTicks, Runnable action) { + SERVER_TASKS.add(new DelayedTask(Math.max(0, delayTicks), action)); + } + + /** + * 在客户端主线程延迟 {@code delayTicks} 个 tick 后执行 {@code action}。{@code delayTicks <= 0} 时下一 tick 执行。 + */ + public static void onClient(int delayTicks, Runnable action) { + CLIENT_TASKS.add(new DelayedTask(Math.max(0, delayTicks), action)); + } + + private static void pump(Deque tasks) { + // 先快照本 tick 要处理的项数,避免任务自身再调度 onServer/onClient 时本 tick 重复处理 + int count = tasks.size(); + for (int i = 0; i < count; i++) { + DelayedTask task = tasks.poll(); + if (task == null) break; + if (task.delayTicks() <= 0) { + task.action().run(); + } else { + tasks.add(task.withRemaining(task.delayTicks() - 1)); + } + } + } + + @EventBusSubscriber(modid = "anvillib_test") + public static final class Server { + @SubscribeEvent + public static void onServerTick(ServerTickEvent.Post event) { + pump(SERVER_TASKS); + } + } + + @EventBusSubscriber(modid = "anvillib_test", value = Dist.CLIENT) + public static final class Client { + @SubscribeEvent + public static void onClientTick(ClientTickEvent.Post event) { + pump(CLIENT_TASKS); + } + } +} diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/TestRpcMethods.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/TestRpcMethods.java new file mode 100644 index 00000000..69d0d33f --- /dev/null +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/TestRpcMethods.java @@ -0,0 +1,317 @@ +package dev.anvilcraft.lib.v2.test.rpc; + +import dev.anvilcraft.lib.v2.rpc.CallableParam; +import dev.anvilcraft.lib.v2.rpc.IRemoteCallableValidator; +import dev.anvilcraft.lib.v2.rpc.RemoteCallable; +import lombok.Getter; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.neoforged.neoforge.network.handling.IPayloadContext; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 用于测试 RPC 功能的远程可调用方法集合。 + */ +public final class TestRpcMethods { + @Getter + private static final List callLog = new CopyOnWriteArrayList<>(); + @Getter + private static final AtomicInteger invocationCounter = new AtomicInteger(0); + + private TestRpcMethods() { + } + + public static void clearLog() { + callLog.clear(); + invocationCounter.set(0); + } + + // ============ 无参方法 ============ + + @RemoteCallable + public static void noArgs() { + callLog.add("noArgs"); + invocationCounter.incrementAndGet(); + } + + // ============ 单参方法 - 基础类型 ============ + + @RemoteCallable + public static void withInt(int value) { + callLog.add("withInt:" + value); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void withLong(long value) { + callLog.add("withLong:" + value); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void withFloat(float value) { + callLog.add("withFloat:" + value); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void withDouble(double value) { + callLog.add("withDouble:" + value); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void withBoolean(boolean value) { + callLog.add("withBoolean:" + value); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void withByte(byte value) { + callLog.add("withByte:" + value); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void withShort(short value) { + callLog.add("withShort:" + value); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void withString(String value) { + callLog.add("withString:" + value); + invocationCounter.incrementAndGet(); + } + + // ============ 包装类型 ============ + + @RemoteCallable + public static void withBoxedInt(Integer value) { + callLog.add("withBoxedInt:" + value); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void withBoxedBoolean(Boolean value) { + callLog.add("withBoxedBoolean:" + value); + invocationCounter.incrementAndGet(); + } + + // ============ 数组类型 ============ + + @RemoteCallable + public static void withByteArray(byte[] value) { + callLog.add("withByteArray:" + value.length); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void withLongArray(long[] value) { + callLog.add("withLongArray:" + value.length); + invocationCounter.incrementAndGet(); + } + + // ============ 多参方法 ============ + + @RemoteCallable + public static void twoParams(String name, int count) { + callLog.add("twoParams:" + name + "," + count); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void threeParams(int a, String b, boolean c) { + callLog.add("threeParams:" + a + "," + b + "," + c); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void fourParams(int a, int b, int c, int d) { + callLog.add("fourParams:" + a + "," + b + "," + c + "," + d); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void fiveParams(int a, int b, int c, int d, int e) { + callLog.add("fiveParams:" + a + "," + b + "," + c + "," + d + "," + e); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void manyParams(int a, int b, int c, int d, int e, int f, int g, int h) { + callLog.add("manyParams:" + a + "," + b + "," + c + "," + d + "," + e + "," + f + "," + g + "," + h); + invocationCounter.incrementAndGet(); + } + + // ============ 自定义编解码器 ============ + + @RemoteCallable + public static void withCompoundTag(CompoundTag tag) { + callLog.add("withCompoundTag:" + tag.size()); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void withCustomCodec( + @CallableParam(clazz = TestCodecs.class, field = "BLOCK_POS") BlockPos pos + ) { + callLog.add("withCustomCodec:" + pos.getX() + "," + pos.getY() + "," + pos.getZ()); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void withMixedCodecs( + String name, + @CallableParam(clazz = TestCodecs.class, field = "BLOCK_POS") BlockPos pos, + int value + ) { + callLog.add("withMixedCodecs:" + name + "," + pos + "," + value); + invocationCounter.incrementAndGet(); + } + + // ============ 有返回值的方法 ============ + + @RemoteCallable + public static int returnInt() { + callLog.add("returnInt"); + invocationCounter.incrementAndGet(); + return 42; + } + + @RemoteCallable + public static String returnString() { + callLog.add("returnString"); + invocationCounter.incrementAndGet(); + return "test result"; + } + + @RemoteCallable + public static boolean returnBoolean() { + callLog.add("returnBoolean"); + invocationCounter.incrementAndGet(); + return true; + } + + @RemoteCallable + public static int computeSum(int a, int b) { + callLog.add("computeSum:" + a + "," + b); + invocationCounter.incrementAndGet(); + return a + b; + } + + @RemoteCallable + public static String concatenate(String a, String b) { + callLog.add("concatenate:" + a + "," + b); + invocationCounter.incrementAndGet(); + return a + b; + } + + @RemoteCallable + public static byte[] returnByteArray(int size) { + callLog.add("returnByteArray:" + size); + invocationCounter.incrementAndGet(); + return new byte[size]; + } + + @RemoteCallable + @CallableParam(clazz = TestCodecs.class, field = "BLOCK_POS") + public static BlockPos returnCustomType(int x, int y, int z) { + callLog.add("returnCustomType:" + x + "," + y + "," + z); + invocationCounter.incrementAndGet(); + return new BlockPos(x, y, z); + } + + // ============ 异常情况 ============ + + @RemoteCallable + public static void throwsException() { + callLog.add("throwsException"); + invocationCounter.incrementAndGet(); + throw new RuntimeException("Intentional test exception"); + } + + @RemoteCallable + public static int throwsExceptionWithReturn() { + callLog.add("throwsExceptionWithReturn"); + invocationCounter.incrementAndGet(); + throw new IllegalArgumentException("Cannot compute result"); + } + + // ============ 带校验器的方法 ============ + + @RemoteCallable(validator = AlwaysRejectValidator.class) + public static void alwaysRejected() { + callLog.add("alwaysRejected"); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable(validator = ConditionalValidator.class) + public static void conditionalAccept(int value) { + callLog.add("conditionalAccept:" + value); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable(validator = ConditionalValidator.class) + public static int conditionalReturn(int value) { + callLog.add("conditionalReturn:" + value); + invocationCounter.incrementAndGet(); + return value * 2; + } + + // ============ 边界值测试 ============ + + @RemoteCallable + public static void extremeValues(int maxInt, long maxLong, double maxDouble) { + callLog.add("extremeValues:" + maxInt + "," + maxLong + "," + maxDouble); + invocationCounter.incrementAndGet(); + } + + @RemoteCallable + public static void emptyString(String empty) { + callLog.add("emptyString:'" + empty + "'"); + invocationCounter.incrementAndGet(); + } + + // ============ 校验器实现 ============ + + public static class AlwaysRejectValidator implements IRemoteCallableValidator { + @Override + public boolean validate(IPayloadContext ctx, Method method, Object[] args) { + return false; + } + } + + public static class ConditionalValidator implements IRemoteCallableValidator { + @Override + public boolean validate(IPayloadContext ctx, Method method, Object[] args) { + if (args.length == 0) return true; + if (args[0] instanceof Integer value) { + return value >= 0; + } + return true; + } + } + + // ============ 自定义编解码器 ============ + + public static class TestCodecs { + public static final StreamCodec BLOCK_POS = + StreamCodec.composite( + ByteBufCodecs.VAR_INT, + BlockPos::getX, + ByteBufCodecs.VAR_INT, + BlockPos::getY, + ByteBufCodecs.VAR_INT, + BlockPos::getZ, + BlockPos::new + ); + } +} diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/package-info.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/package-info.java new file mode 100644 index 00000000..350eadb0 --- /dev/null +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/rpc/package-info.java @@ -0,0 +1,9 @@ +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package dev.anvilcraft.lib.v2.test.rpc; + +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/WheelDemoMenus.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/WheelDemoMenus.java index 8712a08c..cfd7386c 100644 --- a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/WheelDemoMenus.java +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/WheelDemoMenus.java @@ -1,35 +1,118 @@ package dev.anvilcraft.lib.v2.test.wheel; +import com.mojang.blaze3d.vertex.PoseStack; import dev.anvilcraft.lib.v2.wheel.api.WheelMenuBuilder; import dev.anvilcraft.lib.v2.wheel.api.WheelMenuModel; +import dev.anvilcraft.lib.v2.wheel.api.WheelSelectionEffect; +import net.minecraft.client.gui.GuiGraphics; import net.minecraft.network.chat.Component; +import net.minecraft.world.item.Items; public final class WheelDemoMenus { private WheelDemoMenus() { } - public static WheelMenuModel buildTapDemo(int slotsPerPage) { + public static WheelMenuModel buildTapDemo(int slotsPerPage, WheelSelectionEffect selectionEffect) { return WheelMenuBuilder.create() .slotsPerPage(slotsPerPage) - .action("action_1", Component.literal("Action 1"), ctx -> {}) - .action("action_2", Component.literal("Action 2"), ctx -> {}) - .submenu("tools", Component.literal("Tools"), submenu -> submenu - .action("tool_a", Component.literal("Tool A"), ctx -> {}) - .action("tool_b", Component.literal("Tool B"), ctx -> {}) + .selectionEffect(selectionEffect) + .action( + "action_1", Component.literal("Action 1"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "action_2", Component.literal("Action 2"), WheelDemoMenus::render, ctx -> { + } + ) + .submenu( + "tools", Component.literal("Tools"), WheelDemoMenus::render, submenu -> submenu + .action( + "tool_a", Component.literal("Tool A"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "tool_b", Component.literal("Tool B"), WheelDemoMenus::render, ctx -> { + } + ) + ) + .action( + "action_3", Component.literal("Action 3"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "action_4", Component.literal("Action 4"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "action_5", Component.literal("Action 5"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "action_6", Component.literal("Action 6"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "action_7", Component.literal("Action 7"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "action_8", Component.literal("Action 8"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "action_9", Component.literal("Action 9"), WheelDemoMenus::render, ctx -> { + } ) - .action("action_3", Component.literal("Action 3"), ctx -> {}) .build(); } - public static WheelMenuModel buildHoldDemo(int slotsPerPage) { + public static WheelMenuModel buildHoldDemo(int slotsPerPage, WheelSelectionEffect selectionEffect) { return WheelMenuBuilder.create() .slotsPerPage(slotsPerPage) - .action("hold_1", Component.literal("Hold 1"), (g, p, w, h) -> {}, ctx -> {}) - .submenu("ignored_submenu", Component.literal("Ignored Submenu"), (g, p, w, h) -> {}, submenu -> submenu - .action("never_opened", Component.literal("Never Opened"), (g, p, w, h) -> {}, ctx -> {}) + .selectionEffect(selectionEffect) + .action( + "hold_1", Component.literal("Hold 1"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "hold_2", Component.literal("Hold 2"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "hold_3", Component.literal("Hold 3"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "hold_4", Component.literal("Hold 4"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "hold_5", Component.literal("Hold 5"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "hold_6", Component.literal("Hold 6"), WheelDemoMenus::render, ctx -> { + } + ) + .action( + "hold_7", Component.literal("Hold 7"), WheelDemoMenus::render, ctx -> { + } + ) + .submenu( + "ignored_submenu", Component.literal("Ignored Submenu"), WheelDemoMenus::render, submenu -> submenu + .action( + "never_opened", Component.literal("Never Opened"), WheelDemoMenus::render, ctx -> { + } + ) + ) + .action( + "hold_8", Component.literal("Hold 8"), WheelDemoMenus::render, ctx -> { + } ) - .action("hold_2", Component.literal("Hold 2"), (g, p, w, h) -> {}, ctx -> {}) .build(); } -} + public static void render(GuiGraphics graphics, PoseStack pose, int width, int height) { + graphics.renderItem(Items.APPLE.getDefaultInstance(), -8, -8); + } +} diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/WheelTestClientHandler.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/WheelTestClientHandler.java index 147f71f4..c7b7bef9 100644 --- a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/WheelTestClientHandler.java +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/WheelTestClientHandler.java @@ -1,6 +1,7 @@ package dev.anvilcraft.lib.v2.test.wheel; import dev.anvilcraft.lib.v2.test.AnvilLibTest; +import dev.anvilcraft.lib.v2.wheel.api.WheelSelectionEffect; import dev.anvilcraft.lib.v2.wheel.client.input.WheelScreenController; import net.minecraft.client.Minecraft; import net.neoforged.api.distmarker.Dist; @@ -13,7 +14,8 @@ @EventBusSubscriber(modid = AnvilLibTest.MOD_ID, value = Dist.CLIENT) public final class WheelTestClientHandler { private static final WheelScreenController CONTROLLER = new WheelScreenController(); - private static boolean holdKeyWasDown = false; + private static boolean holdDotKeyWasDown = false; + private static boolean holdAnnularKeyWasDown = false; private WheelTestClientHandler() { } @@ -30,7 +32,10 @@ public static void onClientTick(ClientTickEvent.Pre event) { // --- TAP --- while (WheelTestKeys.TAP_KEY.consumeClick()) { - CONTROLLER.openTap(WheelDemoMenus.buildTapDemo(8)); + CONTROLLER.openTap(WheelDemoMenus.buildTapDemo(8, WheelSelectionEffect.DOT)); + } + while (WheelTestKeys.TAP_ANNULAR_KEY.consumeClick()) { + CONTROLLER.openTap(WheelDemoMenus.buildTapDemo(8, WheelSelectionEffect.ANNULAR_SECTOR)); } } @@ -40,20 +45,38 @@ public static void onClientTick(ClientTickEvent.Pre event) { @SubscribeEvent public static void onKeyInput(InputEvent.Key event) { Minecraft mc = Minecraft.getInstance(); - if (mc.player == null || !WheelTestKeys.HOLD_KEY.matches(event.getKey(), event.getScanCode())) { + if (mc.player == null) { + return; + } + boolean holdDotKey = WheelTestKeys.HOLD_KEY.matches(event.getKey(), event.getScanCode()); + boolean holdAnnularKey = WheelTestKeys.HOLD_ANNULAR_KEY.matches(event.getKey(), event.getScanCode()); + if (!holdDotKey && !holdAnnularKey) { + return; + } + // 长按模式下松开按键时轮盘 Screen 已打开,必须放行 RELEASE 事件才能触发关闭 + if (mc.screen != null && event.getAction() != GLFW.GLFW_RELEASE) { return; } if (event.getAction() == GLFW.GLFW_PRESS) { - if (!holdKeyWasDown) { - CONTROLLER.onHoldKeyPressed(WheelDemoMenus.buildHoldDemo(8)); - holdKeyWasDown = true; + if (holdDotKey && !holdDotKeyWasDown) { + CONTROLLER.onHoldKeyPressed(WheelDemoMenus.buildHoldDemo(8, WheelSelectionEffect.DOT)); + holdDotKeyWasDown = true; + } + if (holdAnnularKey && !holdAnnularKeyWasDown) { + CONTROLLER.onHoldKeyPressed(WheelDemoMenus.buildHoldDemo(8, WheelSelectionEffect.ANNULAR_SECTOR)); + holdAnnularKeyWasDown = true; } return; } - if (event.getAction() == GLFW.GLFW_RELEASE && holdKeyWasDown) { - CONTROLLER.onHoldKeyReleased(); - holdKeyWasDown = false; + if (event.getAction() == GLFW.GLFW_RELEASE) { + if (holdDotKey && holdDotKeyWasDown) { + CONTROLLER.onHoldKeyReleased(); + holdDotKeyWasDown = false; + } + if (holdAnnularKey && holdAnnularKeyWasDown) { + CONTROLLER.onHoldKeyReleased(); + holdAnnularKeyWasDown = false; + } } } } - diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/WheelTestKeys.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/WheelTestKeys.java index f1a9cd43..243f61bc 100644 --- a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/WheelTestKeys.java +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/WheelTestKeys.java @@ -13,7 +13,7 @@ public final class WheelTestKeys { public static final String CATEGORY = "key.categories.anvillib_test.wheel"; - /** 点按模式:按一下打开,鼠标选择后点击触发 */ + /** 点按模式(圆点选中效果):按一下打开,鼠标选择后点击触发 */ public static final KeyMapping TAP_KEY = new KeyMapping( "key.anvillib_test.wheel_tap", InputConstants.Type.KEYSYM, @@ -21,7 +21,7 @@ public final class WheelTestKeys { CATEGORY ); - /** 长按模式:按住显示,松开触发当前选中项 */ + /** 长按模式(圆点选中效果):按住显示,松开触发当前选中项 */ public static final KeyMapping HOLD_KEY = new KeyMapping( "key.anvillib_test.wheel_hold", InputConstants.Type.KEYSYM, @@ -29,6 +29,22 @@ public final class WheelTestKeys { CATEGORY ); + /** 点按模式(扇形选中效果) */ + public static final KeyMapping TAP_ANNULAR_KEY = new KeyMapping( + "key.anvillib_test.wheel_tap_annular", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_B, + CATEGORY + ); + + /** 长按模式(扇形选中效果) */ + public static final KeyMapping HOLD_ANNULAR_KEY = new KeyMapping( + "key.anvillib_test.wheel_hold_annular", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_N, + CATEGORY + ); + private WheelTestKeys() { } @@ -36,5 +52,7 @@ private WheelTestKeys() { public static void onRegisterKeyMappings(RegisterKeyMappingsEvent event) { event.register(TAP_KEY); event.register(HOLD_KEY); + event.register(TAP_ANNULAR_KEY); + event.register(HOLD_ANNULAR_KEY); } } diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/data/lang/package-info.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/data/lang/package-info.java index 0c334edf..bf8afca0 100644 --- a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/data/lang/package-info.java +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/data/lang/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.test.wheel.data.lang; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/package-info.java b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/package-info.java index b0fcde8f..0bf72eb3 100644 --- a/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/package-info.java +++ b/module.test/src/main/java/dev/anvilcraft/lib/v2/test/wheel/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.test.wheel; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.test/src/main/resources/assets/anvillib_test/lang/zh_cn.json b/module.test/src/main/resources/assets/anvillib_test/lang/zh_cn.json index 01f416d1..69653ab7 100644 --- a/module.test/src/main/resources/assets/anvillib_test/lang/zh_cn.json +++ b/module.test/src/main/resources/assets/anvillib_test/lang/zh_cn.json @@ -1,5 +1,7 @@ { "key.categories.anvillib_test.wheel": "铁砧库 测试-轮盘", "key.anvillib_test.wheel_tap": "轮盘(触发)", - "key.anvillib_test.wheel_hold": "轮盘(长按)" + "key.anvillib_test.wheel_hold": "轮盘(长按)", + "key.anvillib_test.wheel_tap_annular": "轮盘(触发,扇形)", + "key.anvillib_test.wheel_hold_annular": "轮盘(长按,扇形)" } \ No newline at end of file diff --git a/module.util/build.gradle b/module.util/build.gradle index 13d6dfac..1363f35d 100644 --- a/module.util/build.gradle +++ b/module.util/build.gradle @@ -115,6 +115,7 @@ sourceSets.main.resources { srcDir 'src/generated/resources' } dependencies { + if (System.getenv("NOT_DEV") == 'true') { jarJar(api("dev.anvilcraft.lib:anvillib-codec-neoforge-1.21.1:latest.release")) } else { @@ -270,6 +271,24 @@ machete { enabled = false } +sourceSets { + test { + compileClasspath += sourceSets.main.compileClasspath + runtimeClasspath += sourceSets.main.compileClasspath + } +} + +tasks.register('runShapeUtilJoinTimingTest', JavaExec) { + group = 'verification' + description = 'Runs ShapeUtil join timing checks for connected AABBs.' + classpath = sourceSets.test.runtimeClasspath + sourceSets.main.compileClasspath + mainClass = 'dev.anvilcraft.lib.v2.util.ShapeUtilJoinTimingTest' +} + +tasks.named('check') { + dependsOn tasks.named('runShapeUtilJoinTimingTest') +} + lombok { version = "1.18.34" } diff --git a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/OutlineUtil.java b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/OutlineUtil.java new file mode 100644 index 00000000..f7efa5ef --- /dev/null +++ b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/OutlineUtil.java @@ -0,0 +1,434 @@ +package dev.anvilcraft.lib.v2.util; + +import dev.anvilcraft.lib.v2.util.client.Line; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import javax.annotation.Nullable; + +import java.util.ArrayList; +import java.util.BitSet; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +public final class OutlineUtil { + private static final String PARALLEL_THRESHOLD_PROPERTY = "anvillib.outline.parallelThreshold"; + private static final int DEFAULT_PARALLEL_BOX_THRESHOLD = 1024; + + private OutlineUtil() { + } + + public static List extractOutline(List boxes) { + return extractOutline(boxes, null); + } + + public static List extractOutline(List boxes, @Nullable Executor executor) { + if (boxes.isEmpty()) { + return List.of(); + } + + EdgeAccumulator accumulator = new EdgeAccumulator(); + List tasks = createAxisTasks(boxes); + if (boxes.size() < parallelBoxThreshold()) { + for (AxisTask task : tasks) { + accumulator.addAll(task.extract()); + } + } else { + for (EdgeAccumulator extracted : extractParallel(tasks, executor)) { + accumulator.addAll(extracted); + } + } + return accumulator.toLines(); + } + + private static List extractParallel(List tasks, @Nullable Executor executor) { + if (executor == null) { + return tasks.parallelStream().map(AxisTask::extract).toList(); + } + return tasks.stream() + .map(task -> CompletableFuture.supplyAsync(task::extract, executor)) + .map(CompletableFuture::join) + .toList(); + } + + private static int parallelBoxThreshold() { + return Integer.getInteger(PARALLEL_THRESHOLD_PROPERTY, DEFAULT_PARALLEL_BOX_THRESHOLD); + } + + private static List createAxisTasks(List boxes) { + List tasks = new ArrayList<>(3); + for (int axis = 0; axis < 3; axis++) { + tasks.add(new AxisTask(boxes, axis, firstOtherAxis(axis), secondOtherAxis(axis))); + } + return tasks; + } + + private record AxisTask(List boxes, int axis, int uAxis, int vAxis) { + private EdgeAccumulator extract() { + TreeSet planes = new TreeSet<>(); + Map> starts = new HashMap<>(); + Map> ends = new HashMap<>(); + for (AABB box : boxes) { + if (isDegenerate(box)) { + continue; + } + double min = min(box, axis); + double max = max(box, axis); + Rect projection = project(box, uAxis, vAxis); + planes.add(min); + planes.add(max); + starts.computeIfAbsent(min, ignored -> new ArrayList<>()).add(projection); + ends.computeIfAbsent(max, ignored -> new ArrayList<>()).add(projection); + } + + EdgeAccumulator accumulator = new EdgeAccumulator(); + RectMultiset active = new RectMultiset(); + for (double plane : planes) { + List lower = active.toList(); + active.removeAll(ends.getOrDefault(plane, List.of())); + active.addAll(starts.getOrDefault(plane, List.of())); + extractPlaneOutline(axis, uAxis, vAxis, plane, lower, active.toList(), accumulator); + } + return accumulator; + } + } + + private static void extractPlaneOutline( + int axis, + int uAxis, + int vAxis, + double plane, + List lower, + List upper, + EdgeAccumulator accumulator + ) { + if (lower.isEmpty() && upper.isEmpty()) { + return; + } + + AxisGrid grid = AxisGrid.create(lower, upper); + if (grid.uCoordinates.length < 2 || grid.vCoordinates.length < 2) { + return; + } + + BitSet[] lowerRows = createRows(grid.uSize()); + BitSet[] upperRows = createRows(grid.uSize()); + mark(lowerRows, lower, grid); + mark(upperRows, upper, grid); + + for (int u = 0; u < grid.uSize(); u++) { + for (int v = 0; v < grid.vSize(); v++) { + if (empty(lowerRows, upperRows, u, v)) { + continue; + } + + if (v == 0 || empty(lowerRows, upperRows, u, v - 1)) { + accumulator.add( + point(axis, uAxis, vAxis, plane, grid.uCoordinates[u], grid.vCoordinates[v]), + point(axis, uAxis, vAxis, plane, grid.uCoordinates[u + 1], grid.vCoordinates[v]) + ); + } + if (v == grid.vSize() - 1 || empty(lowerRows, upperRows, u, v + 1)) { + accumulator.add( + point(axis, uAxis, vAxis, plane, grid.uCoordinates[u], grid.vCoordinates[v + 1]), + point(axis, uAxis, vAxis, plane, grid.uCoordinates[u + 1], grid.vCoordinates[v + 1]) + ); + } + if (u == 0 || empty(lowerRows, upperRows, u - 1, v)) { + accumulator.add( + point(axis, uAxis, vAxis, plane, grid.uCoordinates[u], grid.vCoordinates[v]), + point(axis, uAxis, vAxis, plane, grid.uCoordinates[u], grid.vCoordinates[v + 1]) + ); + } + if (u == grid.uSize() - 1 || empty(lowerRows, upperRows, u + 1, v)) { + accumulator.add( + point(axis, uAxis, vAxis, plane, grid.uCoordinates[u + 1], grid.vCoordinates[v]), + point(axis, uAxis, vAxis, plane, grid.uCoordinates[u + 1], grid.vCoordinates[v + 1]) + ); + } + } + } + } + + private static BitSet[] createRows(int size) { + BitSet[] rows = new BitSet[size]; + for (int i = 0; i < rows.length; i++) { + rows[i] = new BitSet(); + } + return rows; + } + + private static void mark(BitSet[] rows, List rects, AxisGrid grid) { + for (Rect rect : rects) { + Integer minU = grid.uIndex.get(rect.minU); + Integer maxU = grid.uIndex.get(rect.maxU); + Integer minV = grid.vIndex.get(rect.minV); + Integer maxV = grid.vIndex.get(rect.maxV); + if (minU == null || maxU == null || minV == null || maxV == null) { + continue; + } + for (int u = minU; u < maxU; u++) { + rows[u].set(minV, maxV); + } + } + } + + private static boolean empty(BitSet[] lowerRows, BitSet[] upperRows, int u, int v) { + return lowerRows[u].get(v) == upperRows[u].get(v); + } + + private static Rect project(AABB box, int uAxis, int vAxis) { + return new Rect( + canonical(min(box, uAxis)), + canonical(max(box, uAxis)), + canonical(min(box, vAxis)), + canonical(max(box, vAxis)) + ); + } + + private static Point point(int axis, int uAxis, int vAxis, double plane, double u, double v) { + double[] coordinates = new double[3]; + coordinates[axis] = plane; + coordinates[uAxis] = u; + coordinates[vAxis] = v; + return new Point(coordinates[0], coordinates[1], coordinates[2]); + } + + private static boolean isDegenerate(AABB box) { + return box.minX >= box.maxX || box.minY >= box.maxY || box.minZ >= box.maxZ; + } + + private static int firstOtherAxis(int axis) { + return switch (axis) { + case 0 -> 1; + case 1, 2 -> 0; + default -> throw new IllegalArgumentException("Unknown axis: " + axis); + }; + } + + private static int secondOtherAxis(int axis) { + return switch (axis) { + case 0, 1 -> 2; + case 2 -> 1; + default -> throw new IllegalArgumentException("Unknown axis: " + axis); + }; + } + + private static double min(AABB box, int axis) { + return switch (axis) { + case 0 -> box.minX; + case 1 -> box.minY; + case 2 -> box.minZ; + default -> throw new IllegalArgumentException("Unknown axis: " + axis); + }; + } + + private static double max(AABB box, int axis) { + return switch (axis) { + case 0 -> box.maxX; + case 1 -> box.maxY; + case 2 -> box.maxZ; + default -> throw new IllegalArgumentException("Unknown axis: " + axis); + }; + } + + private static double canonical(double value) { + return value == 0.0 ? 0.0 : value; + } + + private record Rect(double minU, double maxU, double minV, double maxV) { + } + + private static final class RectMultiset { + private final Map counts; + + private RectMultiset() { + this.counts = new HashMap<>(); + } + + private void addAll(List rects) { + for (Rect rect : rects) { + counts.merge(rect, 1, Integer::sum); + } + } + + private void removeAll(List rects) { + for (Rect rect : rects) { + counts.computeIfPresent(rect, (ignored, count) -> count == 1 ? null : count - 1); + } + } + + private List toList() { + return new ArrayList<>(counts.keySet()); + } + } + + private record Point(double x, double y, double z) { + private Vec3 toVec3() { + return new Vec3(x, y, z); + } + } + + private record AxisGrid( + double[] uCoordinates, + double[] vCoordinates, + Map uIndex, + Map vIndex + ) { + private static AxisGrid create(List lower, List upper) { + TreeSet uCoordinates = new TreeSet<>(); + TreeSet vCoordinates = new TreeSet<>(); + addCoordinates(lower, uCoordinates, vCoordinates); + addCoordinates(upper, uCoordinates, vCoordinates); + double[] uArray = toArray(uCoordinates); + double[] vArray = toArray(vCoordinates); + return new AxisGrid(uArray, vArray, index(uArray), index(vArray)); + } + + private int uSize() { + return uCoordinates.length - 1; + } + + private int vSize() { + return vCoordinates.length - 1; + } + + private static void addCoordinates(List rects, Set uCoordinates, Set vCoordinates) { + for (Rect rect : rects) { + uCoordinates.add(rect.minU); + uCoordinates.add(rect.maxU); + vCoordinates.add(rect.minV); + vCoordinates.add(rect.maxV); + } + } + + private static double[] toArray(TreeSet coordinates) { + double[] result = new double[coordinates.size()]; + int index = 0; + for (double coordinate : coordinates) { + result[index++] = coordinate; + } + return result; + } + + private static Map index(double[] coordinates) { + Map result = new HashMap<>(); + for (int i = 0; i < coordinates.length; i++) { + result.put(coordinates[i], i); + } + return result; + } + } + + private static final class EdgeAccumulator { + private final Set segments = new HashSet<>(); + + private void add(Point first, Point second) { + if (first.equals(second)) { + return; + } + segments.add(Segment.create(first, second)); + } + + private void addAll(EdgeAccumulator other) { + segments.addAll(other.segments); + } + + private List toLines() { + Map> intervals = new HashMap<>(); + for (Segment segment : segments) { + intervals.computeIfAbsent(segment.key(), ignored -> new ArrayList<>()).add(segment.interval()); + } + + List result = new ArrayList<>(); + for (Map.Entry> entry : intervals.entrySet()) { + LineKey key = entry.getKey(); + List lineIntervals = entry.getValue(); + lineIntervals.sort(Comparator.comparingDouble(Interval::min).thenComparingDouble(Interval::max)); + + double min = lineIntervals.getFirst().min; + double max = lineIntervals.getFirst().max; + for (int i = 1; i < lineIntervals.size(); i++) { + Interval interval = lineIntervals.get(i); + if (interval.min <= max) { + max = Math.max(max, interval.max); + } else { + result.add(key.toLine(min, max)); + min = interval.min; + max = interval.max; + } + } + result.add(key.toLine(min, max)); + } + return result; + } + } + + private record Segment(Point from, Point to) { + private static Segment create(Point first, Point second) { + int axis = varyingAxis(first, second); + if (coordinate(first, axis) <= coordinate(second, axis)) { + return new Segment(first, second); + } + return new Segment(second, first); + } + + private LineKey key() { + int axis = varyingAxis(from, to); + return new LineKey(axis, coordinate(from, fixedAxis(axis, 0)), coordinate(from, fixedAxis(axis, 1))); + } + + private Interval interval() { + int axis = varyingAxis(from, to); + return new Interval(coordinate(from, axis), coordinate(to, axis)); + } + + private static int varyingAxis(Point from, Point to) { + if (from.x != to.x) return 0; + if (from.y != to.y) return 1; + if (from.z != to.z) return 2; + throw new IllegalArgumentException("Zero-length segment"); + } + } + + private record LineKey(int axis, double firstFixed, double secondFixed) { + private Line toLine(double min, double max) { + double[] from = new double[3]; + double[] to = new double[3]; + from[axis] = min; + to[axis] = max; + from[fixedAxis(axis, 0)] = firstFixed; + to[fixedAxis(axis, 0)] = firstFixed; + from[fixedAxis(axis, 1)] = secondFixed; + to[fixedAxis(axis, 1)] = secondFixed; + return new Line(new Point(from[0], from[1], from[2]).toVec3(), new Point(to[0], to[1], to[2]).toVec3()); + } + } + + private record Interval(double min, double max) { + } + + private static int fixedAxis(int varyingAxis, int fixedIndex) { + return switch (varyingAxis) { + case 0 -> fixedIndex == 0 ? 1 : 2; + case 1 -> fixedIndex == 0 ? 0 : 2; + case 2 -> fixedIndex == 0 ? 0 : 1; + default -> throw new IllegalArgumentException("Unknown axis: " + varyingAxis); + }; + } + + private static double coordinate(Point point, int axis) { + return switch (axis) { + case 0 -> point.x; + case 1 -> point.y; + case 2 -> point.z; + default -> throw new IllegalArgumentException("Unknown axis: " + axis); + }; + } +} diff --git a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/client/Line.java b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/client/Line.java new file mode 100644 index 00000000..7ee577e3 --- /dev/null +++ b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/client/Line.java @@ -0,0 +1,47 @@ +package dev.anvilcraft.lib.v2.util.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import net.minecraft.world.phys.Vec3; + +public record Line(Vec3 start, Vec3 end, int thickness, float length) { + public Line(Vec3 start, Vec3 end, float length) { + this(start, end, 4, length); + } + + public Line(Vec3 start, Vec3 end, int thickness) { + this(start, end, thickness, (float) start.distanceTo(end)); + } + + public Line(Vec3 start, Vec3 end) { + this(start, end, (float) start.distanceTo(end)); + } + + @Deprecated(forRemoval = true) + public void render(PoseStack pose, VertexConsumer vertex, Vec3 camera, int color) { + this.render(pose.last(), vertex, camera, color); + } + + public void render(PoseStack.Pose pose, VertexConsumer vertex, Vec3 camera, int color) { + float dx = (float) (this.start().x - this.end().x); + float dy = (float) (this.start().y - this.end().y); + float dz = (float) (this.start().z - this.end().z); + // 1.21.1 的 VertexConsumer 无 setLineWidth(1.21.2 渲染管线改造后引入),线宽忽略 + vertex.addVertex( + pose.pose(), + (float) (this.start().x - camera.x), + (float) (this.start().y - camera.y), + (float) (this.start().z - camera.z) + ) + .setColor(color) + .setNormal(pose, dx /= this.length(), dy /= this.length(), dz /= this.length()); + vertex.addVertex( + pose.pose(), + (float) (this.end().x - camera.x), + (float) (this.end().y - camera.y), + (float) (this.end().z - camera.z) + ) + .setColor(color) + .setNormal(pose, dx, dy, dz); + } +} diff --git a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/component/package-info.java b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/component/package-info.java index 3854bb0f..9ebf8ec5 100644 --- a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/component/package-info.java +++ b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/component/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.util.component; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/package-info.java b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/package-info.java index 5b27ce6c..3f07dc21 100644 --- a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/package-info.java +++ b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.util; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/BlockStatePredicate.java b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/BlockStatePredicate.java index e855f630..af41bb53 100644 --- a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/BlockStatePredicate.java +++ b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/BlockStatePredicate.java @@ -668,4 +668,4 @@ public interface ValueMatcher { */ , S extends StateHolder> List applyToState(S state, Property property); } -} \ No newline at end of file +} diff --git a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/ItemIngredientPredicate.java b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/ItemIngredientPredicate.java index 516ee912..12eccaf0 100644 --- a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/ItemIngredientPredicate.java +++ b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/ItemIngredientPredicate.java @@ -236,4 +236,4 @@ public ItemIngredientPredicate build() { return new ItemIngredientPredicate(this.items, this.count, this.components, this.subPredicates.build()); } } -} \ No newline at end of file +} diff --git a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/ItemPredicate.java b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/ItemPredicate.java index a6c91707..7ddaa503 100644 --- a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/ItemPredicate.java +++ b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/ItemPredicate.java @@ -169,4 +169,4 @@ public ItemPredicate build() { return new ItemPredicate(this.items, this.count, this.components, this.subPredicates.build()); } } -} \ No newline at end of file +} diff --git a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/package-info.java b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/package-info.java index f4ba3df4..8c765df0 100644 --- a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/package-info.java +++ b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/predicate/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.util.predicate; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/stack/UnlimitedItemStack.java b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/stack/UnlimitedItemStack.java index 05dfa6a6..35413177 100644 --- a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/stack/UnlimitedItemStack.java +++ b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/stack/UnlimitedItemStack.java @@ -5,47 +5,67 @@ import com.mojang.serialization.Codec; import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.anvilcraft.lib.v2.util.Util; import io.netty.handler.codec.DecoderException; import io.netty.handler.codec.EncoderException; import lombok.Getter; import lombok.Setter; import net.minecraft.core.Holder; -import net.minecraft.core.HolderLookup; +import net.minecraft.core.HolderSet; +import net.minecraft.core.NonNullList; +import net.minecraft.core.component.DataComponentHolder; +import net.minecraft.core.component.DataComponentMap; import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.component.TypedDataComponent; import net.minecraft.core.registries.Registries; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.NbtOps; -import net.minecraft.nbt.Tag; import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.chat.Component; import net.minecraft.network.codec.ByteBufCodecs; import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceKey; import net.minecraft.tags.TagKey; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.component.TooltipProvider; +import net.minecraft.world.item.enchantment.Enchantment; +import net.minecraft.world.item.enchantment.ItemEnchantments; import net.minecraft.world.level.ItemLike; -import net.neoforged.neoforge.common.util.INBTSerializable; +import net.neoforged.neoforge.common.ItemAbility; +import net.neoforged.neoforge.common.MutableDataComponentHolder; +import net.neoforged.neoforge.registries.datamaps.DataMapType; +import net.neoforged.neoforge.registries.datamaps.IWithData; import org.slf4j.Logger; import java.util.List; -import java.util.Optional; +import java.util.Objects; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; +import javax.annotation.Nullable; @Getter @Setter -public class UnlimitedItemStack implements INBTSerializable { +public class UnlimitedItemStack implements DataComponentHolder, MutableDataComponentHolder, IWithData { private static final Logger LOGGER = LogUtils.getLogger(); public static final UnlimitedItemStack EMPTY = new UnlimitedItemStack(ItemStack.EMPTY, 0); - public static final MapCodec MAP_CODEC = RecordCodecBuilder.mapCodec(inst -> inst.group( - ItemStack.ITEM_NON_AIR_CODEC - .fieldOf("id") - .forGetter(stack -> stack.getStack().getItemHolder()), - Codec.INT - .fieldOf("count") - .forGetter(UnlimitedItemStack::getCount), - DataComponentPatch.CODEC - .optionalFieldOf("components", DataComponentPatch.EMPTY) - .forGetter(stack -> stack.getStack().getComponentsPatch()) - ).apply(inst, UnlimitedItemStack::new)); + public static final MapCodec MAP_CODEC = MapCodec.recursive( + "UnlimitedItemStack", + ignored -> RecordCodecBuilder.mapCodec(inst -> inst.group( + ItemStack.ITEM_NON_AIR_CODEC + .fieldOf("id") + .forGetter(stack -> stack.getStack().getItemHolder()), + Codec.INT + .fieldOf("count") + .forGetter(UnlimitedItemStack::getCount), + DataComponentPatch.CODEC + .optionalFieldOf("components", DataComponentPatch.EMPTY) + .forGetter(stack -> stack.getStack().getComponentsPatch()) + ).apply(inst, UnlimitedItemStack::new)) + ); public static final Codec CODEC = Codec.lazyInitialized(MAP_CODEC::codec); public static final StreamCodec OPTIONAL_STREAM_CODEC = new StreamCodec<>() { private static final StreamCodec> ITEM_STREAM_CODEC = ByteBufCodecs.holderRegistry( @@ -82,15 +102,15 @@ public void encode(RegistryFriendlyByteBuf buf, UnlimitedItemStack stack) { UnlimitedItemStack.OPTIONAL_STREAM_CODEC.encode(buf, stack); } }; - private ItemStack stack; + public static final StreamCodec> OPTIONAL_LIST_STREAM_CODEC = OPTIONAL_STREAM_CODEC + .apply(ByteBufCodecs.collection(NonNullList::createWithCapacity)); + private ItemStack stack = ItemStack.EMPTY; private int count; public UnlimitedItemStack(ItemStack stack, int count) { this.count = Math.max(count, 0); - if (this.count == 0) { - this.stack = ItemStack.EMPTY; - } else { - this.stack = stack.copyWithCount(1); + if (this.count != 0) { + this.setStack(stack); } } @@ -103,64 +123,70 @@ public UnlimitedItemStack(ItemStack stack) { } public boolean isEmpty() { - return this.stack.isEmpty() || this.count == 0; + return this.getStack().isEmpty() || this.count <= 0; } - public UnlimitedItemStack copy() { - return new UnlimitedItemStack(this.stack, this.count); + public UnlimitedItemStack split(int amount) { + int realAmount = Math.min(amount, this.getCount()); + UnlimitedItemStack result = this.copyWithCount(realAmount); + this.shrink(realAmount); + return result; } - public UnlimitedItemStack copyWithCount(int count) { - return new UnlimitedItemStack(this.stack, count); + public UnlimitedItemStack copyAndClear() { + if (this.isEmpty()) { + return UnlimitedItemStack.EMPTY; + } else { + UnlimitedItemStack result = this.copy(); + this.setCount(0); + return result; + } } - @Override - public CompoundTag serializeNBT(HolderLookup.Provider provider) { - return Util.cast(CODEC.encodeStart(provider.createSerializationContext(NbtOps.INSTANCE), this).getOrThrow()); + public Item getItem() { + return this.typeHolder().value(); } - @Override - public void deserializeNBT(HolderLookup.Provider provider, CompoundTag nbt) { - UnlimitedItemStack.parse(provider, nbt).ifPresent(this::copyFrom); + @SuppressWarnings("deprecation") + public Holder getItemHolder() { + return this.isEmpty() ? Items.AIR.builtInRegistryHolder() : this.getStack().getItemHolder(); } - public static Optional parse(HolderLookup.Provider provider, Tag tag) { - return CODEC.parse(provider.createSerializationContext(NbtOps.INSTANCE), tag) - .resultOrPartial(info -> LOGGER.error("Tried to load invalid item: '{}'", info)); + @SuppressWarnings("deprecation") + public Holder typeHolder() { + return this.isEmpty() ? Items.AIR.builtInRegistryHolder() : this.getStack().getItemHolder(); } - /** - * 该方法不会设置数量。请使用 {@link UnlimitedItemStack#setCount(int)} 设置数量。 - * - * @param stack 提供物品和数据组件的 {@link ItemStack} - */ - public void setStack(ItemStack stack) { - this.stack = stack.copyWithCount(1); + public Stream> tags() { + return this.typeHolder().tags(); } - public void grow(int count) { - this.setCount(this.count + count); + public boolean is(TagKey tag) { + return this.typeHolder().is(tag); } - public void copyFrom(UnlimitedItemStack stack) { - this.setStack(stack.getStack()); - this.setCount(stack.getCount()); + public boolean is(HolderSet set) { + return set.contains(this.typeHolder()); } - public Item getItem() { - return this.stack.getItem(); + public boolean is(Item rawType) { + return this.typeHolder().value() == rawType; + } + + public boolean is(Holder type) { + return this.is(type.value()); } - public boolean is(Item item) { - return this.stack.is(item); + public boolean is(ResourceKey type) { + return this.typeHolder().is(type); } public boolean is(ItemLike item) { - return this.stack.is(item.asItem()); + return this.is(item.asItem()); } - public boolean is(TagKey item) { - return this.stack.is(item); + public boolean is(Predicate> item) { + return item.test(this.typeHolder()); } public boolean isAny(ItemLike... items) { @@ -170,28 +196,106 @@ public boolean isAny(ItemLike... items) { return false; } - public Holder getItemHolder() { - return this.stack.getItemHolder(); + @Override + public @Nullable T getData(DataMapType type) { + return this.typeHolder().getData(type); } - public DataComponentPatch getComponentsPatch() { - return this.stack.getComponentsPatch(); + public int getMaxStackSize() { + return this.getItem().getMaxStackSize(this.toStack()); + } + + public boolean isStackable() { + return this.getMaxStackSize() > 1 && (!this.isDamageableItem() || !this.isDamaged()); + } + + public boolean isDamageableItem() { + return this.getStack().isDamageableItem(); + } + + public boolean isDamaged() { + return this.getStack().isDamaged(); + } + + public UnlimitedItemStack copy() { + return new UnlimitedItemStack(this.getStack(), this.getCount()); + } + + public UnlimitedItemStack copyWithCount(int count) { + return new UnlimitedItemStack(this.getStack(), count); + } + + public UnlimitedItemStack transmuteCopy(ItemLike newItem) { + return this.transmuteCopy(newItem, this.getCount()); + } + + public UnlimitedItemStack transmuteCopy(ItemLike newItem, int newCount) { + return this.isEmpty() ? UnlimitedItemStack.EMPTY : this.transmuteCopyIgnoreEmpty(newItem, newCount); + } + + @SuppressWarnings("deprecation") + private UnlimitedItemStack transmuteCopyIgnoreEmpty(ItemLike newItem, int newCount) { + return new UnlimitedItemStack(newItem.asItem().builtInRegistryHolder(), newCount, this.getComponentsPatch()); + } + + public boolean matches(ItemStack stack) { + return ItemStack.matches(this.toStack(), stack); + } + + public boolean matches(UnlimitedItemStack stack) { + if (this == stack) { + return true; + } + return this.getCount() == stack.getCount() && this.isSameItemSameComponents(stack); + } + + public boolean isSameItem(ItemStack stack) { + return ItemStack.isSameItem(this.toStack(), stack); + } + + public boolean isSameItem(UnlimitedItemStack stack) { + return this.is(stack.getItem()); } public boolean isSameItemSameComponents(ItemStack stack) { return ItemStack.isSameItemSameComponents(this.getStack(), stack); } - public boolean isSameItemSameComponents(UnlimitedItemStack stack) { - return this.isSameItemSameComponents(stack.getStack()); + public boolean isSameItemSameComponents(@Nullable UnlimitedItemStack stack) { + return stack != null && this.isSameItemSameComponents(stack.getStack()); } - public static boolean listMatches(List list, List other) { - if (list.size() != other.size()) return false; - for (int i = 0; i < list.size(); i++) { - if (!list.get(i).isSameItemSameComponents(other.get(i))) return false; + public boolean matchesIgnoringComponents(ItemStack other, Predicate> ignoredPredicate) { + ItemStack stack = this.toStack(); + if (stack == other) { + return true; + } else if (stack.getCount() != other.getCount()) { + return false; + } else if (!stack.is(other.getItem())) { + return false; + } else if (stack.isEmpty() && other.isEmpty()) { + return true; + } else if (stack.getComponents().size() != other.getComponents().size()) { + return false; + } else { + for (DataComponentType type : stack.getComponents().keySet()) { + Object componentA = stack.getComponents().get(type); + Object componentB = other.getComponents().get(type); + if (componentA == null || componentB == null) { + return false; + } + + if (!Objects.equals(componentA, componentB) && !ignoredPredicate.test(type)) { + return false; + } + } + + return true; } - return true; + } + + public boolean matchesIgnoringComponents(UnlimitedItemStack stack, Predicate> ignoredPredicate) { + return this.matchesIgnoringComponents(stack.toStack(), ignoredPredicate); } public int hashItemAndComponents() { @@ -206,55 +310,165 @@ public static int hashStackList(List list) { return i; } - public UnlimitedItemStack splitUnlimited(int amount) { - if (amount > this.stack.getMaxStackSize()) { - throw new IllegalArgumentException("Cannot split amount that bigger than max stack size."); - } - this.count -= amount; - return this.copyWithCount(amount); + public @Nullable TypedDataComponent getTyped(DataComponentType type) { + T value = this.get(type); + return value != null ? new TypedDataComponent<>(type, value) : null; + } + + @Override + public @Nullable T set(DataComponentType type, @Nullable T value) { + return this.getStack().set(type, value); + } + + public @Nullable T set(TypedDataComponent value) { + return this.getStack().set(value.type(), value.value()); + } + + @Override + public void copyFrom(DataComponentHolder src, DataComponentType... componentTypes) { + this.getStack().copyFrom(src, componentTypes); } - public ItemStack split(int amount) { - if (amount > this.stack.getMaxStackSize()) { - throw new IllegalArgumentException("Cannot split amount that bigger than max stack size."); + public void copyFrom(UnlimitedItemStack stack) { + this.setStack(stack.getStack()); + this.setCount(stack.getCount()); + } + + @Override + public @Nullable T update(DataComponentType type, T defaultValue, U value, BiFunction combiner) { + return this.getStack().update(type, defaultValue, value, combiner); + } + + @Override + public @Nullable T update(DataComponentType type, T defaultValue, UnaryOperator function) { + return this.getStack().update(type, defaultValue, function); + } + + @Override + public @Nullable T remove(DataComponentType type) { + return this.getStack().remove(type); + } + + @Override + public void applyComponents(DataComponentPatch patch) { + this.getStack().applyComponents(patch); + } + + @Override + public void applyComponents(DataComponentMap components) { + this.getStack().applyComponents(components); + } + + @Override + public void addToTooltip( + DataComponentType type, + Item.TooltipContext context, + Consumer adder, + TooltipFlag flag + ) { + this.toStack().addToTooltip(type, context, adder, flag); + } + + public boolean canPerformAction(ItemAbility ability) { + return this.typeHolder().value().canPerformAction(this.toStack(), ability); + } + + public ItemEnchantments getTagEnchantments() { + return this.getStack().getTagEnchantments(); + } + + public int getEnchantmentLevel(Holder enchantment) { + return this.getStack().getEnchantmentLevel(enchantment); + } + + public int getCount() { + return this.isEmpty() ? 0 : this.count; + } + + public int count() { + return this.getCount(); + } + + public void grow(int amount) { + this.setCount(this.getCount() + amount); + } + + public void shrink(int amount) { + this.grow(-amount); + } + + /// 将内部存储的物品栈替换为指定物品栈 + /// + ///

    注意:该方法不会设置数量。请使用 {@link UnlimitedItemStack#setCount(int)} 设置数量

    + /// + /// @param stack 提供物品和数据组件的 {@link ItemStack} + /// + /// @see UnlimitedItemStack#setCount(int) + public void setStack(ItemStack stack) { + this.stack = stack.copyWithCount(1); + } + + @Override + public DataComponentMap getComponents() { + return this.getStack().getComponents(); + } + + public DataComponentPatch getComponentsPatch() { + return this.getStack().getComponentsPatch(); + } + + public ItemStack getCraftingRemainingItem() { + return this.getStack().getCraftingRemainingItem(); + } + + public ItemStack getCraftingRemainder() { + return this.getCraftingRemainingItem(); + } + + public static boolean listMatches(List list, List other) { + if (list.size() != other.size()) return false; + for (int i = 0; i < list.size(); i++) { + if (!list.get(i).isSameItemSameComponents(other.get(i))) return false; } - this.count -= amount; - return this.getStack().copyWithCount(amount); - } - - /** - * 将本Stack转为一个{@link ItemStack}。
    - * 数量可能大于{@link ItemStack}允许的最大数量。
    - * 若需要数量安全的{@link ItemStack},请查看{@link UnlimitedItemStack#toStacks()} - * - * @return 一个与本Stack数据完全相同的ItemStack - * @see UnlimitedItemStack#toStacks() - */ + return true; + } + + /// 将本物品栈转为一个 {@link ItemStack}。
    + /// 数量可能大于 {@link ItemStack} 允许的最大数量。
    + /// 若需要数量安全的 {@link ItemStack},请查看{@link UnlimitedItemStack#toStacks()} + /// + /// @return 一个与本物品栈数据完全相同的 {@link ItemStack} + /// @see UnlimitedItemStack#toStacks() public ItemStack toStack() { - return this.stack.copyWithCount(this.count); - } - - /** - * 将本Stack按存储的{@link ItemStack}允许的最大数量转为一个{@link ItemStack}列表。 - * - * @return 一个ItemStack列表。
    - * 每个ItemStack都有相同的物品和数据组件。
    - * 除最后一个外,每个ItemStack的数量都为其允许的最大数量;
    - * 最后一个ItemStack的数量为{@code totalCount - [(size - 1) * maxCount]} - * @see UnlimitedItemStack#toStack() - */ + return this.getStack().copyWithCount(this.getCount()); + } + + /// 将本物品栈按存储的 {@link ItemStack} 允许的最大数量转为一个物品栈列表。 + /// + /// @return 一个物品栈列表。
    + /// 每个物品栈都有相同的物品和数据组件。
    + /// 将本物品栈的数量 {@code count} 按存储的物品栈允许的最大数量 {@code max} 分割为 {@code n} 份,
    + /// 前 {@code n - 1} 份物品栈的数量都为 {@code max},
    + /// 最后一份物品栈的数量为 {@code count - [(n - 1) * max]} + /// @see UnlimitedItemStack#toStack() public List toStacks() { - int maxCount = this.stack.getMaxStackSize(); - if (this.count <= maxCount) return List.of(this.stack.copyWithCount(count)); + ItemStack stack = this.getStack(); + int count = this.getCount(); + int maxCount = stack.getMaxStackSize(); + if (count <= maxCount) { + return List.of(stack.copyWithCount(count)); + } - int fullStacks = this.count / maxCount; + int fullStacks = count / maxCount; ImmutableList.Builder stacksBuilder = ImmutableList.builder(); for (int i = 0; i < fullStacks; i++) { - stacksBuilder.add(this.stack.copyWithCount(maxCount)); + stacksBuilder.add(stack.copyWithCount(maxCount)); } - int remain = this.count % maxCount; - if (remain != 0) stacksBuilder.add(this.stack.copyWithCount(remain)); + int remain = count % maxCount; + if (remain != 0) { + stacksBuilder.add(stack.copyWithCount(remain)); + } return stacksBuilder.build(); } @@ -262,12 +476,16 @@ public List toStacks() { @Override public boolean equals(Object o) { if (!(o instanceof UnlimitedItemStack stack1)) return false; - return this.isSameItemSameComponents(stack1) - && this.getCount() == stack1.getCount(); + return this.matches(stack1); } @Override public int hashCode() { - return ((this.stack.getItem().hashCode() + 31) * 31 + Integer.hashCode(this.count)) * 31 + this.stack.getComponents().hashCode(); + return Objects.hash(this.getStack().getItem(), this.getCount(), this.getStack().getComponents()); + } + + @Override + public String toString() { + return this.getCount() + " " + this.getItem(); } -} \ No newline at end of file +} diff --git a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/stack/package-info.java b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/stack/package-info.java index ed55ee0a..40fc6855 100644 --- a/module.util/src/main/java/dev/anvilcraft/lib/v2/util/stack/package-info.java +++ b/module.util/src/main/java/dev/anvilcraft/lib/v2/util/stack/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.util.stack; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.util/src/test/java/dev/anvilcraft/lib/v2/util/ShapeUtilJoinTimingTest.java b/module.util/src/test/java/dev/anvilcraft/lib/v2/util/ShapeUtilJoinTimingTest.java new file mode 100644 index 00000000..f10b52f3 --- /dev/null +++ b/module.util/src/test/java/dev/anvilcraft/lib/v2/util/ShapeUtilJoinTimingTest.java @@ -0,0 +1,232 @@ +package dev.anvilcraft.lib.v2.util; + +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.phys.shapes.BooleanOp; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; +import dev.anvilcraft.lib.v2.util.client.Line; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; + +public final class ShapeUtilJoinTimingTest { + private static final int AABB_COUNT = 500; + private static final long RANDOM_SEED = 0x5A5A5A5AL; + + private ShapeUtilJoinTimingTest() { + } + + public static void main(String[] args) throws ExecutionException, InterruptedException { + assertOutlineMatchesShapePath(List.of(new AABB(0.0, 0.0, 0.0, 1.0, 1.0, 1.0)), "single box"); + assertOutlineMatchesShapePath( + List.of( + new AABB(0.0, 0.0, 0.0, 1.0, 1.0, 1.0), + new AABB(0.5, 0.5, 0.0, 1.5, 1.5, 1.0) + ), + "overlapping boxes" + ); + assertOutlineMatchesShapePath( + List.of( + new AABB(0.0, 0.0, 0.0, 1.0, 1.0, 1.0), + new AABB(1.0, 0.0, 0.0, 2.0, 1.0, 1.0) + ), + "face-adjacent boxes" + ); + assertOutlineMatchesShapePath( + List.of( + new AABB(0.0, 0.0, 0.0, 2.0, 2.0, 2.0), + new AABB(0.5, 0.5, 0.5, 1.5, 1.5, 1.5) + ), + "contained box" + ); + assertOutlineMatchesShapePath( + List.of( + new AABB(0.0, 0.0, 0.0, 1.0, 1.0, 1.0), + new AABB(2.0, 0.0, 0.0, 3.0, 1.0, 1.0) + ), + "disconnected boxes" + ); + assertOutlineMatchesShapePath(createConnectedDiagonalAabbs(24, RANDOM_SEED), "connected diagonal chain"); + assertParallelMatchesSequential(createConnectedDiagonalAabbs(64, RANDOM_SEED)); + assertCustomExecutorParallelMatchesSequential(createConnectedDiagonalAabbs(64, RANDOM_SEED)); + + List boxes = createConnectedDiagonalAabbs(AABB_COUNT, RANDOM_SEED); + JoinTimingResult shapeResult = joinAndExtractOutlineByShape(boxes); + OutlineTimingResult aabbResult = extractOutlineByAabbs(boxes); + + assertEquals(AABB_COUNT, boxes.size(), "generated AABB count"); + assertFalse(shapeResult.joined().isEmpty(), "joined shape should not be empty"); + assertNotNull(shapeResult.outline(), "shape outline result"); + assertNotNull(aabbResult.outline(), "AABB outline result"); + assertSameLines(shapeResult.outline(), aabbResult.outline(), "benchmark outline"); + } + + private static void assertOutlineMatchesShapePath(List boxes, String label) + throws ExecutionException, InterruptedException { + JoinTimingResult shapeResult = joinAndExtractOutlineByShape(boxes); + OutlineTimingResult aabbResult = extractOutlineByAabbs(boxes); + assertSameLines(shapeResult.outline(), aabbResult.outline(), label); + } + + private static void assertParallelMatchesSequential(List boxes) { + String propertyName = "anvillib.outline.parallelThreshold"; + String previous = System.getProperty(propertyName); + try { + System.setProperty(propertyName, Integer.toString(Integer.MAX_VALUE)); + List sequential = OutlineUtil.extractOutline(boxes); + System.setProperty(propertyName, "1"); + List parallel = OutlineUtil.extractOutline(boxes); + assertEquals(sequential, parallel, "parallel outline branch"); + } finally { + if (previous == null) { + System.clearProperty(propertyName); + } else { + System.setProperty(propertyName, previous); + } + } + } + + private static void assertCustomExecutorParallelMatchesSequential(List boxes) { + String propertyName = "anvillib.outline.parallelThreshold"; + String previous = System.getProperty(propertyName); + int[] executions = {0}; + Executor countingExecutor = command -> { + executions[0]++; + command.run(); + }; + try { + System.setProperty(propertyName, Integer.toString(Integer.MAX_VALUE)); + List sequential = OutlineUtil.extractOutline(boxes); + System.setProperty(propertyName, "1"); + List parallel = OutlineUtil.extractOutline(boxes, countingExecutor); + if (executions[0] <= 0) { + throw new AssertionError("custom executor should run parallel outline tasks"); + } + assertEquals(sequential, parallel, "custom executor parallel outline branch"); + } finally { + if (previous == null) { + System.clearProperty(propertyName); + } else { + System.setProperty(propertyName, previous); + } + } + } + + private static JoinTimingResult joinAndExtractOutlineByShape(List boxes) throws ExecutionException, InterruptedException { + List shapes = boxes.stream() + .map(box -> Shapes.box(box.minX, box.minY, box.minZ, box.maxX, box.maxY, box.maxZ)) + .toList(); + long started = System.nanoTime(); + VoxelShape result; + try (var executor = Executors.newFixedThreadPool(Math.max(1, Runtime.getRuntime().availableProcessors() - 1))) { + result = ShapeUtil.threadedJoin(shapes, BooleanOp.OR, executor).get(); + } + List outline = new ArrayList<>(); + result.forAllEdges( + (x1, y1, z1, x2, y2, z2) -> outline.add( + new Line(new Vec3(x1, y1, z1), new Vec3(x2, y2, z2)) + ) + ); + long elapsedNanos = System.nanoTime() - started; + System.out.printf( + "Shape path: thread-joined %d connected AABBs into %d lines in %.3f ms.%n", + boxes.size(), + outline.size(), + elapsedNanos / 1_000_000.0 + ); + return new JoinTimingResult(result, outline); + } + + private static OutlineTimingResult extractOutlineByAabbs(List boxes) { + long started = System.nanoTime(); + List outline = OutlineUtil.extractOutline(boxes); + long elapsedNanos = System.nanoTime() - started; + System.out.printf( + "AABB path: extracted %d connected AABBs into %d lines in %.3f ms.%n", + boxes.size(), + outline.size(), + elapsedNanos / 1_000_000.0 + ); + return new OutlineTimingResult(outline); + } + + private static List createConnectedDiagonalAabbs(int count, long seed) { + Random random = new Random(seed); + List boxes = new ArrayList<>(count); + double x = 0.0; + double y = 0.0; + double size = 1.0; + boxes.add(new AABB(x, y, 0.0, x + size, y + size, 1.0)); + + for (int i = 1; i < count; i++) { + double nextSize = 0.7 + random.nextDouble() * 0.6; + double step = Math.min(size, nextSize) * (0.45 + random.nextDouble() * 0.1); + x += step; + y += step; + boxes.add(new AABB(x, y, 0.0, x + nextSize, y + nextSize, 1.0)); + size = nextSize; + } + return boxes; + } + + private static void assertEquals(int expected, int actual, String label) { + if (expected != actual) { + throw new AssertionError(label + ": expected " + expected + ", got " + actual); + } + } + + private static void assertEquals(List expected, List actual, String label) { + if (!expected.equals(actual)) { + List missing = new ArrayList<>(expected); + missing.removeAll(actual); + List extra = new ArrayList<>(actual); + extra.removeAll(expected); + throw new AssertionError( + label + ": expected " + expected.size() + " lines, got " + actual.size() + + ", missing=" + missing + + ", extra=" + extra + ); + } + } + + private static void assertSameLines(List expected, List actual, String label) { + Set expectedSet = new HashSet<>(expected); + Set actualSet = new HashSet<>(actual); + if (!expectedSet.equals(actualSet)) { + Set missing = new HashSet<>(expectedSet); + missing.removeAll(actualSet); + Set extra = new HashSet<>(actualSet); + extra.removeAll(expectedSet); + throw new AssertionError( + label + ": expected " + expectedSet.size() + " lines, got " + actualSet.size() + + ", missing=" + missing + + ", extra=" + extra + ); + } + } + + private static void assertFalse(boolean value, String label) { + if (value) { + throw new AssertionError(label); + } + } + + private static void assertNotNull(Object value, String label) { + if (value == null) { + throw new AssertionError(label + " should not be null"); + } + } + + private record JoinTimingResult(VoxelShape joined, List outline) { + } + + private record OutlineTimingResult(List outline) { + } +} diff --git a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/WheelMenuBuilder.java b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/WheelMenuBuilder.java index 82fac9ae..e373716e 100644 --- a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/WheelMenuBuilder.java +++ b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/WheelMenuBuilder.java @@ -11,6 +11,8 @@ public final class WheelMenuBuilder { private final List entries = new ArrayList<>(); private int slotsPerPage = WheelMenuModel.DEFAULT_SLOTS_PER_PAGE; private int deadZone = 15; + private WheelSelectionEffect selectionEffect = WheelSelectionEffect.ANNULAR_SECTOR; + private int selectionEffectColor = WheelMenuModel.DEFAULT_SELECTION_EFFECT_COLOR; private WheelMenuBuilder() { } @@ -32,6 +34,16 @@ public WheelMenuBuilder deadZone(int deadZone){ return this; } + public WheelMenuBuilder selectionEffect(WheelSelectionEffect selectionEffect) { + this.selectionEffect = Objects.requireNonNull(selectionEffect, "selectionEffect"); + return this; + } + + public WheelMenuBuilder selectionEffectColor(int selectionEffectColor) { + this.selectionEffectColor = selectionEffectColor; + return this; + } + public WheelMenuBuilder action( String id, Component label, @@ -75,7 +87,13 @@ public WheelMenuBuilder submenu( } public WheelMenuModel build() { - return WheelMenuModel.of(List.copyOf(this.entries), this.slotsPerPage, this.deadZone); + return WheelMenuModel.of( + List.copyOf(this.entries), + this.slotsPerPage, + this.deadZone, + this.selectionEffect, + this.selectionEffectColor + ); } public static final class WheelSubmenuBuilder { diff --git a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/WheelMenuModel.java b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/WheelMenuModel.java index 3885e50a..b3fd3d4b 100644 --- a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/WheelMenuModel.java +++ b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/WheelMenuModel.java @@ -6,26 +6,56 @@ public final class WheelMenuModel { public static final int DEFAULT_SLOTS_PER_PAGE = 8; + public static final int DEFAULT_SELECTION_EFFECT_COLOR = 0xFFFABC02; private final List rootEntries; private final int slotsPerPage; private final int deadZone; + private final WheelSelectionEffect selectionEffect; + private final int selectionEffectColor; - private WheelMenuModel(List rootEntries, int slotsPerPage, int deadZone) { + private WheelMenuModel( + List rootEntries, + int slotsPerPage, + int deadZone, + WheelSelectionEffect selectionEffect, + int selectionEffectColor + ) { if (slotsPerPage < 1) { throw new IllegalArgumentException("slotsPerPage must be >= 1"); } this.rootEntries = List.copyOf(Objects.requireNonNull(rootEntries, "rootEntries")); this.slotsPerPage = slotsPerPage; this.deadZone = deadZone; + this.selectionEffect = Objects.requireNonNull(selectionEffect, "selectionEffect"); + this.selectionEffectColor = selectionEffectColor; } public static WheelMenuModel of(List rootEntries) { - return new WheelMenuModel(rootEntries, DEFAULT_SLOTS_PER_PAGE, 30); + return new WheelMenuModel(rootEntries, DEFAULT_SLOTS_PER_PAGE, 30, WheelSelectionEffect.DOT, DEFAULT_SELECTION_EFFECT_COLOR); } public static WheelMenuModel of(List rootEntries, int slotsPerPage, int deadZone) { - return new WheelMenuModel(rootEntries, slotsPerPage, deadZone); + return new WheelMenuModel(rootEntries, slotsPerPage, deadZone, WheelSelectionEffect.DOT, DEFAULT_SELECTION_EFFECT_COLOR); + } + + public static WheelMenuModel of( + List rootEntries, + int slotsPerPage, + int deadZone, + WheelSelectionEffect selectionEffect + ) { + return new WheelMenuModel(rootEntries, slotsPerPage, deadZone, selectionEffect, DEFAULT_SELECTION_EFFECT_COLOR); + } + + public static WheelMenuModel of( + List rootEntries, + int slotsPerPage, + int deadZone, + WheelSelectionEffect selectionEffect, + int selectionEffectColor + ) { + return new WheelMenuModel(rootEntries, slotsPerPage, deadZone, selectionEffect, selectionEffectColor); } public List rootEntries() { @@ -40,6 +70,14 @@ public int deadZone() { return this.deadZone; } + public WheelSelectionEffect selectionEffect() { + return this.selectionEffect; + } + + public int selectionEffectColor() { + return this.selectionEffectColor; + } + public int pageCount(List entries) { List safeEntries = Objects.requireNonNull(entries, "entries"); return WheelPagination.pageCount(safeEntries.size(), this.slotsPerPage); diff --git a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/WheelSelectionEffect.java b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/WheelSelectionEffect.java new file mode 100644 index 00000000..4b3e32bb --- /dev/null +++ b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/WheelSelectionEffect.java @@ -0,0 +1,6 @@ +package dev.anvilcraft.lib.v2.wheel.api; + +public enum WheelSelectionEffect { + DOT, + ANNULAR_SECTOR +} diff --git a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/package-info.java b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/package-info.java index 31324d72..cf574937 100644 --- a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/package-info.java +++ b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/api/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.wheel.api; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/component/WheelFrostedBackground.java b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/component/WheelFrostedBackground.java new file mode 100644 index 00000000..044de8cc --- /dev/null +++ b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/component/WheelFrostedBackground.java @@ -0,0 +1,103 @@ +package dev.anvilcraft.lib.v2.wheel.client.gui.component; + +import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.blaze3d.pipeline.TextureTarget; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.BufferUploader; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.Tesselator; +import com.mojang.blaze3d.vertex.VertexFormat; +import dev.anvilcraft.lib.v2.wheel.client.init.LibShaders; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.renderer.ShaderInstance; +import org.jetbrains.annotations.ApiStatus; +import org.joml.Matrix4f; + +import java.util.Objects; + +/** + * 轮盘毛玻璃盘面背景:每帧对主渲染目标做一次可分离高斯模糊,供盘面着色器采样。 + * 两个纹理目标乒乓交替,水平/垂直各模糊两轮共四 pass,与 dev/26.1 的 GaussianBlur(2.0f, 1.0f) 行为一致。 + */ +@ApiStatus.Internal +@Slf4j +public final class WheelFrostedBackground implements AutoCloseable { + private static final float SAMPLE_STEP_LENGTH = 2.0f; + + private TextureTarget firstTarget = new TextureTarget(2, 2, true, true); + private TextureTarget secondTarget = new TextureTarget(2, 2, true, true); + private int width; + private int height; + + /** + * 对当前主渲染目标执行模糊,返回模糊结果纹理 id;blur shader 未就绪时返回 -1。 + */ + public int capture(GuiGraphics guiGraphics) { + RenderTarget mainTarget = Minecraft.getInstance().getMainRenderTarget(); + this.guardSize(mainTarget); + return this.blur(guiGraphics, mainTarget.getColorTextureId()); + } + + private int blur(GuiGraphics guiGraphics, int inputTextureId) { + this.blurOnce(guiGraphics, this.secondTarget, inputTextureId, 1.0f, 0.0f); + this.blurOnce(guiGraphics, this.firstTarget, this.secondTarget.getColorTextureId(), 0.0f, 1.0f); + this.blurOnce(guiGraphics, this.secondTarget, this.firstTarget.getColorTextureId(), 1.0f, 0.0f); + this.blurOnce(guiGraphics, this.firstTarget, this.secondTarget.getColorTextureId(), 0.0f, 1.0f); + return this.firstTarget.getColorTextureId(); + } + + private void blurOnce( + GuiGraphics guiGraphics, + TextureTarget target, + int inputTextureId, + float directionX, + float directionY + ) { + ShaderInstance blurShader = LibShaders.getBlurShader(); + if (blurShader == null) { + log.error("Wheel frosted background blur shader is not ready"); + return; + } + target.bindWrite(false); + RenderSystem.setShaderTexture(0, inputTextureId); + Matrix4f matrix4f = guiGraphics.pose().last().pose(); + float w = (float) Minecraft.getInstance().getWindow().getGuiScaledWidth(); + float h = (float) Minecraft.getInstance().getWindow().getGuiScaledHeight(); + Tesselator tesselator = Tesselator.getInstance(); + BufferBuilder bufferBuilder = tesselator.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_TEX); + // 绘制到 FBO 时 GUI 顶部 (y=0) 对应纹理 v=1(纹理数据顶部),此处做 V 轴翻转 + bufferBuilder.addVertex(matrix4f, 0, 0, 0).setUv(0, 1); + bufferBuilder.addVertex(matrix4f, 0, h, 0).setUv(0, 0); + bufferBuilder.addVertex(matrix4f, w, h, 0).setUv(1, 0); + bufferBuilder.addVertex(matrix4f, w, 0, 0).setUv(1, 1); + RenderSystem.disableDepthTest(); + RenderSystem.setShader(() -> blurShader); + blurShader.safeGetUniform("Direction").set(directionX, directionY); + blurShader.safeGetUniform("SampleStepLength").set(SAMPLE_STEP_LENGTH); + RenderSystem.setShaderColor(1, 1, 1, 1); + BufferUploader.drawWithShader(Objects.requireNonNull(bufferBuilder.build())); + RenderSystem.enableDepthTest(); + Minecraft.getInstance().getMainRenderTarget().bindWrite(false); + } + + private void guardSize(RenderTarget mainTarget) { + if (mainTarget.width == this.width && mainTarget.height == this.height) { + return; + } + this.width = mainTarget.width; + this.height = mainTarget.height; + this.firstTarget.destroyBuffers(); + this.secondTarget.destroyBuffers(); + this.firstTarget = new TextureTarget(this.width, this.height, true, true); + this.secondTarget = new TextureTarget(this.width, this.height, true, true); + } + + @Override + public void close() { + this.firstTarget.destroyBuffers(); + this.secondTarget.destroyBuffers(); + } +} diff --git a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/component/WheelWidget.java b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/component/WheelWidget.java index e1dcaf2d..73f5995a 100644 --- a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/component/WheelWidget.java +++ b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/component/WheelWidget.java @@ -9,9 +9,11 @@ import com.mojang.blaze3d.vertex.Tesselator; import com.mojang.blaze3d.vertex.VertexFormat; import dev.anvilcraft.lib.v2.util.MathUtil; +import dev.anvilcraft.lib.v2.wheel.api.WheelSelectionEffect; import dev.anvilcraft.lib.v2.wheel.client.init.LibShaders; import lombok.Getter; import lombok.Setter; +import lombok.extern.slf4j.Slf4j; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.AbstractWidget; @@ -19,6 +21,7 @@ import net.minecraft.client.renderer.GameRenderer; import net.minecraft.client.renderer.ShaderInstance; import net.minecraft.network.chat.Component; +import net.minecraft.util.Mth; import org.joml.Matrix4f; import org.joml.Vector2f; @@ -27,11 +30,47 @@ import java.util.Objects; import javax.annotation.Nullable; +@Slf4j +@SuppressWarnings( + { + "UnusedReturnValue", + "unused" + } +) public class WheelWidget extends AbstractWidget { public static final int IGNORE_CURSOR_MOVE_LENGTH = 15; + public static final int DEFAULT_SELECTION_EFFECT_COLOR = 0xFFFABC02; + private static final float SELECTION_DOT_DIAMETER_RATIO = 0.9f; + private static final float TAU = (float) (Math.PI * 2.0); + private static final float ANGLE_AA_RAD = 0.06f; + private static final Vector2f ROTATION_START = new Vector2f(0, 1); + // 中心区域与扇区之间的白色不透明分隔圆环:半径在中心区基础上外扩的距离与线宽 + // (dev/26.1 的 SdfGraphics.stroke 实际厚度为入参一半) + private static final float SEPARATOR_RING_GAP = 4f; + private static final float SEPARATOR_RING_THICKNESS = 2f; + // 高亮扇区内缘相对中心区的内缩量(贴合分隔圆环外侧) + private static final float SECTION_INNER_INSET = 8f; + // 未停住时高亮扇区外缘相对盘面外边缘的内缩量;停住后内外缘同步外扩 SETTLE_EXPAND 距离 + private static final float SECTION_OUTER_INSET = 2f; + // 鼠标停住后高亮扇区内外缘同步向外扩张的距离与动画参数 + private static final float SETTLE_EXPAND = 2f; + private static final long SETTLE_DELAY_MS = 500L; + private static final long SETTLE_ANIM_MS = 200L; + // 指向鼠标方向的尖括号箭头(贴在分隔圆环内侧)尺寸与线宽 + private static final float HOVER_CHEVRON_SIZE = 9f; + private static final float HOVER_CHEVRON_THICKNESS = 4f; + private static final float HOVER_CHEVRON_RING_GAP = 4f; + // 中心悬停名缩放倍率 + private static final float CENTER_TITLE_SCALE = 1.4f; + // 翻页箭头(尖括号)尺寸与线宽 + private static final float CHEVRON_SIZE = 8f; + private static final float CHEVRON_THICKNESS = 3f; + // 毛玻璃盘面叠加色:白 * 40% 透明度,混合在深色盘面上 + private static final int FROSTED_TINT = 0x66FFFFFF; + // 绘制深度分层:盘面/毛玻璃 < 分隔圆环 < 高亮 < 图标 + private static final float DISC_Z = 50f; private static final float RING_Z = 60f; private static final float SELECTION_Z = 80f; - private static final Vector2f ROTATION_START = new Vector2f(0, 1); private final Minecraft minecraft = Minecraft.getInstance(); private final Vector2f centerPos; @@ -41,8 +80,7 @@ public class WheelWidget extends AbstractWidget { private final int animationMs; // ms private final int closingAnimationMs; // ms private final int ringColor; - private final int selectionEffectColor; - private final int selectionEffectRadius; + private int selectionEffectColor; private final float selectionAnimationSpeedFactor; private final int textColor; private final float textScale; @@ -52,113 +90,219 @@ public class WheelWidget extends AbstractWidget { private float currentAngle = 0; @Getter private int currentSectionIndex = -1; + // 环扇高亮当前渲染角度(弧度,轮盘坐标系),每帧向目标扇区平滑逼近 + private float selectionAngleRad = 0; + // 最近一次选中扇区变化的时间,用于“鼠标停住”检测 + private long selectionChangeTime = System.currentTimeMillis(); + // 鼠标相对轮盘中心的方向角(弧度,与 WheelSection.angle 同坐标系),在死区内时为 null + @Nullable + private Float mouseAngleRad; private Vector2f selectionEffectPos; private boolean animationStarted = false; @Getter @Setter private boolean closingAnimationStarted = false; private final int deadZone; + private WheelSelectionEffect selectionEffect = WheelSelectionEffect.DOT; + @Nullable + private WheelFrostedBackground frostedBackground; + private boolean hasPreviousPage; + private boolean hasNextPage; public WheelWidget( - int x, int y, int width, int height, - float ringInnerRadius, float ringOuterRadius, float textScale, - List sections, int deadZone + int x, + int y, + int width, + int height, + float ringInnerRadius, + float ringOuterRadius, + float textScale, + List sections, + int deadZone ) { this(x, y, width, height, Component.empty(), ringInnerRadius, ringOuterRadius, textScale, sections, deadZone); } public WheelWidget( - int x, int y, int width, int height, - float ringInnerRadius, float ringOuterRadius, float textScale, float degreeOffsetAngle, - List sections, int deadZone + int x, + int y, + int width, + int height, + float ringInnerRadius, + float ringOuterRadius, + float textScale, + float degreeOffsetAngle, + List sections, + int deadZone ) { this(x, y, width, height, Component.empty(), ringInnerRadius, ringOuterRadius, textScale, degreeOffsetAngle, sections, deadZone); } - public WheelWidget( - int x, int y, int width, int height, - float ringInnerRadius, float ringOuterRadius, - List sections, int deadZone - ) { + public WheelWidget(int x, int y, int width, int height, float ringInnerRadius, float ringOuterRadius, List sections, int deadZone) { this(x, y, width, height, Component.empty(), ringInnerRadius, ringOuterRadius, sections, deadZone); } public WheelWidget( - int x, int y, int width, int height, Component message, - float ringInnerRadius, float ringOuterRadius, float textScale, float degreeOffsetAngle, - List sections, int deadZone + int x, + int y, + int width, + int height, + Component message, + float ringInnerRadius, + float ringOuterRadius, + float textScale, + float degreeOffsetAngle, + List sections, + int deadZone ) { this( - x, y, width, height, message, - ringInnerRadius, ringOuterRadius, - 150, 300, 150, + x, + y, + width, + height, + message, + ringInnerRadius, + ringOuterRadius, + 150, + 300, + 150, 0x88000000, - 0xddffff00, 20, 5f, - 0xfdfdfd, textScale, degreeOffsetAngle, - sections, deadZone + DEFAULT_SELECTION_EFFECT_COLOR, + 5f, + 0xfdfdfd, + textScale, + degreeOffsetAngle, + sections, + deadZone ); } public WheelWidget( - int x, int y, int width, int height, Component message, - float ringInnerRadius, float ringOuterRadius, - List sections, int deadZone + int x, + int y, + int width, + int height, + Component message, + float ringInnerRadius, + float ringOuterRadius, + List sections, + int deadZone ) { this( - x, y, width, height, message, - ringInnerRadius, ringOuterRadius, - 150, 300, 150, + x, + y, + width, + height, + message, + ringInnerRadius, + ringOuterRadius, + 150, + 300, + 150, 0x88000000, - 0xddffff00, 20, 5f, - 0xfdfdfd, 1f, 0f, - sections, deadZone + DEFAULT_SELECTION_EFFECT_COLOR, + 5f, + 0xfdfdfd, + 1f, + 0f, + sections, + deadZone ); } public WheelWidget( - int x, int y, int width, int height, Component message, - float ringInnerRadius, float ringOuterRadius, float degreeOffsetAngle, - List sections, int deadZone + int x, + int y, + int width, + int height, + Component message, + float ringInnerRadius, + float ringOuterRadius, + float degreeOffsetAngle, + List sections, + int deadZone ) { this( - x, y, width, height, message, - ringInnerRadius, ringOuterRadius, - 150, 300, 150, + x, + y, + width, + height, + message, + ringInnerRadius, + ringOuterRadius, + 150, + 300, + 150, 0x88000000, - 0xddffff00, 20, 5f, - 0xfdfdfd, 1f, degreeOffsetAngle, - sections, deadZone + DEFAULT_SELECTION_EFFECT_COLOR, + 5f, + 0xfdfdfd, + 1f, + degreeOffsetAngle, + sections, + deadZone ); } public WheelWidget( - int x, int y, int width, int height, - float ringInnerRadius, float ringOuterRadius, - int delay, int animationMs, int closingAnimationMs, + int x, + int y, + int width, + int height, + float ringInnerRadius, + float ringOuterRadius, + int delay, + int animationMs, + int closingAnimationMs, int ringColor, - int selectionEffectColor, int selectionEffectRadius, float selectionAnimationSpeedFactor, - int textColor, float textScale, - List sections, int deadZone + int selectionEffectColor, + float selectionAnimationSpeedFactor, + int textColor, + float textScale, + List sections, + int deadZone ) { this( - x, y, width, height, Component.empty(), - ringInnerRadius, ringOuterRadius, - delay, animationMs, closingAnimationMs, + x, + y, + width, + height, + Component.empty(), + ringInnerRadius, + ringOuterRadius, + delay, + animationMs, + closingAnimationMs, ringColor, - selectionEffectColor, selectionEffectRadius, selectionAnimationSpeedFactor, - textColor, textScale, 0f, - sections, deadZone + selectionEffectColor, + selectionAnimationSpeedFactor, + textColor, + textScale, + 0f, + sections, + deadZone ); } public WheelWidget( - int x, int y, int width, int height, Component message, - float ringInnerRadius, float ringOuterRadius, - int delay, int animationMs, int closingAnimationMs, + int x, + int y, + int width, + int height, + Component message, + float ringInnerRadius, + float ringOuterRadius, + int delay, + int animationMs, + int closingAnimationMs, int ringColor, - int selectionEffectColor, int selectionEffectRadius, float selectionAnimationSpeedFactor, - int textColor, float textScale, float degreeOffsetAngle, - List sections, int deadZone + int selectionEffectColor, + float selectionAnimationSpeedFactor, + int textColor, + float textScale, + float degreeOffsetAngle, + List sections, + int deadZone ) { super(x, y, width, height, message); this.centerPos = new Vector2f(this.getX() + this.getWidth() / 2f, this.getY() + this.getHeight() / 2f); @@ -169,7 +313,6 @@ public WheelWidget( this.closingAnimationMs = closingAnimationMs; this.ringColor = ringColor; this.selectionEffectColor = selectionEffectColor; - this.selectionEffectRadius = selectionEffectRadius; this.selectionAnimationSpeedFactor = selectionAnimationSpeedFactor; this.textColor = textColor; this.textScale = textScale; @@ -180,7 +323,7 @@ public WheelWidget( float rotation = MathUtil.clampWithProportion((degreeEachRotation * i + degreeOffsetAngle) % 360, 0, 360); Vector2f rotated = MathUtil.rotationDegrees(ROTATION_START, rotation) .mul(1, -1) - .mul(this.getSectionCircleDiameter()) + .mul(this.getSectionCircleRadius()) .add(this.centerPos); float detectionStart = (float) (Math.toRadians(rotation - degreeEachRotation / 2f) + Math.PI * 2); float detectionEnd = (float) (Math.toRadians(rotation + degreeEachRotation / 2f) + Math.PI * 2); @@ -194,33 +337,251 @@ public WheelWidget( section )); } - this.selectionEffectPos = MathUtil.rotate( - MathUtil.copy(ROTATION_START) - .mul(this.getSectionCircleDiameter()), - this.currentAngle + this.selectionEffectPos = MathUtil.rotate(MathUtil.copy(ROTATION_START).mul(this.getSectionCircleRadius()), this.currentAngle); + } + + public void renderDisc( + GuiGraphics guiGraphics, + float progress + ) { + this.renderDisc(guiGraphics, this.centerPos.x, this.centerPos.y, this.getDiscRadius() * progress, this.ringColor); + } + + public void renderFrostedBackground( + GuiGraphics guiGraphics, + float progress + ) { + if (this.frostedBackground == null) { + return; + } + try { + Window window = this.minecraft.getWindow(); + float guiScale = (float) window.getGuiScale(); + float radius = this.getDiscRadius() * progress * guiScale; + if (radius <= 0) { + return; + } + int blurredTextureId = this.frostedBackground.capture(guiGraphics); + if (blurredTextureId < 0) { + return; + } + Matrix4f matrix4f = guiGraphics.pose().last().pose(); + float w = (float) window.getGuiScaledWidth(); + float h = (float) window.getGuiScaledHeight(); + Tesselator tesselator = Tesselator.getInstance(); + BufferBuilder bufferBuilder = tesselator.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_TEX_COLOR); + // 全屏 quad:GUI 顶部 (y=0) 对应纹理 v=1(纹理数据顶部),做 V 轴翻转 + bufferBuilder.addVertex(matrix4f, 0, 0, DISC_Z).setUv(0, 1).setColor(FROSTED_TINT); + bufferBuilder.addVertex(matrix4f, 0, h, DISC_Z).setUv(0, 0).setColor(FROSTED_TINT); + bufferBuilder.addVertex(matrix4f, w, h, DISC_Z).setUv(1, 0).setColor(FROSTED_TINT); + bufferBuilder.addVertex(matrix4f, w, 0, DISC_Z).setUv(1, 1).setColor(FROSTED_TINT); + RenderSystem.disableDepthTest(); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + ShaderInstance frostedDiscShader = LibShaders.getFrostedDiscShader(); + if (frostedDiscShader == null) { + RenderSystem.disableBlend(); + RenderSystem.enableDepthTest(); + log.error("Wheel frosted disc shader is not ready"); + return; + } + RenderSystem.setShaderTexture(0, blurredTextureId); + RenderSystem.setShader(() -> frostedDiscShader); + frostedDiscShader.safeGetUniform("Center").set(this.centerPos.x * guiScale, this.centerPos.y * guiScale); + frostedDiscShader.safeGetUniform("FramebufferSize").set((float) window.getWidth(), (float) window.getHeight()); + frostedDiscShader.safeGetUniform("Radius").set(radius); + frostedDiscShader.safeGetUniform("AntiAliasingRadius").set(1.25f); + RenderSystem.setShaderColor(1, 1, 1, 1); + BufferUploader.drawWithShader(Objects.requireNonNull(bufferBuilder.build())); + RenderSystem.disableBlend(); + RenderSystem.enableDepthTest(); + } catch (Exception e) { + RenderSystem.disableBlend(); + RenderSystem.enableDepthTest(); + log.error("Wheel frosted background failed to render", e); + } + } + + public void renderSeparatorRing( + GuiGraphics guiGraphics, + float progress + ) { + // SdfGraphics.stroke 实际厚度为入参一半,此处直接以厚度/2 作为环带半宽 + float radius = (this.ringInnerRadius + SEPARATOR_RING_GAP) * progress; + float halfThickness = SEPARATOR_RING_THICKNESS * 0.5f; + WheelWidget.renderRing( + guiGraphics, + this.centerPos.x, + this.centerPos.y, + 0xFFFFFFFF, + radius - halfThickness, + radius + halfThickness + ); + } + + public void renderSelectionEffect(GuiGraphics guiGraphics, float centerX, float centerY, int color, float ringWidth) { + float dotDiameter = this.getSelectionDotDiameter(ringWidth) * 2.0f; + float dotRadius = dotDiameter * 0.5f; + Window window = this.minecraft.getWindow(); + float guiScale = (float) window.getGuiScale(); + Matrix4f matrix4f = guiGraphics.pose().last().pose(); + Tesselator tesselator = Tesselator.getInstance(); + BufferBuilder bufferBuilder = tesselator.begin( + VertexFormat.Mode.QUADS, + DefaultVertexFormat.POSITION_COLOR + ); + float x1 = centerX - dotRadius - 5; + float y1 = centerY - dotRadius - 5; + float x2 = centerX + dotRadius + 5; + float y2 = centerY + dotRadius + 5; + bufferBuilder.addVertex(matrix4f, x1, y1, SELECTION_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, x1, y2, SELECTION_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, x2, y2, SELECTION_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, x2, y1, SELECTION_Z).setColor(color); + RenderSystem.disableDepthTest(); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + ShaderInstance selectionShader = LibShaders.getSelectionShader(); + if (selectionShader == null) { + this.renderSelectionFallback(guiGraphics, centerX, centerY, color, dotRadius); + RenderSystem.disableBlend(); + RenderSystem.enableDepthTest(); + return; + } + RenderSystem.setShader(() -> selectionShader); + selectionShader + .safeGetUniform("Center") + .set(centerX * guiScale, centerY * guiScale); + selectionShader + .safeGetUniform("FramebufferSize") + .set((float) window.getWidth(), (float) window.getHeight()); + selectionShader + .safeGetUniform("Radius") + .set(dotRadius * guiScale); + RenderSystem.setShaderColor(1, 1, 1, 1); + BufferUploader.drawWithShader(Objects.requireNonNull(bufferBuilder.build())); + RenderSystem.disableBlend(); + RenderSystem.enableDepthTest(); + } + + public void renderAnnularSectorSelection( + GuiGraphics guiGraphics, + float centerX, + float centerY, + int color, + float innerRadius, + float outerRadius, + float centerAngleRad, + float rangeAngleRad + ) { + float boundsRadius = outerRadius + 5; + float x1 = centerX - boundsRadius; + float y1 = centerY - boundsRadius; + float x2 = centerX + boundsRadius; + float y2 = centerY + boundsRadius; + Window window = this.minecraft.getWindow(); + float guiScale = (float) window.getGuiScale(); + // Wheel section angle uses "up" as zero; shader atan uses +X as zero. + float shaderCenterAngle = centerAngleRad + TAU / 4.0f; + Matrix4f matrix4f = guiGraphics.pose().last().pose(); + Tesselator tesselator = Tesselator.getInstance(); + BufferBuilder bufferBuilder = tesselator.begin( + VertexFormat.Mode.QUADS, + DefaultVertexFormat.POSITION_COLOR ); + bufferBuilder.addVertex(matrix4f, x1, y1, SELECTION_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, x1, y2, SELECTION_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, x2, y2, SELECTION_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, x2, y1, SELECTION_Z).setColor(color); + RenderSystem.disableDepthTest(); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + ShaderInstance annularSectorShader = LibShaders.getAnnularSectorShader(); + if (annularSectorShader == null) { + this.renderAnnularSectorFallback( + guiGraphics, + centerX, + centerY, + color, + innerRadius, + outerRadius, + centerAngleRad, + rangeAngleRad + ); + RenderSystem.disableBlend(); + RenderSystem.enableDepthTest(); + return; + } + RenderSystem.setShader(() -> annularSectorShader); + annularSectorShader + .safeGetUniform("Center") + .set(centerX * guiScale, centerY * guiScale); + annularSectorShader + .safeGetUniform("InnerRadius") + .set(innerRadius * guiScale); + annularSectorShader + .safeGetUniform("OuterRadius") + .set(outerRadius * guiScale); + annularSectorShader + .safeGetUniform("AntiAliasingRadius") + .set(1.25f); + annularSectorShader + .safeGetUniform("AngleAntiAliasingRad") + .set(ANGLE_AA_RAD); + annularSectorShader + .safeGetUniform("CenterAngleRad") + .set(shaderCenterAngle); + annularSectorShader + .safeGetUniform("RangeAngleRad") + .set(rangeAngleRad); + RenderSystem.setShaderColor(1, 1, 1, 1); + BufferUploader.drawWithShader(Objects.requireNonNull(bufferBuilder.build())); + RenderSystem.disableBlend(); + RenderSystem.enableDepthTest(); + } + + public WheelWidget setSelectionEffect(WheelSelectionEffect selectionEffect) { + this.selectionEffect = Objects.requireNonNull(selectionEffect, "selectionEffect"); + return this; + } + + public WheelWidget setSelectionEffectColor(int selectionEffectColor) { + this.selectionEffectColor = selectionEffectColor; + return this; + } + + public WheelWidget setFrostedBackground(@Nullable WheelFrostedBackground frostedBackground) { + this.frostedBackground = frostedBackground; + return this; + } + + public WheelWidget setPageState(boolean hasPreviousPage, boolean hasNextPage) { + this.hasPreviousPage = hasPreviousPage; + this.hasNextPage = hasNextPage; + return this; } public WheelWidget setCurrentIndex(int index) { if (index < 0 || index >= this.sections.size()) return this; if (!this.sections.get(index).selectable()) return this; - this.currentSectionIndex = index; + this.setCurrentSectionIndex(index); this.currentAngle = this.sections.get(index).angle; - this.selectionEffectPos = MathUtil.rotate( - MathUtil.copy(ROTATION_START) - .mul(this.getSectionCircleDiameter()), - this.currentAngle - ); + this.selectionEffectPos = MathUtil.rotate(MathUtil.copy(ROTATION_START).mul(this.getSectionCircleRadius()), this.currentAngle); return this; } public WheelWidget clearSelection() { - this.currentSectionIndex = -1; + this.setCurrentSectionIndex(-1); return this; } - public float getSectionCircleDiameter() { - return this.ringOuterRadius - this.ringInnerRadius + this.ringInnerRadius * 2; + public float getSectionCircleRadius() { + // 图标圆心所在圆周的半径:分隔圆环与盘面外缘之间扇区的中间位置 + return (this.ringOuterRadius + this.ringInnerRadius) * 0.5f; + } + + private float getDiscRadius() { + return this.ringOuterRadius; } public int getSectionSize() { @@ -239,9 +600,9 @@ public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, doubl } if (scrollY > 0) { - this.currentSectionIndex = this.findNextSelectableIndex(index, 1); + this.setCurrentSectionIndex(this.findNextSelectableIndex(index, 1)); } else if (scrollY < 0) { - this.currentSectionIndex = this.findNextSelectableIndex(index, -1); + this.setCurrentSectionIndex(this.findNextSelectableIndex(index, -1)); } for (WheelSection section : this.sections) { @@ -259,27 +620,34 @@ public void checkMousePos(double mouseX, double mouseY) { float centerY = this.centerPos.y; Vector2f cursorPos = new Vector2f((float) mouseX - centerX, (float) mouseY - centerY); if (cursorPos.length() < this.deadZone) { - this.currentSectionIndex = -1; + this.mouseAngleRad = null; + this.setCurrentSectionIndex(-1); return; } Vector2f rotationStart = new Vector2f(0, 1); cursorPos.normalize(); double rot = Math.acos(rotationStart.dot(cursorPos) / (rotationStart.length() * cursorPos.length())); double rotation = cursorPos.x < 0 ? Math.PI - rot : Math.PI + rot; + this.mouseAngleRad = (float) (rotation % (Math.PI * 2)); for (WheelSection section : this.sections) { if (( - section.angleStart > section.angleEnd && rotation >= section.angleStart - || rotation >= section.angleStart && rotation <= section.angleEnd - ) - && section.selectable - ) { + section.angleStart > section.angleEnd && rotation >= section.angleStart || rotation >= section.angleStart && rotation <= section.angleEnd + ) && section.selectable) { this.currentAngle = section.angle; - this.currentSectionIndex = this.sections.indexOf(section); + this.setCurrentSectionIndex(this.sections.indexOf(section)); break; } } } + private void setCurrentSectionIndex(int index) { + if (this.currentSectionIndex == index) { + return; + } + this.currentSectionIndex = index; + this.selectionChangeTime = System.currentTimeMillis(); + } + private int findNextSelectableIndex(int start, int direction) { if (this.sections.isEmpty()) { return -1; @@ -307,8 +675,6 @@ public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partia @Override protected void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { - RenderSystem.enableDepthTest(); - RenderSystem.enableBlend(); this.renderClosingAnimation(guiGraphics); if (!this.shouldRender()) { return; @@ -327,14 +693,11 @@ protected void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, flo this.renderProgressAnimation(guiGraphics, progress); return; } - WheelWidget.renderRing( - guiGraphics, - this.centerPos.x, - this.centerPos.y, - this.ringColor, - this.ringInnerRadius * 2, - this.ringOuterRadius * 2 - ); + + this.renderFrostedBackground(guiGraphics, 1f); + this.renderDisc(guiGraphics, 1f); + this.renderSeparatorRing(guiGraphics, 1f); + this.renderHoverChevron(guiGraphics); if (this.currentSectionIndex != -1) { this.renderSelection(guiGraphics); } @@ -344,35 +707,18 @@ protected void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, flo var renderer = value.renderer(); if (renderer != null) { poseStack.pushPose(); - poseStack.translate(x - 10, y - 10, 100); - renderer.render(guiGraphics, poseStack, 20, 20); + poseStack.translate(x, y, 0); + int renderSize = this.getRendererSize(this.ringOuterRadius - this.ringInnerRadius); + renderer.render(guiGraphics, poseStack, renderSize, renderSize); poseStack.popPose(); } - poseStack.pushPose(); - float coordinateScale = 0.7f; - float offsetX = 0.1f * this.width; - float offsetY = 0.1f * this.height; - float adjustedX = (x - offsetX) / coordinateScale; - float adjustedY = (y - offsetY - 20 * this.textScale) / coordinateScale; - - poseStack.translate(offsetX, offsetY, 0); - poseStack.scale(coordinateScale, coordinateScale, coordinateScale); - poseStack.translate(adjustedX, adjustedY, 0); - if (renderer == null) { - poseStack.translate(0, ((this.selectionEffectRadius / 2.0f) + minecraft.font.lineHeight) * this.textScale, 0); - } - poseStack.scale(this.textScale / coordinateScale, this.textScale / coordinateScale, this.textScale / coordinateScale); - guiGraphics.drawCenteredString( - minecraft.font, - value.subTitle, - 0, - 0, - (0xff << 24) | this.textColor - ); - poseStack.popPose(); } - RenderSystem.disableDepthTest(); - RenderSystem.disableBlend(); + this.renderCenterTitle(guiGraphics); + this.renderPageArrows(guiGraphics); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { } public void renderClosingAnimation(GuiGraphics guiGraphics) { @@ -390,74 +736,81 @@ private void renderProgressAnimation(GuiGraphics guiGraphics, float progress) { if (progress == 0) return; PoseStack poseStack = guiGraphics.pose(); poseStack.pushPose(); - WheelWidget.renderRing( - guiGraphics, - this.centerPos.x, - this.centerPos.y, - this.ringColor, - this.ringInnerRadius * 2 * progress, - this.ringOuterRadius * 2 * progress - ); + this.renderFrostedBackground(guiGraphics, progress); + this.renderDisc(guiGraphics, progress); + this.renderSeparatorRing(guiGraphics, progress); poseStack.popPose(); if (this.currentSectionIndex != -1) { - WheelSection section = this.sections.get(this.currentSectionIndex); - Vector2f center = new Vector2f( - (section.center.x - this.centerPos.x) / this.getSectionCircleDiameter(), - (section.center.y - this.centerPos.y) / this.getSectionCircleDiameter() - ).mul(this.getSectionCircleDiameter() * progress).add(this.centerPos.x, this.centerPos.y); - WheelWidget.renderSelectionEffect( - guiGraphics, - center.x, - center.y, - this.selectionEffectColor, - this.selectionEffectRadius - ); + if (this.selectionEffect == WheelSelectionEffect.ANNULAR_SECTOR) { + WheelSection section = this.sections.get(this.currentSectionIndex); + float rangeAngle = this.normalizePositiveAngle(section.angleEnd - section.angleStart) / 2.0f; + float settle = this.settleProgress(); + float expand = settle * SETTLE_EXPAND; + this.renderAnnularSectorSelection( + guiGraphics, + this.centerPos.x, + this.centerPos.y, + this.getSelectionSectorColor(settle), + (this.ringInnerRadius + SECTION_INNER_INSET + expand) * progress, + (this.ringOuterRadius - SECTION_OUTER_INSET + expand) * progress, + section.angle, + rangeAngle + ); + } else { + WheelSection section = this.sections.get(this.currentSectionIndex); + Vector2f center = new Vector2f( + (section.center.x - this.centerPos.x) / this.getSectionCircleRadius(), + (section.center.y - this.centerPos.y) / this.getSectionCircleRadius() + ).mul(this.getSectionCircleRadius() * progress).add(this.centerPos.x, this.centerPos.y); + this.renderSelectionEffect( + guiGraphics, + center.x, + center.y, + this.selectionEffectColor, + (this.ringOuterRadius - this.ringInnerRadius) * progress + ); + } } for (WheelSection value : this.sections) { Vector2f center = new Vector2f( - (value.center.x - this.centerPos.x) / this.getSectionCircleDiameter(), - (value.center.y - this.centerPos.y) / this.getSectionCircleDiameter() - ).mul(this.getSectionCircleDiameter() * progress).add(this.centerPos.x, this.centerPos.y); + (value.center.x - this.centerPos.x) / this.getSectionCircleRadius(), + (value.center.y - this.centerPos.y) / this.getSectionCircleRadius() + ).mul(this.getSectionCircleRadius() * progress).add(this.centerPos.x, this.centerPos.y); float x = center.x; float y = center.y; var renderer = value.renderer(); if (renderer != null) { poseStack.pushPose(); - poseStack.translate(x - 10, y - 10, 100); - renderer.render(guiGraphics, poseStack, 20, 20); + poseStack.translate(x, y, 0); + int renderSize = this.getRendererSize((this.ringOuterRadius - this.ringInnerRadius) * progress); + renderer.render(guiGraphics, poseStack, renderSize, renderSize); poseStack.popPose(); } - final int textAlpha = (int) (progress * 0xff) << 24; - poseStack.pushPose(); - float coordinateScale = 0.7f; - float offsetX = 0.1f * this.width; - float offsetY = 0.1f * this.height; - float adjustedX = (x - offsetX) / coordinateScale; - float adjustedY = (y - offsetY - 20 * this.textScale) / coordinateScale; - - poseStack.translate(offsetX, offsetY, 0); - poseStack.scale(coordinateScale, coordinateScale, coordinateScale); - poseStack.translate(adjustedX, adjustedY, 0); - if (renderer == null) { - poseStack.translate(0, ((this.selectionEffectRadius / 2.0f) + minecraft.font.lineHeight) * this.textScale, 0); + } + if (this.currentSectionIndex != -1) { + WheelSection section = this.sections.get(this.currentSectionIndex); + Component title = section.subTitle(); + if (title != null && !title.getString().isEmpty()) { + final int textAlpha = (int) (progress * 0xff) << 24; + final int shadowAlpha = (int) (progress * 0x99) << 24; + poseStack.pushPose(); + poseStack.translate(this.centerPos.x, this.centerPos.y, 0); + float scale = this.textScale * CENTER_TITLE_SCALE; + poseStack.scale(scale, scale, 1f); + int textY = -this.minecraft.font.lineHeight / 2; + guiGraphics.drawCenteredString(this.minecraft.font, title, 1, textY + 1, shadowAlpha); + guiGraphics.drawCenteredString(this.minecraft.font, title, 0, textY, textAlpha | 0xfdfdfd); + poseStack.popPose(); } - poseStack.scale(this.textScale / coordinateScale, this.textScale / coordinateScale, this.textScale / coordinateScale); - guiGraphics.drawCenteredString( - this.minecraft.font, - value.subTitle, - 0, - 0, - textAlpha | 0xfdfdfd - ); - poseStack.popPose(); } } private void renderSelection(GuiGraphics guiGraphics) { - float selectionEffectAngle = MathUtil.angle( - MathUtil.copy(ROTATION_START), - this.selectionEffectPos - ); + if (this.selectionEffect == WheelSelectionEffect.ANNULAR_SECTOR) { + this.renderSelectionAnnularSector(guiGraphics); + return; + } + float selectionEffectAngle = MathUtil.angle(MathUtil.copy(ROTATION_START), this.selectionEffectPos); float diffAngle = this.currentAngle - selectionEffectAngle; @@ -467,24 +820,198 @@ private void renderSelection(GuiGraphics guiGraphics) { diffAngle += (float) (Math.PI * 2); } - this.selectionEffectPos = MathUtil.rotate( - this.selectionEffectPos, - diffAngle / this.selectionAnimationSpeedFactor - ); + this.selectionEffectPos = MathUtil.rotate(this.selectionEffectPos, diffAngle / this.selectionAnimationSpeedFactor); - Vector2f pos = MathUtil.copy(this.selectionEffectPos) - .mul(1, -1) - .add(this.centerPos); + Vector2f pos = MathUtil.copy(this.selectionEffectPos).mul(1, -1).add(this.centerPos); - WheelWidget.renderSelectionEffect( + this.renderSelectionEffect( guiGraphics, pos.x, pos.y, this.selectionEffectColor, - this.selectionEffectRadius + this.ringOuterRadius - this.ringInnerRadius ); } + private void renderSelectionAnnularSector(GuiGraphics guiGraphics) { + WheelSection currentSection = this.sections.get(this.currentSectionIndex); + // 渲染角度沿最短路径向目标扇区角度平滑逼近,高亮在两个扇区间滑动 + float diffAngle = this.normalizeSignedAngle(currentSection.angle - this.selectionAngleRad); + this.selectionAngleRad += diffAngle / this.selectionAnimationSpeedFactor; + + float rangeAngle = this.normalizePositiveAngle(currentSection.angleEnd - currentSection.angleStart) / 2.0f; + // 鼠标停住后,扇区内外缘同步向外扩张一小段距离(外缘最终贴合盘面外边缘), + // 同时透明度从 20% 平滑提升到 100% + float settle = this.settleProgress(); + float expand = settle * SETTLE_EXPAND; + this.renderAnnularSectorSelection( + guiGraphics, + this.centerPos.x, + this.centerPos.y, + this.getSelectionSectorColor(settle), + this.ringInnerRadius + SECTION_INNER_INSET + expand, + this.ringOuterRadius - SECTION_OUTER_INSET + expand, + this.selectionAngleRad, + rangeAngle + ); + } + + /** + * 根据停住动画进度计算环扇高亮色:基础透明度为 20%,停住后平滑升至配置颜色的透明度。 + */ + private int getSelectionSectorColor(float settle) { + int baseAlpha = (this.selectionEffectColor >>> 24) & 0xFF; + int alpha = Math.round(baseAlpha * (0.2f + 0.8f * settle)); + return (alpha << 24) | (this.selectionEffectColor & 0xFFFFFF); + } + + /** + * 鼠标停住(选中扇区在 SETTLE_DELAY_MS 内未变化)后,0→1 的平滑外推动画进度。 + */ + private float settleProgress() { + long elapsed = System.currentTimeMillis() - this.selectionChangeTime; + float p = Mth.clamp((elapsed - SETTLE_DELAY_MS) / (float) SETTLE_ANIM_MS, 0.0f, 1.0f); + return p * p * (3.0f - 2.0f * p); + } + + private float normalizeSignedAngle(float angle) { + float normalized = angle % TAU; + if (normalized > Math.PI) { + normalized -= TAU; + } else if (normalized < -Math.PI) { + normalized += TAU; + } + return normalized; + } + + private void renderCenterTitle(GuiGraphics guiGraphics) { + if (this.currentSectionIndex < 0) { + return; + } + WheelSection section = this.sections.get(this.currentSectionIndex); + Component title = section.subTitle(); + if (title == null || title.getString().isEmpty()) { + return; + } + PoseStack poseStack = guiGraphics.pose(); + poseStack.pushPose(); + poseStack.translate(this.centerPos.x, this.centerPos.y, 0); + float scale = this.textScale * CENTER_TITLE_SCALE; + poseStack.scale(scale, scale, 1f); + // drawCenteredString 的 y 是文本顶部,向上偏移半个行高使文本垂直居中于轮盘中心 + int textY = -this.minecraft.font.lineHeight / 2; + guiGraphics.drawCenteredString(this.minecraft.font, title, 1, textY + 1, 0x99000000); + guiGraphics.drawCenteredString(this.minecraft.font, title, 0, textY, (0xff << 24) | this.textColor); + poseStack.popPose(); + } + + private void renderPageArrows(GuiGraphics guiGraphics) { + if (!this.hasPreviousPage && !this.hasNextPage) { + return; + } + // 左右下角的箭头做轻微的水平浮动动画,提示可以翻页 + float phase = System.currentTimeMillis() / 1000f; + float bob = (float) Math.sin(phase * 2.2f) * 4f; + float cornerDist = this.getDiscRadius() * 0.7071f + 26f; + float arrowY = this.centerPos.y + cornerDist; + float leftX = this.centerPos.x - cornerDist + bob; + float rightX = this.centerPos.x + cornerDist - bob; + if (this.hasPreviousPage) { + this.renderChevron(guiGraphics, leftX, arrowY, CHEVRON_SIZE, 0xFFFFFFFF, true); + } + if (this.hasNextPage) { + this.renderChevron(guiGraphics, rightX, arrowY, CHEVRON_SIZE, 0xFFFFFFFF, false); + } + } + + private void renderChevron(GuiGraphics guiGraphics, float x, float y, float size, int color, boolean pointingLeft) { + float halfThickness = CHEVRON_THICKNESS * 0.5f; + if (pointingLeft) { + this.renderSegment(guiGraphics, x + size, y - size, x - size, y, color, halfThickness); + this.renderSegment(guiGraphics, x - size, y, x + size, y + size, color, halfThickness); + } else { + this.renderSegment(guiGraphics, x - size, y - size, x + size, y, color, halfThickness); + this.renderSegment(guiGraphics, x + size, y, x - size, y + size, color, halfThickness); + } + } + + private float normalizePositiveAngle(float angle) { + float normalized = angle % TAU; + return normalized < 0 ? normalized + TAU : normalized; + } + + /** + * 在分隔圆环内侧渲染一个指向鼠标方向的尖括号箭头。 + */ + private void renderHoverChevron(GuiGraphics guiGraphics) { + if (this.mouseAngleRad == null) { + return; + } + // 与扇区排布同一坐标系换算屏幕方向:正上方为 0 + Vector2f dir = MathUtil.rotate(MathUtil.copy(ROTATION_START), this.mouseAngleRad).mul(1, -1); + Vector2f perp = new Vector2f(-dir.y, dir.x); + float tipDist = this.ringInnerRadius - HOVER_CHEVRON_RING_GAP; + Vector2f tip = new Vector2f(dir).mul(tipDist).add(this.centerPos); + Vector2f base = new Vector2f(dir).mul(tipDist - HOVER_CHEVRON_SIZE).add(this.centerPos); + Vector2f wingA = new Vector2f(base).add(new Vector2f(perp).mul(HOVER_CHEVRON_SIZE * 0.8f)); + Vector2f wingB = new Vector2f(base).add(new Vector2f(perp).mul(-HOVER_CHEVRON_SIZE * 0.8f)); + float halfThickness = HOVER_CHEVRON_THICKNESS * 0.5f; + this.renderSegment(guiGraphics, wingA.x, wingA.y, tip.x, tip.y, 0xFFFFFFFF, halfThickness); + this.renderSegment(guiGraphics, wingB.x, wingB.y, tip.x, tip.y, 0xFFFFFFFF, halfThickness); + } + + private void renderSegment( + GuiGraphics guiGraphics, + float x0, + float y0, + float x1, + float y1, + int color, + float halfThickness + ) { + Window window = this.minecraft.getWindow(); + float guiScale = (float) window.getGuiScale(); + float margin = halfThickness + 5; + float minX = Math.min(x0, x1) - margin; + float minY = Math.min(y0, y1) - margin; + float maxX = Math.max(x0, x1) + margin; + float maxY = Math.max(y0, y1) + margin; + Matrix4f matrix4f = guiGraphics.pose().last().pose(); + Tesselator tesselator = Tesselator.getInstance(); + BufferBuilder bufferBuilder = tesselator.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR); + bufferBuilder.addVertex(matrix4f, minX, minY, SELECTION_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, minX, maxY, SELECTION_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, maxX, maxY, SELECTION_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, maxX, minY, SELECTION_Z).setColor(color); + RenderSystem.disableDepthTest(); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + ShaderInstance segmentShader = LibShaders.getSegmentShader(); + if (segmentShader == null) { + this.renderSegmentFallback(guiGraphics, x0, y0, x1, y1, color, halfThickness); + RenderSystem.disableBlend(); + RenderSystem.enableDepthTest(); + return; + } + RenderSystem.setShader(() -> segmentShader); + segmentShader.safeGetUniform("Point0").set(x0 * guiScale, y0 * guiScale); + segmentShader.safeGetUniform("Point1").set(x1 * guiScale, y1 * guiScale); + segmentShader.safeGetUniform("LineWidth").set(halfThickness * 2f * guiScale); + segmentShader.safeGetUniform("FramebufferSize").set((float) window.getWidth(), (float) window.getHeight()); + RenderSystem.setShaderColor(1, 1, 1, 1); + BufferUploader.drawWithShader(Objects.requireNonNull(bufferBuilder.build())); + RenderSystem.disableBlend(); + RenderSystem.enableDepthTest(); + } + + private float getSelectionDotDiameter(float ringWidth) { + return ringWidth * SELECTION_DOT_DIAMETER_RATIO; + } + + private int getRendererSize(float ringWidth) { + return Math.max(1, Math.round(this.getSelectionDotDiameter(ringWidth))); + } + public void onClosing() { if (this.shouldRender() && !this.closingAnimationStarted) { this.displayTime = System.currentTimeMillis(); @@ -494,39 +1021,129 @@ public void onClosing() { } } - @Override - protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { + private void renderDisc(GuiGraphics guiGraphics, float centerX, float centerY, float radius, int color) { + Window window = this.minecraft.getWindow(); + float guiScale = (float) window.getGuiScale(); + Matrix4f matrix4f = guiGraphics.pose().last().pose(); + Tesselator tesselator = Tesselator.getInstance(); + BufferBuilder bufferBuilder = tesselator.begin( + VertexFormat.Mode.QUADS, + DefaultVertexFormat.POSITION_COLOR + ); + float x1 = centerX - radius - 5; + float y1 = centerY - radius - 5; + float x2 = centerX + radius + 5; + float y2 = centerY + radius + 5; + bufferBuilder.addVertex(matrix4f, x1, y1, DISC_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, x1, y2, DISC_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, x2, y2, DISC_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, x2, y1, DISC_Z).setColor(color); + RenderSystem.disableDepthTest(); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + ShaderInstance discShader = LibShaders.getDiscShader(); + if (discShader == null) { + this.renderDiscFallback(guiGraphics, centerX, centerY, radius, color); + RenderSystem.disableBlend(); + RenderSystem.enableDepthTest(); + return; + } + RenderSystem.setShader(() -> discShader); + discShader.safeGetUniform("Center").set(centerX * guiScale, centerY * guiScale); + discShader.safeGetUniform("FramebufferSize").set((float) window.getWidth(), (float) window.getHeight()); + discShader.safeGetUniform("Radius").set(radius * guiScale); + RenderSystem.setShaderColor(1, 1, 1, 1); + BufferUploader.drawWithShader(Objects.requireNonNull(bufferBuilder.build())); + RenderSystem.disableBlend(); + RenderSystem.enableDepthTest(); } - public record WheelSection( - Vector2f center, - float angle, - float angleStart, - float angleEnd, - Component subTitle, - @Nullable SectionRenderer renderer, - boolean selectable - ) { - public WheelSection( - Vector2f center, - float angle, - float angleStart, - float angleEnd, - RawSection section - ) { - this(center, angle, angleStart, angleEnd, section.name(), section.renderer(), section.selectable()); + private void renderDiscFallback(GuiGraphics guiGraphics, float centerX, float centerY, float radius, int centerColor) { + Matrix4f matrix4f = guiGraphics.pose().last().pose(); + Tesselator tesselator = Tesselator.getInstance(); + BufferBuilder bufferBuilder = tesselator.begin( + VertexFormat.Mode.TRIANGLE_FAN, + DefaultVertexFormat.POSITION_COLOR + ); + int edgeColor = centerColor & 0x00FFFFFF; + int segments = 48; + bufferBuilder.addVertex(matrix4f, centerX, centerY, DISC_Z).setColor(centerColor); + for (int i = 0; i <= segments; i++) { + float angle = (float) (Math.PI * 2 * i / segments); + float sin = (float) Math.sin(angle); + float cos = (float) Math.cos(angle); + bufferBuilder.addVertex(matrix4f, centerX + cos * radius, centerY + sin * radius, DISC_Z).setColor(edgeColor); } + RenderSystem.setShader(GameRenderer::getPositionColorShader); + RenderSystem.setShaderColor(1, 1, 1, 1); + BufferUploader.drawWithShader(Objects.requireNonNull(bufferBuilder.build())); } - public record RawSection(Component name, @Nullable SectionRenderer renderer, boolean selectable) { - public RawSection(Component name, @Nullable SectionRenderer renderer) { - this(name, renderer, true); + private void renderSelectionFallback(GuiGraphics guiGraphics, float centerX, float centerY, int color, float radius) { + this.renderDiscFallback(guiGraphics, centerX, centerY, radius, color); + } + + private void renderAnnularSectorFallback( + GuiGraphics guiGraphics, + float centerX, + float centerY, + int color, + float innerRadius, + float outerRadius, + float centerAngleRad, + float rangeAngleRad + ) { + Matrix4f matrix4f = guiGraphics.pose().last().pose(); + Tesselator tesselator = Tesselator.getInstance(); + BufferBuilder bufferBuilder = tesselator.begin( + VertexFormat.Mode.TRIANGLE_STRIP, + DefaultVertexFormat.POSITION_COLOR + ); + int segments = 48; + float startAngle = centerAngleRad - rangeAngleRad; + float sweepAngle = rangeAngleRad * 2; + for (int i = 0; i <= segments; i++) { + float angle = startAngle + sweepAngle * i / segments; + float sin = (float) Math.sin(angle); + float cos = (float) Math.cos(angle); + bufferBuilder.addVertex(matrix4f, centerX + cos * outerRadius, centerY + sin * outerRadius, SELECTION_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, centerX + cos * innerRadius, centerY + sin * innerRadius, SELECTION_Z).setColor(color); } + RenderSystem.setShader(GameRenderer::getPositionColorShader); + RenderSystem.setShaderColor(1, 1, 1, 1); + BufferUploader.drawWithShader(Objects.requireNonNull(bufferBuilder.build())); } - @FunctionalInterface - public interface SectionRenderer { - void render(GuiGraphics graphics, PoseStack pose, int width, int height); + private void renderSegmentFallback( + GuiGraphics guiGraphics, + float x0, + float y0, + float x1, + float y1, + int color, + float halfThickness + ) { + Vector2f dir = new Vector2f(x1 - x0, y1 - y0); + float length = dir.length(); + if (length <= 0) { + log.warn("Wheel segment fallback skipped: zero-length segment"); + return; + } + dir.div(length); + Vector2f perp = new Vector2f(-dir.y, dir.x).mul(halfThickness); + Matrix4f matrix4f = guiGraphics.pose().last().pose(); + Tesselator tesselator = Tesselator.getInstance(); + BufferBuilder bufferBuilder = tesselator.begin( + VertexFormat.Mode.TRIANGLE_STRIP, + DefaultVertexFormat.POSITION_COLOR + ); + bufferBuilder.addVertex(matrix4f, x0 + perp.x, y0 + perp.y, SELECTION_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, x0 - perp.x, y0 - perp.y, SELECTION_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, x1 + perp.x, y1 + perp.y, SELECTION_Z).setColor(color); + bufferBuilder.addVertex(matrix4f, x1 - perp.x, y1 - perp.y, SELECTION_Z).setColor(color); + RenderSystem.setShader(GameRenderer::getPositionColorShader); + RenderSystem.setShaderColor(1, 1, 1, 1); + BufferUploader.drawWithShader(Objects.requireNonNull(bufferBuilder.build())); } public static void renderRing( @@ -556,9 +1173,12 @@ public static void renderRing( Window window = Minecraft.getInstance().getWindow(); float guiScale = (float) window.getGuiScale(); RenderSystem.disableDepthTest(); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); ShaderInstance ringShader = LibShaders.getRingShader(); if (ringShader == null) { renderRingFallback(guiGraphics, centerX, centerY, color, innerDiameter, outerDiameter); + RenderSystem.disableBlend(); RenderSystem.enableDepthTest(); return; } @@ -576,55 +1196,7 @@ public static void renderRing( RenderSystem.setShaderColor(1, 1, 1, 1); BufferUploader.drawWithShader(Objects.requireNonNull(bufferBuilder.build())); - RenderSystem.enableDepthTest(); - } - - public static void renderSelectionEffect( - GuiGraphics guiGraphics, - float centerX, - float centerY, - int color, - float radius - ) { - PoseStack poseStack = guiGraphics.pose(); - Matrix4f matrix4f = poseStack.last().pose(); - Tesselator tesselator = Tesselator.getInstance(); - BufferBuilder bufferBuilder = tesselator.begin( - VertexFormat.Mode.QUADS, - DefaultVertexFormat.POSITION_COLOR - ); - float x1 = centerX - radius - 5; - float y1 = centerY - radius - 5; - float x2 = centerX + radius + 5; - float y2 = centerY + radius + 5; - bufferBuilder.addVertex(matrix4f, x1, y1, SELECTION_Z).setColor(color); - bufferBuilder.addVertex(matrix4f, x1, y2, SELECTION_Z).setColor(color); - bufferBuilder.addVertex(matrix4f, x2, y2, SELECTION_Z).setColor(color); - bufferBuilder.addVertex(matrix4f, x2, y1, SELECTION_Z).setColor(color); - - Window window = Minecraft.getInstance().getWindow(); - float guiScale = (float) window.getGuiScale(); - RenderSystem.disableDepthTest(); - ShaderInstance selectionShader = LibShaders.getSelectionShader(); - if (selectionShader == null) { - renderSelectionFallback(guiGraphics, centerX, centerY, color, radius); - RenderSystem.enableDepthTest(); - return; - } - RenderSystem.setShader(() -> selectionShader); - - selectionShader - .safeGetUniform("Center") - .set(centerX * guiScale, centerY * guiScale); - selectionShader - .safeGetUniform("FramebufferSize") - .set((float) window.getWidth(), (float) window.getHeight()); - selectionShader - .safeGetUniform("Radius") - .set(radius * guiScale); - - RenderSystem.setShaderColor(1, 1, 1, 1); - BufferUploader.drawWithShader(Objects.requireNonNull(bufferBuilder.build())); + RenderSystem.disableBlend(); RenderSystem.enableDepthTest(); } @@ -658,41 +1230,24 @@ private static void renderRingFallback( BufferUploader.drawWithShader(Objects.requireNonNull(bufferBuilder.build())); } - private static void renderSelectionFallback( - GuiGraphics guiGraphics, - float centerX, - float centerY, - int color, - float radius - ) { - renderDisc(guiGraphics, centerX, centerY, radius, color); + @FunctionalInterface + public interface SectionRenderer { + void render(GuiGraphics graphics, PoseStack pose, int width, int height); } - private static void renderDisc( - GuiGraphics guiGraphics, - float centerX, - float centerY, - float radius, - int centerColor + public record WheelSection( + Vector2f center, float angle, float angleStart, float angleEnd, Component subTitle, + @Nullable SectionRenderer renderer, boolean selectable ) { - PoseStack poseStack = guiGraphics.pose(); - Matrix4f matrix4f = poseStack.last().pose(); - Tesselator tesselator = Tesselator.getInstance(); - BufferBuilder bufferBuilder = tesselator.begin( - VertexFormat.Mode.TRIANGLE_FAN, - DefaultVertexFormat.POSITION_COLOR - ); - int edgeColor = centerColor & 0x00FFFFFF; - int segments = 48; - bufferBuilder.addVertex(matrix4f, centerX, centerY, SELECTION_Z).setColor(centerColor); - for (int i = 0; i <= segments; i++) { - float angle = (float) (Math.PI * 2 * i / segments); - float sin = (float) Math.sin(angle); - float cos = (float) Math.cos(angle); - bufferBuilder.addVertex(matrix4f, centerX + cos * radius, centerY + sin * radius, SELECTION_Z).setColor(edgeColor); + public WheelSection(Vector2f center, float angle, float angleStart, float angleEnd, RawSection section) { + this(center, angle, angleStart, angleEnd, section.name(), section.renderer(), section.selectable()); } - RenderSystem.setShader(GameRenderer::getPositionColorShader); - RenderSystem.setShaderColor(1, 1, 1, 1); - BufferUploader.drawWithShader(Objects.requireNonNull(bufferBuilder.build())); } + + public record RawSection(Component name, @Nullable SectionRenderer renderer, boolean selectable) { + public RawSection(Component name, @Nullable SectionRenderer renderer) { + this(name, renderer, true); + } + } + } diff --git a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/component/package-info.java b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/component/package-info.java index 53c687ac..3809009a 100644 --- a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/component/package-info.java +++ b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/component/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.wheel.client.gui.component; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/screen/WheelScreen.java b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/screen/WheelScreen.java index 6fca34e5..ab85f1e4 100644 --- a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/screen/WheelScreen.java +++ b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/screen/WheelScreen.java @@ -5,6 +5,7 @@ import dev.anvilcraft.lib.v2.wheel.api.WheelMenuModel; import dev.anvilcraft.lib.v2.wheel.api.WheelOpenMode; import dev.anvilcraft.lib.v2.wheel.api.WheelPageModel; +import dev.anvilcraft.lib.v2.wheel.client.gui.component.WheelFrostedBackground; import dev.anvilcraft.lib.v2.wheel.client.gui.component.WheelWidget; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.Screen; @@ -13,10 +14,11 @@ import java.util.ArrayDeque; import java.util.Deque; import java.util.List; +import javax.annotation.Nullable; public class WheelScreen extends Screen { - private static final float WHEEL_INNER_RADIUS_SCALE = 0.12f; - private static final float WHEEL_OUTER_RADIUS_SCALE = 0.22f; + private static final float WHEEL_INNER_RADIUS_SCALE = 0.17f; + private static final float WHEEL_OUTER_RADIUS_SCALE = 0.33f; private final WheelMenuModel model; private final WheelOpenMode openMode; @@ -24,6 +26,8 @@ public class WheelScreen extends Screen { private WheelWidget wheelWidget; private int currentPageIndex; + @Nullable + private WheelFrostedBackground frostedBackground; public WheelScreen(WheelMenuModel model, WheelOpenMode openMode) { super(Component.empty()); @@ -32,16 +36,6 @@ public WheelScreen(WheelMenuModel model, WheelOpenMode openMode) { this.menuStack.addLast(this.model.rootEntries()); } - @Override - public boolean isPauseScreen() { - return false; - } - - @Override - protected void init() { - this.rebuildWheelWidget(); - } - public static WheelScreen tap(WheelMenuModel model) { return new WheelScreen(model, WheelOpenMode.TAP); } @@ -57,14 +51,34 @@ public void triggerFromHoldRelease() { this.triggerSelectedOrClose(); } + @Override + public boolean isPauseScreen() { + return false; + } + + @Override + protected void init() { + if (this.frostedBackground != null) { + this.frostedBackground.close(); + } + this.frostedBackground = new WheelFrostedBackground(); + this.rebuildWheelWidget(); + } + + @Override + public void removed() { + if (this.frostedBackground != null) { + this.frostedBackground.close(); + this.frostedBackground = null; + } + super.removed(); + } + @Override public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { if (this.wheelWidget != null) { this.wheelWidget.render(guiGraphics, mouseX, mouseY, partialTick); } - - Component pageInfo = Component.literal((this.currentPageIndex + 1) + " / " + this.pageCountForCurrentMenu()); - guiGraphics.drawCenteredString(this.font, pageInfo, this.width / 2, this.height - 22, 0xFFFFFFFF); } @Override @@ -178,6 +192,13 @@ private void rebuildWheelWidget() { sections, this.model.deadZone() ); + this.wheelWidget.setFrostedBackground(this.frostedBackground); + this.wheelWidget.setPageState( + this.currentPageIndex > 0, + this.currentPageIndex < this.pageCountForCurrentMenu() - 1 + ); + this.wheelWidget.setSelectionEffectColor(this.model.selectionEffectColor()); + this.wheelWidget.setSelectionEffect(this.model.selectionEffect()); this.wheelWidget.clearSelection(); } @@ -198,4 +219,3 @@ private List currentMenuEntries() { return this.menuStack.getLast(); } } - diff --git a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/screen/package-info.java b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/screen/package-info.java index b83c8086..86e4be80 100644 --- a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/screen/package-info.java +++ b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/gui/screen/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.wheel.client.gui.screen; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/init/LibShaders.java b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/init/LibShaders.java index 8544e297..53dcfb16 100644 --- a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/init/LibShaders.java +++ b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/init/LibShaders.java @@ -20,6 +20,16 @@ public class LibShaders { static @Nullable ShaderInstance ringShader; @Getter static @Nullable ShaderInstance selectionShader; + @Getter + static @Nullable ShaderInstance annularSectorShader; + @Getter + static @Nullable ShaderInstance discShader; + @Getter + static @Nullable ShaderInstance frostedDiscShader; + @Getter + static @Nullable ShaderInstance blurShader; + @Getter + static @Nullable ShaderInstance segmentShader; @SubscribeEvent public static void register(RegisterShadersEvent event) { @@ -40,6 +50,46 @@ public static void register(RegisterShadersEvent event) { ), it -> selectionShader = it ); + event.registerShader( + new ShaderInstance( + event.getResourceProvider(), + AnvilLibWheel.of("annular_sector"), + DefaultVertexFormat.POSITION_COLOR + ), + it -> annularSectorShader = it + ); + event.registerShader( + new ShaderInstance( + event.getResourceProvider(), + AnvilLibWheel.of("disc"), + DefaultVertexFormat.POSITION_COLOR + ), + it -> discShader = it + ); + event.registerShader( + new ShaderInstance( + event.getResourceProvider(), + AnvilLibWheel.of("frosted_disc"), + DefaultVertexFormat.POSITION_TEX_COLOR + ), + it -> frostedDiscShader = it + ); + event.registerShader( + new ShaderInstance( + event.getResourceProvider(), + AnvilLibWheel.of("blur"), + DefaultVertexFormat.POSITION_TEX + ), + it -> blurShader = it + ); + event.registerShader( + new ShaderInstance( + event.getResourceProvider(), + AnvilLibWheel.of("segment"), + DefaultVertexFormat.POSITION_COLOR + ), + it -> segmentShader = it + ); } catch (IOException e) { log.error(e.getLocalizedMessage(), e); } diff --git a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/init/package-info.java b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/init/package-info.java index 9c4598bf..f34e997b 100644 --- a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/init/package-info.java +++ b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/init/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.wheel.client.init; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/input/package-info.java b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/input/package-info.java index 647b5738..c4654dc3 100644 --- a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/input/package-info.java +++ b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/client/input/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.wheel.client.input; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/package-info.java b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/package-info.java index c78eb4cb..d5582a73 100644 --- a/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/package-info.java +++ b/module.wheel/src/main/java/dev/anvilcraft/lib/v2/wheel/package-info.java @@ -1,7 +1,9 @@ +@FieldsAreNonnullByDefault @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault package dev.anvilcraft.lib.v2.wheel; -import net.minecraft.MethodsReturnNonnullByDefault; +import com.mojang.logging.annotations.FieldsAreNonnullByDefault; +import com.mojang.logging.annotations.MethodsReturnNonnullByDefault; import javax.annotation.ParametersAreNonnullByDefault; diff --git a/module.wheel/src/main/resources/assets/anvillib/shaders/core/annular_sector.fsh b/module.wheel/src/main/resources/assets/anvillib/shaders/core/annular_sector.fsh new file mode 100644 index 00000000..53954bb0 --- /dev/null +++ b/module.wheel/src/main/resources/assets/anvillib/shaders/core/annular_sector.fsh @@ -0,0 +1,56 @@ +#version 150 + +in vec4 vertexColor; + +uniform vec4 ColorModulator; +uniform vec2 Center; +// 注意:以下为半径而非直径(与 dev/26.1 语义对齐) +uniform float InnerRadius; +uniform float OuterRadius; +uniform float AntiAliasingRadius; +uniform float AngleAntiAliasingRad; +uniform float CenterAngleRad; +uniform float RangeAngleRad; + +out vec4 fragColor; + +const float PI = 3.14159265358979323846; +const float TAU = 2.0 * PI; + +float normalizeAngleP(float angle) { + float normalized = mod(angle, TAU); + return normalized < 0.0 ? normalized + TAU : normalized; +} + +float normalizeAnglePN(float angle) { + float normalized = mod(angle, TAU); + if (normalized < -PI) { + return normalized + TAU; + } else if (normalized > PI) { + return normalized - TAU; + } else { + return normalized; + } +} + +float calcAngleAlpha(float posAngle, float centerAngle, float rangeAngle, float aa) { + float center = normalizeAngleP(centerAngle); + float dist = abs(normalizeAnglePN(normalizeAngleP(posAngle) - center)); + float angleAa = max(min(aa, fwidth(dist) * 1.5), 0.0001); + + return 1.0 - smoothstep(rangeAngle - angleAa, rangeAngle + angleAa, dist); +} + +void main() { + vec2 fragPos = gl_FragCoord.xy; + vec4 color = vertexColor; + float distance = distance(fragPos, Center); + float angle = atan(fragPos.y - Center.y, fragPos.x - Center.x); + float radialAa = max(min(AntiAliasingRadius, fwidth(distance) * 1.5), 0.0001); + + color.a *= smoothstep(InnerRadius - radialAa, InnerRadius + radialAa, distance) + * (1.0 - smoothstep(OuterRadius - radialAa, OuterRadius + radialAa, distance)) + * calcAngleAlpha(angle, CenterAngleRad, RangeAngleRad, AngleAntiAliasingRad); + + fragColor = color * ColorModulator; +} diff --git a/module.wheel/src/main/resources/assets/anvillib/shaders/core/annular_sector.json b/module.wheel/src/main/resources/assets/anvillib/shaders/core/annular_sector.json new file mode 100644 index 00000000..10b92176 --- /dev/null +++ b/module.wheel/src/main/resources/assets/anvillib/shaders/core/annular_sector.json @@ -0,0 +1,68 @@ +{ + "vertex": "position_color", + "fragment": "anvillib:annular_sector", + "samplers": [ + ], + "uniforms": [ + { "name": "ModelViewMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ProjMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ColorModulator", "type": "float", "count": 4, "values": [ 1.0, 1.0, 1.0, 1.0 ] }, + { + "name": "Center", + "type": "float", + "count": 2, + "values": [ + 0.0, + 0.0 + ] + }, + { + "name": "InnerRadius", + "type": "float", + "count": 1, + "values": [ + 0.0 + ] + }, + { + "name": "OuterRadius", + "type": "float", + "count": 1, + "values": [ + 0.0 + ] + }, + { + "name": "AntiAliasingRadius", + "type": "float", + "count": 1, + "values": [ + 1.25 + ] + }, + { + "name": "AngleAntiAliasingRad", + "type": "float", + "count": 1, + "values": [ + 0.06 + ] + }, + { + "name": "CenterAngleRad", + "type": "float", + "count": 1, + "values": [ + 0.0 + ] + }, + { + "name": "RangeAngleRad", + "type": "float", + "count": 1, + "values": [ + 0.0 + ] + } + ] +} diff --git a/module.wheel/src/main/resources/assets/anvillib/shaders/core/blur.fsh b/module.wheel/src/main/resources/assets/anvillib/shaders/core/blur.fsh new file mode 100644 index 00000000..74b9be71 --- /dev/null +++ b/module.wheel/src/main/resources/assets/anvillib/shaders/core/blur.fsh @@ -0,0 +1,26 @@ +#version 150 + +in vec2 texCoord0; + +uniform vec4 ColorModulator; +uniform sampler2D DiffuseSampler; +uniform vec2 Direction; +uniform float SampleStepLength; + +out vec4 fragColor; + +void main() { + // 按 Direction 做可分离高斯模糊,SampleStepLength 为采样步长(纹素) + vec2 texelSize = 1.0 / textureSize(DiffuseSampler, 0); + vec2 step = Direction * SampleStepLength * texelSize; + vec4 sum = texture(DiffuseSampler, texCoord0) * 0.227027; + sum += texture(DiffuseSampler, texCoord0 + step) * 0.1945946; + sum += texture(DiffuseSampler, texCoord0 - step) * 0.1945946; + sum += texture(DiffuseSampler, texCoord0 + step * 2.0) * 0.1216216; + sum += texture(DiffuseSampler, texCoord0 - step * 2.0) * 0.1216216; + sum += texture(DiffuseSampler, texCoord0 + step * 3.0) * 0.054054; + sum += texture(DiffuseSampler, texCoord0 - step * 3.0) * 0.054054; + sum += texture(DiffuseSampler, texCoord0 + step * 4.0) * 0.016216; + sum += texture(DiffuseSampler, texCoord0 - step * 4.0) * 0.016216; + fragColor = sum * ColorModulator; +} diff --git a/module.wheel/src/main/resources/assets/anvillib/shaders/core/blur.json b/module.wheel/src/main/resources/assets/anvillib/shaders/core/blur.json new file mode 100644 index 00000000..86b16141 --- /dev/null +++ b/module.wheel/src/main/resources/assets/anvillib/shaders/core/blur.json @@ -0,0 +1,31 @@ +{ + "vertex": "position_tex", + "fragment": "anvillib:blur", + "samplers": [ + { + "name": "DiffuseSampler" + } + ], + "uniforms": [ + { "name": "ModelViewMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ProjMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ColorModulator", "type": "float", "count": 4, "values": [ 1.0, 1.0, 1.0, 1.0 ] }, + { + "name": "Direction", + "type": "float", + "count": 2, + "values": [ + 1.0, + 0.0 + ] + }, + { + "name": "SampleStepLength", + "type": "float", + "count": 1, + "values": [ + 2.0 + ] + } + ] +} diff --git a/module.wheel/src/main/resources/assets/anvillib/shaders/core/disc.fsh b/module.wheel/src/main/resources/assets/anvillib/shaders/core/disc.fsh new file mode 100644 index 00000000..e31cae47 --- /dev/null +++ b/module.wheel/src/main/resources/assets/anvillib/shaders/core/disc.fsh @@ -0,0 +1,24 @@ +#version 150 + +in vec4 vertexColor; + +uniform vec4 ColorModulator; +uniform vec2 FramebufferSize; +uniform vec2 Center; +uniform float Radius; +uniform float AntiAliasingRadius; + +out vec4 fragColor; + +void main() { + // GUI 通道的 gl_FragCoord 以窗口左下角为原点,翻转至左上角原点的 GUI 坐标 + vec2 fragPos = vec2(gl_FragCoord.x, FramebufferSize.y - gl_FragCoord.y); + float dist = distance(fragPos, Center); + float aa = max(min(AntiAliasingRadius, fwidth(dist) * 1.5), 0.0001); + // 均匀圆形遮罩:半径内不透明,边缘抗锯齿过渡 + float mask = 1.0 - smoothstep(Radius - aa, Radius + aa, dist); + + vec4 color = vertexColor; + color.a *= mask; + fragColor = color * ColorModulator; +} diff --git a/module.wheel/src/main/resources/assets/anvillib/shaders/core/disc.json b/module.wheel/src/main/resources/assets/anvillib/shaders/core/disc.json new file mode 100644 index 00000000..554d78a5 --- /dev/null +++ b/module.wheel/src/main/resources/assets/anvillib/shaders/core/disc.json @@ -0,0 +1,45 @@ +{ + "vertex": "position_color", + "fragment": "anvillib:disc", + "samplers": [ + ], + "uniforms": [ + { "name": "ModelViewMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ProjMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ColorModulator", "type": "float", "count": 4, "values": [ 1.0, 1.0, 1.0, 1.0 ] }, + { + "name": "FramebufferSize", + "type": "float", + "count": 2, + "values": [ + 0.0, + 0.0 + ] + }, + { + "name": "Center", + "type": "float", + "count": 2, + "values": [ + 0.0, + 0.0 + ] + }, + { + "name": "Radius", + "type": "float", + "count": 1, + "values": [ + 0.0 + ] + }, + { + "name": "AntiAliasingRadius", + "type": "float", + "count": 1, + "values": [ + 1.5 + ] + } + ] +} diff --git a/module.wheel/src/main/resources/assets/anvillib/shaders/core/frosted_disc.fsh b/module.wheel/src/main/resources/assets/anvillib/shaders/core/frosted_disc.fsh new file mode 100644 index 00000000..42bb2020 --- /dev/null +++ b/module.wheel/src/main/resources/assets/anvillib/shaders/core/frosted_disc.fsh @@ -0,0 +1,29 @@ +#version 150 + +in vec2 texCoord0; +in vec4 vertexColor; + +uniform vec4 ColorModulator; +uniform vec2 FramebufferSize; +uniform vec2 Center; +uniform float Radius; +uniform float AntiAliasingRadius; + +uniform sampler2D Sampler0; + +out vec4 fragColor; + +void main() { + // GUI 通道的 gl_FragCoord 以窗口左下角为原点,翻转至左上角原点的 GUI 坐标 + vec2 fragPos = vec2(gl_FragCoord.x, FramebufferSize.y - gl_FragCoord.y); + float dist = distance(fragPos, Center); + float aa = max(min(AntiAliasingRadius, fwidth(dist) * 1.5), 0.0001); + // 圆形盘面遮罩:半径内可见,边缘抗锯齿过渡 + float mask = 1.0 - smoothstep(Radius - aa, Radius + aa, dist); + + // Sampler0 为每帧高斯模糊后的主渲染目标 + vec4 scene = texture(Sampler0, texCoord0); + + // vertexColor 作为毛玻璃着色与混合强度:rgb 调制模糊内容,a 控制叠加透明度 + fragColor = vec4(scene.rgb * vertexColor.rgb, mask * vertexColor.a) * ColorModulator; +} diff --git a/module.wheel/src/main/resources/assets/anvillib/shaders/core/frosted_disc.json b/module.wheel/src/main/resources/assets/anvillib/shaders/core/frosted_disc.json new file mode 100644 index 00000000..b72c2745 --- /dev/null +++ b/module.wheel/src/main/resources/assets/anvillib/shaders/core/frosted_disc.json @@ -0,0 +1,48 @@ +{ + "vertex": "position_tex_color", + "fragment": "anvillib:frosted_disc", + "samplers": [ + { + "name": "Sampler0" + } + ], + "uniforms": [ + { "name": "ModelViewMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ProjMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ColorModulator", "type": "float", "count": 4, "values": [ 1.0, 1.0, 1.0, 1.0 ] }, + { + "name": "FramebufferSize", + "type": "float", + "count": 2, + "values": [ + 0.0, + 0.0 + ] + }, + { + "name": "Center", + "type": "float", + "count": 2, + "values": [ + 0.0, + 0.0 + ] + }, + { + "name": "Radius", + "type": "float", + "count": 1, + "values": [ + 0.0 + ] + }, + { + "name": "AntiAliasingRadius", + "type": "float", + "count": 1, + "values": [ + 1.25 + ] + } + ] +} diff --git a/module.wheel/src/main/resources/assets/anvillib/shaders/core/segment.fsh b/module.wheel/src/main/resources/assets/anvillib/shaders/core/segment.fsh new file mode 100644 index 00000000..4a0ea5c7 --- /dev/null +++ b/module.wheel/src/main/resources/assets/anvillib/shaders/core/segment.fsh @@ -0,0 +1,29 @@ +#version 150 + +in vec4 vertexColor; + +uniform vec4 ColorModulator; +uniform vec2 FramebufferSize; +uniform vec2 Point0; +uniform vec2 Point1; +uniform float LineWidth; +uniform float AntiAliasingRadius; + +out vec4 fragColor; + +void main() { + // GUI 通道的 gl_FragCoord 以窗口左下角为原点,翻转至左上角原点的 GUI 坐标 + vec2 fragPos = vec2(gl_FragCoord.x, FramebufferSize.y - gl_FragCoord.y); + // 点到线段的最短距离 + vec2 segment = Point1 - Point0; + float length2 = dot(segment, segment); + float t = length2 > 0.0 ? clamp(dot(fragPos - Point0, segment) / length2, 0.0, 1.0) : 0.0; + float dist = distance(fragPos, Point0 + segment * t); + float aa = max(min(AntiAliasingRadius, fwidth(dist) * 1.5), 0.0001); + // LineWidth 为线段总宽度,边缘抗锯齿过渡 + float alpha = 1.0 - smoothstep(LineWidth * 0.5 - aa, LineWidth * 0.5 + aa, dist); + + vec4 color = vertexColor; + color.a *= alpha; + fragColor = color * ColorModulator; +} diff --git a/module.wheel/src/main/resources/assets/anvillib/shaders/core/segment.json b/module.wheel/src/main/resources/assets/anvillib/shaders/core/segment.json new file mode 100644 index 00000000..91293e24 --- /dev/null +++ b/module.wheel/src/main/resources/assets/anvillib/shaders/core/segment.json @@ -0,0 +1,54 @@ +{ + "vertex": "position_color", + "fragment": "anvillib:segment", + "samplers": [ + ], + "uniforms": [ + { "name": "ModelViewMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ProjMat", "type": "matrix4x4", "count": 16, "values": [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, + { "name": "ColorModulator", "type": "float", "count": 4, "values": [ 1.0, 1.0, 1.0, 1.0 ] }, + { + "name": "FramebufferSize", + "type": "float", + "count": 2, + "values": [ + 0.0, + 0.0 + ] + }, + { + "name": "Point0", + "type": "float", + "count": 2, + "values": [ + 0.0, + 0.0 + ] + }, + { + "name": "Point1", + "type": "float", + "count": 2, + "values": [ + 0.0, + 0.0 + ] + }, + { + "name": "LineWidth", + "type": "float", + "count": 1, + "values": [ + 0.0 + ] + }, + { + "name": "AntiAliasingRadius", + "type": "float", + "count": 1, + "values": [ + 1.5 + ] + } + ] +} diff --git a/roseau.yaml b/roseau.yaml new file mode 100644 index 00000000..1f196728 --- /dev/null +++ b/roseau.yaml @@ -0,0 +1,9 @@ +common: + excludes: + annotations: + - name: java.lang.Deprecated + - name: com.google.common.annotations.Beta + - name: org.jetbrains.annotations.ApiStatus$Experimental + - name: org.jetbrains.annotations.ApiStatus$Internal + - name: org.jetbrains.annotations.ApiStatus$Obsolete + - name: org.jetbrains.annotations.ApiStatus$ScheduledForRemoval diff --git a/settings.gradle b/settings.gradle index 5491395c..b1aa275b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,28 +10,42 @@ plugins { id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' } -include 'module.config' include 'module.codec' +include 'module.collision' +include 'module.config' +include 'module.explosion' +include 'module.font' include 'module.integration' include 'module.moveable-entity-block' include 'module.multiblock' include 'module.network' include 'module.recipe' include 'module.registrum' +include 'module.rpc' +include 'module.space-select' +include 'module.sync' +include 'module.sync:processor' include 'module.util' include 'module.wheel' include 'module.yukkuri' include 'module.main' include 'module.test' -project(':module.config').name = 'anvillib-config-neoforge-1.21.1' project(':module.codec').name = 'anvillib-codec-neoforge-1.21.1' +project(':module.collision').name = 'anvillib-collision-neoforge-1.21.1' +project(':module.config').name = 'anvillib-config-neoforge-1.21.1' +project(':module.explosion').name = 'anvillib-explosion-neoforge-1.21.1' +project(':module.font').name = 'anvillib-font-neoforge-1.21.1' project(':module.integration').name = 'anvillib-integration-neoforge-1.21.1' project(':module.moveable-entity-block').name = 'anvillib-moveable-entity-block-neoforge-1.21.1' project(':module.multiblock').name = 'anvillib-multiblock-neoforge-1.21.1' project(':module.network').name = 'anvillib-network-neoforge-1.21.1' project(':module.recipe').name = 'anvillib-recipe-neoforge-1.21.1' project(':module.registrum').name = 'anvillib-registrum-neoforge-1.21.1' +project(':module.rpc').name = 'anvillib-rpc-neoforge-1.21.1' +project(':module.space-select').name = 'anvillib-space-select-neoforge-1.21.1' +project(':module.sync').name = 'anvillib-sync-neoforge-1.21.1' +project(':module.sync:processor').name = 'anvillib-sync-processor-neoforge-1.21.1' project(':module.util').name = 'anvillib-util-neoforge-1.21.1' project(':module.wheel').name = 'anvillib-wheel-neoforge-1.21.1' project(':module.yukkuri').name = 'anvillib-yukkuri-neoforge-1.21.1'