diff --git a/.github/copilot-instructions.md b/.github/agents.md similarity index 71% rename from .github/copilot-instructions.md rename to .github/agents.md index 1de2427aaf8..5d77151f28c 100644 --- a/.github/copilot-instructions.md +++ b/.github/agents.md @@ -1,4 +1,4 @@ -# Beyond All Reason - Copilot Instructions +# Beyond All Reason - Agent Instructions Mixed script types: LuaUI widgets, LuaRules gadgets, BOS animation scripts, shaders, RmlUi documents, busted specs. @@ -17,10 +17,27 @@ Before editing code: Keep patches narrowly scoped and easy to review. Large cross-subsystem refactors need an explicit request. +Before you hand work over: + +Four checks decide whether a change is mergeable. Three of them run Lua tooling pinned to a version you can install +locally and reproduce exactly. Run the ones your changes touch. + +| Check | Tool and pin | What it scopes | Local command | +| --- | --- | --- | --- | +| `busted` | lux-cli 0.28.3 | Whole spec suite, never a diff | `lx --lua-version 5.1 test` | +| `stylua` | StyLua 2.5.2 | Only hunks overlapping lines you wrote | `stylua --check --respect-ignores ` | +| `emmylua_check` | emmylua 0.24.0 | Whole tree, compared against the base | `emmylua_check -c .emmyrc.json .` | +| `docker compose up (headless)` | stock engine | Whole game, headless | `tools/headless_testing/README.md` | + ## Keeping These Instructions Current -- Update the affected section in the same pull request whenever a change alters a convention, tool, workflow, - directory, or command described here. +Instruction files: this one, `.github/RmlUi-instructions.md`, `.github/spec-instructions.md`, and +(only on the `mission-api/dev` branch) `luarules/mission_api/mission-api-instructions.md`. +The rules below apply to all of them. + +- Update the affected file and section in the same pull request whenever a change alters a convention, tool, + workflow, directory, or command they describe. A change to a subsystem covered by its own file updates that file, + not this one. - Verify a claim before writing it down (run the command, read the config, count the occurrences). - Delete guidance that no longer matches the repository instead of layering exceptions on top of it. @@ -109,12 +126,13 @@ Match the validator to the file type. If unsure of a tool's scope, inspect the p - lux 0.28.x appends duplicate `dependencies` and `entrypoints` entries to `lux.lock` on every cold sync (a run with no `.lux/` tree), growing the file by ~13 lines each time without ever converging. Tests still pass. Do not commit that churn: `git checkout -- lux.lock` afterwards, and only commit a lockfile change you made deliberately. -- Lint: `luacheck` 1.2.0 with `.luacheckrc`; CI reports only lines changed in the PR (`.github/workflows/lint.yml`). -- Format: StyLua with `.stylua.toml` (tabs, indent width 4, 120 columns, CRLF, sorted requires) and `.styluaignore`; - `.editorconfig` mirrors the indent and whitespace rules. `.github/workflows/format_check.yml` pins stylua 2.5.2 and - runs it with `--check --respect-ignores`; install that version locally rather than relying on `lx fmt`. -- Types: EmmyLua analyzer via `.emmyrc.json`, stubs in `types/`, engine definitions from the `recoil-lua-library` - submodule. The codebase is at zero type errors — keep it there. +- Lint: `luacheck` 1.2.0 with `.luacheckrc`. Treat `.luacheckrc` as a style record, not as a gate. +- Format: StyLua 2.5.2 with `.stylua.toml` (tabs, indent width 4, 120 columns, CRLF, sorted requires) and + `.styluaignore`; `.editorconfig` mirrors the indent and whitespace rules. The gate fails only on changed hunks. +- Types: EmmyLua analyzer 0.24.0 via `.emmyrc.json`, stubs in `types/`, engine definitions from the + `recoil-lua-library` submodule. This is not a zero-errors gate. A new error blocks anywhere, and warnings run on + two budgets: fifteen net-new warnings per thousand changed lines, and thirty total warnings per thousand lines in + the files you changed. A change with no type errors still fails when it adds warnings faster than that per line. - Integration tests: headless engine via `docker compose -f tools/headless_testing/docker-compose.yml` (`.github/workflows/test_integration.yml`). They can also be run without docker against an engine already downloaded by an installed BAR client — see `tools/headless_testing/README.md`. @@ -125,35 +143,25 @@ Match the validator to the file type. If unsure of a tool's scope, inspect the p ### Commands ```sh -lx --lua-version 5.1 test # full busted suite (what CI runs) -lx --lua-version 5.1 test -- spec/common/lib_spline_spec.lua # single spec file -lx --lua-version 5.1 test -- --output=plainTerminal # extra flags after `--` reach busted -lx lint # luacheck over the project; provisions luacheck itself -lx exec luacheck -- path/to/file.lua # lint one file -stylua --respect-ignores path/to/file.lua # format one file -stylua --check --respect-ignores path/to/file.lua # verify formatting without rewriting +lx --lua-version 5.1 test # full busted suite (what CI runs); needs lux-cli 0.28.3 +busted --output=plainTerminal # same suite, when the .lux tree is already synced +busted spec/common/lib_spline_spec.lua # single spec file +stylua --check --respect-ignores path/to/file.lua # what the format gate sees; needs stylua 2.5.2 +stylua path/to/file.lua # format one file (`lx fmt` reformats the whole codebase) +emmylua_check -c .emmyrc.json . # what the type gate sees; needs emmylua 0.24.0 ``` -Always drive busted and luacheck through `lx`. It is the only invocation that resolves against the project's -`.lux/` tree and `lux.lock`; a system-wide `busted` or `luacheck` on `PATH` silently resolves a different dependency -set, and the lux-generated wrappers under `.lux/` and `~/.local/share/lux/` are not runnable directly (they need the -`LUA_INIT` loader that `lx` sets). +These mean nothing without the pinned versions, and the versions are not installed for you. -StyLua is the exception: it is a standalone binary, not a rock, so `lx exec stylua` fails and `lx fmt` is a silent -no-op on lux 0.28.x (exit 0, nothing reformatted). Call `stylua` directly, pinned to the version in -`.github/workflows/format_check.yml` (2.5.2). Pass `--respect-ignores` whenever you name explicit paths, or -`.styluaignore` is bypassed and generated files such as the atlases get reformatted. - -Scope `luacheck` and `stylua` to the files you touched; repository-wide runs create large unrelated diffs. `lx lint` -reports pre-existing warnings across the tree, so compare against the baseline rather than assuming your change -caused them. +Scope `stylua` to the files you touched; repository-wide runs create large unrelated diffs. The type check has no +scoped mode, so read its output against the baseline rather than assuming your change caused everything in it. ### By file type - `.lua` (LuaUI/LuaRules/AI/common): relevant `spec/` tests, clean luacheck and StyLua, plus in-engine runtime verification (LuaUI reload). Do not reach for BOS tooling. -- `spec/**/*_spec.lua`: `lx --lua-version 5.1 test`, per the commands above. Keep `lux.lock` and `.emmyrc.json` in - sync when dependencies resolve to new versions. +- `spec/**/*_spec.lua`: busted, per the commands above. Keep `lux.lock` and `.emmyrc.json` in sync when dependencies + resolve to new versions. - Definition and gamedata changes: `spec/gamedata/unitdefs_spec.lua` covers def loading. - `.bos`: `BARScriptCompiler.exe` (external tool). - Shaders: compile path plus runtime fallback behavior where applicable. @@ -161,16 +169,17 @@ caused them. ## Tests +Read `.github/spec-instructions.md` before adding or changing anything under `spec/`. + - Add or update unit tests for the behavior you change, not only for shared logic in `common/` and `modules/`: new logic arrives with tests, changed logic has its tests updated, and a bug fix gets a test that fails without it. +- A new test must fail against the commit before yours and pass against yours. A test that passes both is asserting + something nobody changed, and it goes stale without anyone noticing. Never write pending tests. - When rendering or engine callins make code hard to test, extract the decision-making part into a testable function and cover that. Only genuinely rendering-bound behavior stays manual and in-engine. - Tests live in `spec/`, mirroring source layout (`spec/common/`, `spec/luaui/Widgets/`, `spec/gamedata/`) and named `*_spec.lua`. `.busted` sets `pattern = "_spec"` and `ROOT = spec/`, and puts `common/`, `luarules/`, `luaui/`, and `spec/` on `package.path`, so require modules by their repo-relative path. -- `spec/spec_helper.lua` mocks the engine surface (`Spring`, `LOG`, `GG`, `unpack`) — extend it instead of re-mocking - per file. Build engine state with `spec/builders/` (`spring_synced_builder`, `unit_def_builder`, and friends) - rather than hand-rolled tables. ## Compatibility and Data Ownership @@ -192,8 +201,9 @@ caused them. (`.github/PULL_REQUEST_GUIDELINES.md`). - Fill in the "Test steps" checklist in `.github/PULL_REQUEST_TEMPLATE.md`, and attach before/after media for visible changes. -- Player-visible balance and gameplay changes get a `changelog.txt` entry under the current `# Month` heading, in the - existing style: `• [Unit] 1500 -> 1400 health`. Internal refactors and tooling changes do not. +- Player-visible balance and gameplay changes get a `changelog.md` entry under the current `# Month` heading, in the + existing style: `- [Unit] 1500 -> 1400 health`, with sub-points as nested list items. The file is Markdown and is + rendered in-game by `gui_changelog_info.lua`. Internal refactors and tooling changes do not. - Style expectations beyond this file live in `CONTRIBUTING.md` (engine-call overhead, caching Defs lookups, correct iterators, comments explain "why" not "what", no dead code). @@ -207,6 +217,13 @@ caused them. - Only add strings to `language/en/`; the community handles other languages through Transifex (`language/transifex.yml`). +## Mission API + +The data-driven mission runtime has load-order and dispatch conventions of its own. Read +`luarules/mission_api/mission-api-instructions.md` before editing `luarules/mission_api/`, +`luarules/gadgets/api_missions*.lua`, `singleplayer/`, or `spec/mission_api/`. That file, and most of what it +describes, lives on `mission-api/dev` rather than `master`. + ## RmlUi - Follow RmlUi syntax and semantics, but optimize for performance: avoid unnecessary DOM updates, reflows, excessive diff --git a/.github/scripts/emmylua_compare.py b/.github/scripts/emmylua_compare.py index 2ceacd7fe9d..aaac12c21d2 100644 --- a/.github/scripts/emmylua_compare.py +++ b/.github/scripts/emmylua_compare.py @@ -616,6 +616,8 @@ def main(): delta_ceiling = int( args.warn_total_per_kloc * args.changed_file_lines_delta / 1000.0 ) + # A negative delta would mean shrinking a file requires fixing type warnings. + delta_ceiling = max(0, delta_ceiling) budget = { "added": added, "resolved": resolved, diff --git a/.github/spec-instructions.md b/.github/spec-instructions.md new file mode 100644 index 00000000000..be352e7422f --- /dev/null +++ b/.github/spec-instructions.md @@ -0,0 +1,87 @@ +# Spec Instructions — busted unit tests + +This file covers everything under `spec/`. Read it before adding or changing a spec, a builder, or +`spec/spec_helper.lua`. Subsystem conventions that sit on top of these rules live with their subsystem, currently +`luarules/mission_api/mission-api-instructions.md`. + +The rules here exist because a spec is read far more often than it is written, and usually by someone who did not +write it and is trying to work out whether a red test means their change is wrong. + +## The gates that judge a spec + +The gate table, the pinned versions and the handoff rule live in `.github/agents.md` under "Before You Hand Work +Over". Three things about those gates are specific to `spec/` and are easy to get wrong. + +Two exemption lists apply here and they do not agree with each other. `.styluaignore` exempts nothing under `spec/`, +so every spec and every builder is format-gated. `.emmyrc.json` exempts `spec/spec_helper.lua` and +`spec/builders/**` from type checking, and nothing else. So a builder is formatted but never type-checked, while the +spec beside it is both. Never assume a sibling directory carries the same exemptions as yours. + +Because the builders are unchecked, a mistake in one surfaces as a confusing type warning in each spec that uses it +rather than as an error where the mistake is. When warnings appear in a spec you did not expect to touch types, +suspect the builder first. + +Do not read an absent check as a passing one. `gh pr checks ` lists which gates actually ran, and a gate that +never fired looks exactly like one that passed. Run the suite yourself before you hand anything over, particularly +for work based on a long-lived branch, where a spec can go stale for days without anyone seeing red. + +## What a spec has to be + +**Readable without a tour.** Someone looking at one failing assertion must be able to decide whether the expected +value is right by reading the spec file and at most one helper. If understanding your test means opening three +builders and a subsystem helper, inline the fixture instead. + +**Indented with tabs.** The repo uses tabs and StyLua enforces them on every line you write. A space-indented spec +fails the format gate on almost every line of the file, which buries whatever else the check found. + +**Honest about what it proves.** A new test must fail against the commit before yours and pass against yours. If it +passes both, it is asserting something nobody changed, and it will go stale without anyone noticing. + +**About behavior the code actually promises.** Read the implementation before you write the assertion. A test that +invents a rule the module never implemented is worse than no test, because it fails later for a reason that has +nothing to do with the change that tripped it, and the person who hits it has no way to tell which side is wrong. + +**Self-consistent.** Before adding a case, read the neighbouring cases in the same file. Two tests in one spec that +imply contradictory rules mean at least one of them is wrong. + +## What a spec must not do + +Do not re-implement production logic inside the test harness. A builder that mirrors a production module is a second +copy that drifts, and every spec that trusts it inherits the drift. Call the production module instead. A comment of +the form `Mirrors ` in anything under `spec/builders/` is a defect, not documentation. + +Do not assert only that a stub was called. `assert.equal(1, #calls.doThing)` passes when `doThing` does nothing. Wire +the real module into the mock and assert the state it should have produced. + +Do not import the whole builder barrel. Require the builders you use by path rather than including +`spec/builders/index.lua`, which pulls in every builder on every spec file that touches it. + +Do not mix setup conventions inside one directory. Whatever a `spec//` directory does, hand-assigned +globals, a subsystem helper, or a builder, every spec in it does the same thing, so that fixing one teaches you how +to fix the next. + +Do not write specs for behavior that is not implemented. Open an issue instead. A green suite is supposed to mean +the code works. + +## How much to write + +New logic arrives with tests, changed logic has its tests updated, and a bug fix gets a test that fails without it. +That mandate has no upper bound in it, so apply one yourself. + +Specs that add more than twice the lines of the implementation they cover need a sentence in the pull request saying +why. Sometimes the answer is good, because a validation layer really does need a case per rule. More often it means +the same machinery is being driven from several directions, and two specs are covering one behavior. + +Test infrastructure changes, meaning `spec/builders/` and `spec/spec_helper.lua`, land in their own pull request +ahead of the feature that needs them. They are the files every other spec depends on, and they are impossible to +review inside a large feature diff. + +## Where specs live + +Specs mirror the source tree under `spec/` and are named `*_spec.lua`. `.busted` sets `pattern = "_spec"` and +`ROOT = spec/`, and puts `common/`, `luarules/`, `luaui/` and `spec/` on `package.path`, so require modules by their +repository-relative path. + +`spec/spec_helper.lua` mocks the engine surface, currently `Spring`, `LOG`, `GG` and `unpack`. Extend it rather than +re-mocking per file, and keep in mind that it is excluded from type checking, so mistakes in it surface as confusing +type warnings in the specs that use it rather than as errors in the helper. diff --git a/.github/workflows/quick_deploy.yml b/.github/workflows/quick_deploy.yml deleted file mode 100644 index 5beda179786..00000000000 --- a/.github/workflows/quick_deploy.yml +++ /dev/null @@ -1,38 +0,0 @@ -# Workflow to push the change to deploy the change to players -# on push to master much quicker without waiting for cron jobs. -name: Deploy -on: - push: - branches: - - stable -jobs: - deploy: - runs-on: ubuntu-latest - if: github.repository == 'beyond-all-reason/Beyond-All-Reason' - permissions: - id-token: write - steps: - - name: Trigger rebuild - run: | - echo "$SSH_KEY" > id.key - chmod og-rwx id.key - ssh -i id.key -o StrictHostKeyChecking=no debian@repos.beyondallreason.dev byar - env: - SSH_KEY: ${{ secrets.SSH_REPOS_DEPLOY_KEY }} - - name: Authenticate to Google Cloud - id: auth - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: projects/640511349987/locations/global/workloadIdentityPools/github-actions/providers/github - service_account: github-actions@bar-rapid-syncer-176212.iam.gserviceaccount.com - token_format: id_token - id_token_audience: cdnupdater - id_token_include_email: true - - name: Sync files to CDN - run: | - curl --fail -H "Authorization: Bearer ${{ steps.auth.outputs.id_token }}" \ - -X POST -d '["byar"]' https://rapidsyncer-ssd-7xiouooxaa-ey.a.run.app/sync - - name: Update CDN pointer - run: | - curl --fail -H "Authorization: Bearer ${{ steps.auth.outputs.id_token }}" \ - -X GET https://bunny-update-edge-rule-7xiouooxaa-ew.a.run.app/update-edge-rule.sh diff --git a/.github/workflows/rapid-build.yml b/.github/workflows/rapid-build.yml new file mode 100644 index 00000000000..9b57f9341c0 --- /dev/null +++ b/.github/workflows/rapid-build.yml @@ -0,0 +1,51 @@ +# Builds rapid package and deploys to CDN. +# +# Automatically builds `byar:test` version on push to `stable` branch and +# allows to create `byar:pr:{number}` and `byar:br:{branch_name}` custom +# rapid branches via manual workflow trigger. +# +# The policy controling what are the allowed rapid branches based on triggers +# is configured in +# https://github.com/beyond-all-reason/rapid-hosting/blob/main/playbook/group_vars/prod/vars.yml +name: Rapid build +on: + push: + branches: + - stable + workflow_dispatch: + inputs: + pr-or-branch: + description: PR number or branch name to build + required: true + type: string +concurrency: + group: rapid-build +jobs: + build: + if: github.event_name == 'workflow_dispatch' || github.repository == 'beyond-all-reason/Beyond-All-Reason' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Resolve the dispatch input + id: resolve + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + PR_OR_BRANCH: ${{ inputs.pr-or-branch }} + run: | + if [ -z "${PR_OR_BRANCH//[0-9]/}" ]; then + commit="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_OR_BRANCH" --jq .head.sha)" + echo "branch=pr-$PR_OR_BRANCH" >> "$GITHUB_OUTPUT" + else + commit="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$PR_OR_BRANCH" --jq .object.sha)" + echo "branch=br-$PR_OR_BRANCH" >> "$GITHUB_OUTPUT" + fi + echo "commit=$commit" >> "$GITHUB_OUTPUT" + - uses: beyond-all-reason/rapid-hosting/action@main + with: + url: https://repos.beyondallreason.dev/build + repo: byar + branch: ${{ steps.resolve.outputs.branch || 'test' }} + commit: ${{ steps.resolve.outputs.commit || github.sha }} diff --git a/.github/workflows/test_unit.yml b/.github/workflows/test_unit.yml index 69be52da87d..59a87517aac 100644 --- a/.github/workflows/test_unit.yml +++ b/.github/workflows/test_unit.yml @@ -2,9 +2,9 @@ name: Run Unit Tests on: push: - branches: ['*'] + branches: ['**'] # needs to tolerate a slash but not run on tag pushes pull_request: - branches: ['*'] + branches: ['**'] # needs to tolerate a slash but not run on tag pushes jobs: busted: diff --git a/.github/workflowsloc-graph.yml b/.github/workflowsloc-graph.yml deleted file mode 100644 index 2179464b5f0..00000000000 --- a/.github/workflowsloc-graph.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: LOC graph - -on: - schedule: - - cron: '0 0 * * *' # Daily at midnight - workflow_dispatch: - -permissions: - contents: write - -jobs: - build-loc-graph: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: botforge-pro/loc-graph-action@main \ No newline at end of file diff --git a/.luacheckrc b/.luacheckrc index 40bdfc5adc2..3dd852b186f 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -10,6 +10,7 @@ codes = true exclude_files = { "common/luaUtilities/**", ".lux/**", + "mapgenerator/**", -- ${PLACEHOLDER} templates, not parseable as Lua "recoil-lua-library/**", } @@ -57,19 +58,19 @@ globals = { -- "LCS", "Path", "Table", "Log", "String", "Shaders", "Time", "Array", "StartScript", "CMDTYPE", "COBSCALE", "CallAsTeam", "SYNCED", "loadlib", - -- Unit scripts (LUS) - "piece", "script", "UnitScript", "UNITSCRIPT_DIR", "Turn", "Move", "Spin", "StopSpin", "Hide", - "Show", "Explode", "EmitSfx", "Sleep", "StartThread", "Signal", "SetSignalMask", "WaitForTurn", - "WaitForMove", "GetUnitValue", "SetUnitValue", "x_axis", "y_axis", "z_axis", "SIG_WALK", - "SFX", "unitID", "MultiMove", "MultiTurn", - -- BAR namespace and shared helpers - "BAR", "Utilities", "Debug", "I18N", "I18N_PATH", "Lava", "GetModOptionsCopy", "lowerkeys", "pairsByKeys", - "ipairs_reverse", - -- Handler internals - "addon", "handler", "actionHandler", "fontHandler", "ghInfo", "CALLIN_MAP", "_G", - -- Game data and commands - "GameCMD", "ExplosionDefs", "GadgetCrashingAircraft", "Scenario", "SG", "CMD_AREA_MEX", "CMD_WANTED_SPEED", - "CMD_WANT_CLOAK", - -- Lua/runtime - "socket", "gcinfo", "game_engine", -} \ No newline at end of file + -- Unit scripts (LUS) + "piece", "script", "UnitScript", "UNITSCRIPT_DIR", "Turn", "Move", "Spin", "StopSpin", "Hide", + "Show", "Explode", "EmitSfx", "Sleep", "StartThread", "Signal", "SetSignalMask", "WaitForTurn", + "WaitForMove", "GetUnitValue", "SetUnitValue", "x_axis", "y_axis", "z_axis", "SIG_WALK", + "SFX", "unitID", "MultiMove", "MultiTurn", + -- BAR namespace and shared helpers + "BAR", "Utilities", "Debug", "I18N", "I18N_PATH", "Lava", "GetModOptionsCopy", "lowerkeys", "pairsByKeys", + "ipairs_reverse", + -- Handler internals + "addon", "handler", "actionHandler", "fontHandler", "ghInfo", "CALLIN_MAP", "_G", + -- Game data and commands + "GameCMD", "ExplosionDefs", "GadgetCrashingAircraft", "Scenario", "SG", "CMD_AREA_MEX", "CMD_WANTED_SPEED", + "CMD_WANT_CLOAK", + -- Lua/runtime + "socket", "gcinfo", "game_engine", +} diff --git a/README.md b/README.md index c984038c1d3..6334f0be229 100644 --- a/README.md +++ b/README.md @@ -152,10 +152,3 @@ Then open **User Settings (JSON)** and add: } ], ``` - -### Lines of Code Over Time - - - - Lines of code over time - diff --git a/bitmaps/gpl/jet2.bmp b/bitmaps/gpl/jet2.bmp deleted file mode 100644 index 56979a154cf..00000000000 Binary files a/bitmaps/gpl/jet2.bmp and /dev/null differ diff --git a/changelog.md b/changelog.md new file mode 100644 index 00000000000..fd57ccd00cb --- /dev/null +++ b/changelog.md @@ -0,0 +1,719 @@ +# September +- [Legion changes] + - Perdition doesn't gain extra range from elevation + - Martyr damages are added to its team's damage dealt + - EMP damage prevents reactive armor from regenerating + - Disable Air Units now removes and refunds drone spawners + - Range rings, graphics, blueprints, and other housekeeping +- [Scavenger Zombies] + - After 15 minutes into the game, zombies will swarm all teams evenly once they reach 10% of the value of all players combined. + - Zombies spawn with XP skewed to the minimum veterancy so they aren't so tanky so often. + - When zombie revive timer has been reset, a purple poof now appears above it. + - Units that don't leave corpses like the Fiend will no longer respawn as zombies. + - Zombie constructors get a boosted capture range of a minimum of 300. This makes them capable of capturing aircraft. + - Zombies now can control aircraft when they're captured or produced. + +# August +- [Spectre] 12500 -> 9000 energycost, 165 -> 150 metalcost, 380 -> 450 health +- [T1, Seaplane Air Constructors] -35% energycost, -35% buildtime, -35% speed +- [LRPCs] 1100 -> 900 weaponvelocity +- [Nukes] 1.3x base damage, 0.45 -> 0 edgeeffectiveness -> More damage dealt at the center of explosion, less damage on the edges of the explosion +- [Sumo] 6500 -> 6000 health +- [Mammoth] 22.5 -> 23 speed +- [Sheldon] 50.4 -> 50 speed +- [Floating AA turrets] Stats made to match their land counterparts +- [Legion changes] + - Basic mex now similar stats as other T1 mexes, T1.5 mex removed + - Wind generator 45m -> 43m cost + - Solar 155m -> 150m cost + - Goblin 25m, 500e -> 30m, 420e cost + - Satyr 400e -> 500e cost, 1100 -> 1220 buildtime, can no longer fire vertically + - Phalanx 50 -> 44 speed + - Alaris weapon switched from gauss to a shotgun with the same range and a 10% higher dps. Unit should feel more responsive overall with a slightly higher acceleration, turnrate, turret turnrate, and weapon projectile speed. Unit speed reduced 102->99. + - Helios 69 -> 75 speed, 330 -> 320 range, 400 -> 370 turnrate + - Lance 3800e -> 3600e cost + - Prometheus friendly fire significantly reduced + - Inferno firing pattern changed to sector fire so its shots will have much less vertical spread, 1100 -> 1200 range + - Perdition stockpile time 50s -> 40s + - Rhapsis (T1 Medium AA Tower) 156 -> 180 DPS, 840 -> 950 range + - Pluto (T2 Microflak Tower) and Fulmen (Naval Microflak Tower) 800 -> 875 range + +# July +- [Stout, Brute] +10% buildtime, 1.1667s -> 1.2s reloadtime, 330 -> 350 sightdistance +- [Rover] 1000 -> 1100 buildtime +- [Pounder] 1500 -> 1400 health +- [Grunt] 500 -> 520 sightdistance, 42 -> 43 metalcost +- [Gunslinger] 1560 -> 1800 health, 49.5 -> 50 speed, 24 AoE added, 500 -> 600 weaponvelocity +- [Welder] 9500 -> 8000 buildtime, 2950 -> 3500 health, 47.4 -> 48 speed +- [Sumo] 15000 -> 12000 buildtime, 5940 -> 6500 health, 37.5 -> 38 speed, 0.16 -> 0.3 beamtime, 25 -> 55 dmg vs air +- [Turtle] 450 -> 600 weaponvelocity, Impulse added +- [Torpedo gunships] + - Puffin: 18000 -> 14000 buildtime, speed 271 -> 210, removed special damage vs commander + - Monsoon: 13000 -> 11000 buildtime, speed 266 -> 200, 5s -> 4s reloadtime +- [AA turrets] + - Flak turrets: 775 -> 850 range, 0.5333 -> 0.5 reloadtime + - Chainsaw / Eradicator: 1125 -> 1200 range + - Ferret: 840 -> 950 range, 176 -> 208 DPS, Stealth added, 360 -> 330 metalcost, 5800 -> 5000 buildtime, 1330 -> 1600 health + - SAM: 840 -> 950 range, 190 -> 225 DPS, 315 -> 350 metalcost, 6100 -> 5500 energycost, 5240 -> 4500 buildtime, 2800 -> 2500 health +- [T2 fighters] + - Highwind: +7% costs + - Nighthawk: +7% costs, 740 -> 690 range +- [Legion changes] + - Phobos health increased 800 -> 840, turnrate increased 720 -> 800 + - Karkinos cost reduced 330m2600E -> 310m2400E, heatray reloadtime reduced 2 -> 1.8s, shotgun range increased 240 -> 250 + - Dragon's Jaw no longer gains range with elevation + - Arquebus reloadtime increased 6 -> 7s, damage increased 750 -> 850 + - Thanatos reloadtime reduced 9 -> 8s + - Quickshot turret turn speed and weapon velocity increased + - Blindfold health reduced 890 -> 600, juno bomb aoe reduced 700 -> 500 + - Phoenix bugfix, it should no longer fire up into space + +# June +- [General] Hold ALT when upgrading metal extractors to ignore allied extractors. +- [All Builders] Idle mobile builders now auto-repair units within a range that depends on the movement state. +- [Commando] 1560 -> 1800 health, EMP resistance, weapon inaccuracy removed, weapon can target air, but does reduced dmg vs air (30%) +- [Legion changes] + - Phalanx cost increased 450m4750E -> 470m5000E + - Praetorian speed increased 63 -> 72, health reduced 25000 -> 22000, flak dps reduced 514 -> 385, missile battery changed from a burst of 3 -> 12 with smaller missiles (dps 100 -> 300), cluster cannon removed + - Aquilon (T2 aa bot) airlos increased 850 -> 1100, railgun range increased 1050->1150, reloadtime reduced 4s -> 3s (dps 56 -> 75), microflak range increased 650 -> 700, dps reduced 506 -> 300 + - Glaucus (scout hover) speed increased 96 -> 102 + - Thalassa (T2 cruiser) range increased 450 -> 500 + - Belcher sprayangle reduced 2500 -> 1500 + - Advanced solar buildtime 13580 -> 12500 + +# May +- [T1 bot constructors] +70 health +- [Pawn] Weapon 3 -> 2 dmg vs Air +- [Blitz] Weapon 3 -> 2 dmg vs Air, 750 -> 730 health +- [Rover] 950 -> 1000 buildtime +- [Stout] 75.9 -> 75 speed, 2900 -> 3100 buildtime, 1780 -> 1800 health +- [Brute] 72.9 -> 72 speed, 3310 -> 3500 buildtime, 1970 -> 2000 health +- [Pounder] 40.5 -> 40 speed, 3000 -> 3100 buildtime, 1490 -> 1500 health. Weapon projectile no longer overshoots its range, and doesn't gain extra range from elevation +- [Hound] 1280 -> 1200 health, 3.167 -> 3.3s reloadtime +- [Tiger] 70.5 -> 69 speed, 665 -> 690 metalcost +- [Archangel] 1.5s -> 1.3s reloadtime for longrange missile +- [Manticore] 1.6s -> 1.4s reloadtime for longrange missile +- [Razorback] -5% dps +- [Shiva] 48.3 -> 48 speed, 1550 -> 1600 metalcost +- [Stormbringer] 105 -> 110 damage, +5% m/e/bt +- [Skyhook] 200 -> 185 speed, 6400 -> 9000 energycost +- [Abductor] 225 -> 210 speed, 6600 -> 10000 energycost +- [Stronghold] 175 -> 160 speed, 11000 -> 13000 energycost +- [Battleships] Reduced damage vs subs +- [Cloaked Fusion] 3650 -> 3550 metalcost, 75e/s -> 50e/s cloakcost + +# April +- [Brakerate] + - Set to 90 elmos/s^2 for vehicles, if lower previously. + - Mostly helps slow artillery units to not skid forward, after they've reached their firing range. + - Set to 180 elmos/s^2 for bots, if lower previously. Only affects radar and jammer bots. +- [Sightdistance] + - Set to 330 for vehicles, if lower previously. + - Set to 380 for bots, if lower previously. Except crawling bombs, are kept at 260. + - Affects mostly cons and some artillery, plus notably Centurions, Fiends and rocket bots. +- [Legion changes] + - New modoption added (legionsimplifiedmexes) which rebalances T1 legion to use the same T1 mexes as arm/cor. Light T1 units are given a higher m cost and lower E cost and heavy T1 units are given a lower m cost and higher E cost + - Impulse removed from Goblin, Wheelie, and Hippocampus + - Wheelie cost reduced 25m370E->23m350E, range increased 160->168, bullet velocity increased to improve accuracy + - Phobos health increased 750->800 + - Barrage's napalm aoe reduced 75->60 + - Cacophony cost reduced 420m5500E->380m5500E, health reduced 2350->2200, dps reduced 270->255 + - Adv solar health increased 800->1100 + - Octeres (T1 Artillery ship) reloadtime reduced 11->10s, range increased 930->960 + - Decurion energycost increased 3000->3600 + - Lance reloadtime reduced 8->7.5s + - Arquebus cost reduced 800m16000E->750m15000E + - Javelin speed increased 65->68 + - Praetorian speed increased 60->63, acceleration and turnrate improved + +# March +- [Legion changes] + - Telchine (T2 amphib bot) script and targeting improvements, range increased 400->450 + - Incinerator firing E cost 500->300E/s + - Dolus (T2 radar/jammer ship) speed increased 36->42 + - Phoenix (T2 heatray bomber) movement behavior adjusted to reduce heatray range/damage exploits + +# February +- [Centurion] 330 -> 325 range +- [Hound] buildtime 6230 -> 6500 +- [Fatboy] energycost 15000 -> 20000, buildtime 28000 -> 32000 +- [Sprinter] range 220 -> 230 +- [Abductor] sightdistance 430 -> 520 +- [Minelayers] Transportable by basic transport +- [Mines] mincloakdistance 8 -> 30 +- [T2 radars] 820 -> 1000 sight, 355 -> 500 health +- [T1 radars] 90 -> 180 health +- [Castro] -13% m/e/bt cost +- [Lightning weapons,except Thor] Firing takes -10e for all, used to vary between -5e to -35e +- [Rez subs] + - Removed reclaimspeed reduction -> +20% faster reclaim. Matches its usual buildpower now + - Autoheal 2hp/s -> 5hp/s (lost its idleautoheal of 3hp/s after 10s) + - +16% energycost, metalcost +- [T1 AA ships, subs, and frigates] Autoheal removed +- Idleautoheal standardised to 5hp/s after 60 seconds without getting hit, for everything. + - Rezbots got a 5hp/s normal autoheal to replace their near-instant idleautoheal. +- [Legion changes] + - Carriers all start with half of their drones pre-built, with the cost of those drones added to the carrier + - T1 Drone health reduced 415->325, acceleration increased, drones retreat after taking 1 aa shot + - T2 Drone health reduced 2250->1650 + - Removed health scaling for drones when gaining xp + - Drones no longer have health decay while in the air when the carrier is alive but decay quickly once the carrier is dead + - T1 drones now have to return to carrier to reload after firing 12 shots + - Reduced overall range of drones and made them more tied to their actual ranges to prevent range extension abuse + +# January 2026 +- [Incisor] 0.767s -> 0.8s reloadtime, 85.5 -> 85 speed, 2200 -> 2300 buildtime, 1040 -> 1100 energycost +- [Blitz, Pawn] 500 -> 600 weaponvelocity +- [Vehicle scouts] +10% reloadtime +- [Banshee, Roughneck] 800 -> 1000 weaponvelocity, 16 -> 40 AoE +- [Hornet] Missile tracks properly +- [T1 bombers] Sprayangle removed, Stormbringer -5 speed +- [Sprinter] 171 -> 160 metalcost, 4140 -> 3800 energycost, 500 -> 600 weaponvelocity +- [Sheldon] 2200 -> 2800 energycost, 410 -> 400 metalcost +- [Fatboy] 6.7333s -> 7s reloadtime, 0.85 -> 0.15 edge effectiveness, 240 -> 300 AoE, 11000 -> 15000 energycost +- [Tzar] 3 -> 3.5 reloadtime, 40.5 -> 39 speed +- [Bull] 60 -> 62 speed +- [Sumo, Battleships] +10% health +- [Spybots] 17600 / 22200 -> 12000 buildtime, change overrides the buildtime formula for them +- [Hound, Gunslinger, Crawling Bombs] buildtime not changed by the formula below +- [Hover platforms] -80m, -750e, -800bt cheaper +- [T1 airplants] -60m, -300bt cheaper +- [T2 constructors] +15% buildpower +- [Seaplane constructors] T2 airplant added to buildlist +- [Construction turrets] + - Metal cost: 210 -> 230 + - Energy cost: 2600 -> 3200 (floating version) +- [Factory buildpower] + - All T2 factories: -300 metalcost, 1.5x buildtime. Except Cortex Vehicleplant only -200 metalcost. + - T2 factories (bots, vehicles, navy) buildpower: 300 -> 600 + - T2 airplants buildpower: 200 -> 600 + - Seaplanes buildpower: 200 -> 300 + - T3 gantry buildpower: 600 -> 1800 +- [Units from t2, t3 and seaplane factories] + - New buildtime = old buildtime * 1.1 + (metalcost * 60 + energycost) / 20 + - Roughly 30% for most units. Less for units with already high bp costs like air, more than that for fast-building units like most ships +- [Advanced geothermals] +50% buildtime +- [Cortex fusion] + - Metal cost: 4500 -> 3600 + - Energy cost: 26000 -> 22000 + - Buildtime: 75400 -> 59000 + - Energy generation: 1100 -> 850 + - Health: 5000 -> 4300 +- [Armada fusion] + - Metal cost: 4300 -> 3350 + - Energy cost: 21000 -> 18000 + - Buildtime: 70000 -> 54000 + - Energy generation: 1000 -> 750 + - Health: 4450 -> 3800 +- [Cloaked fusion] + - Metal cost: 4700 -> 3650 + - Energy cost: 26000 -> 22000 + - Buildtime: 84400 -> 65000 + - Energy generation: 1050 -> 750 + - Cloak cost: 100 -> 75 + - Health: 4450 -> 3800 +- [Decoy Fusion] + - Metalcost: 370 -> 270 + - Health: 5200 -> 3800 +- [Advanced solars] + - Energy generation: 75 -> 80 +- [Shield Rework] + - Shields block projectiles, preventing them from bouncing unpredictably and sometimes into the backline. + - Things inside the shield are protected from blocked projectiles AoE. + - When a shield is near 0 capacity, the last hit over-damages the shield, requiring it to recharge that amount of excess capacity usage before coming back online. + - In addition, there's a minimum down time. + - Projectile types blocked by shields unchanged. +- [Resurrection] Resurrected units regain their old XP. + +- [Legion changes] Updates relevant Legion unit stats to reflect Season 3 changes. Changelog is as follows: + - Cluster weapon damages and reloadtimes increased by ~30%, cluster secondary munition damage increased ~50% with lowered projectile counts + - Napalm weapon leadlimits set to 0, meaning they will always fire at the current location of its target instead of its predicted location + - Commander aa weapon reduced to 300 range + - Medusa tracking reduced to make retargeting weaker + - Martyr speed nerfed to 220 from 230, turnrate nerfed to 750 from 800 + - Mosquito weapon AOE increased to 72 from 70, stockpile time reduced to 1.8 seconds from 2 seconds + - Spy bot became slightly cheaper and slower, buildtime reduced accordingly first to match other spybots + - Strider energy cost reduced to 5250 from 5400 + - Scylla 15% health buff in accordance with other battleships + - Prometheus speed increased to 52 from 51 + - Inferno reloadtime reduced to 7 seconds from 8 seconds + - Alaris energy cost increased to 850 from 800, buildtime increased to 1650 from 1600, reloadtime slightly increased to 2.3 seconds from 2.25 seconds + - Wheelie reloadtime increased by 10% in accordance with other scout vehicles + - Decreased Legion Advanced Solar Collector costs by 3% across the board + - Factory changes are identical to the other factions. Buildtime updates for units use the same formula as for other T2, T3, and seaplane units in the other two factions. + - Legion fusion: + - Metal cost: 4900 -> 4000 + - Energy cost: 27000 -> 25000 + - Buildtime: 80000 -> 66000 + - Energy generation: 1200 -> 950 + - Health: 5400 -> 4600 + +# December 2025 +- Unified maximum water depth for non-amphib land units to 22 (previously varied between 22-30) +- Unified minimum water depth for non-heavy ships to 8 (previously varied between 8-10) +- [Legion changes] + - T2 shipyard, ships, seaplanes, and naval structures added + - Praetorian shotgun spread reduced 1900->1400 + - Decurion range reduced 380->360, now deals 25% instead of 100% damage vs air + - Hippocampus (scout ship) now deals 25% instead of 50% damage vs air + - Small napalm blobs now deal 60dps and last 7s, previous was 45dps for 10s + - Inferno reloadtime 9s->8s + - Perdition stockpile time 70s->50s, stockpile cost reduced 500m17000E->350m14000E, impact damage 2000->1200 damage, napalm deals 120dps for 15s (3000 combined damage) + - Napalm damage cap increased 100->120dps + - Martyr damage vs commanders -25% -> -50% + - Syracusia (Destroyer) health reduced 4000->3800 + - Thalassa (Cruiser) health increased 5400->5600 + - Scylla (Battleship) health increased 8000->9000 + - Corinth (T2 artillery ship) cost increased 12000m115kE->13000m125kE, speed reduced 10% + - Ionia (T2 floating turret) mg range increased 650->700 + +# November 2025 +- [Legion changes] + - Karkinos cost increased, health increased, shotgun slightly higher dps with 2-round burst + - Telchine cost reduction 660m19000E->600m13200E, firing angle increased, speed in water increased 30% + - Triton speed reduced 60->55, range reduced 600->550, speed in water increased 30% + - T1 shipyard, ships, and naval structures added + - Iapetus (aa ship) cost 330m4800E->250m3600E, model scaled down 10%, fire rate reduced 15%, health reduced 20% + - Argonaut (frigate) tracking reduced + - Hippocampus (scout ship) speed increased 93->97, acceleration increased 4% + - Ketea (sub) cost reduced 340m2600E->320m2400E, speed increased 54->57, health reduced 640->600 + +# August 2025 +- [Aircraft] Vision raised to 430, if it was lower previously +- [T1 Bombers] Random inaccuracy removed from their bombs +- [T2 Transports] Skyhook 235 -> 200 speed, Abductor 241 -> 225 speed +- [Hound] 292 -> 340 weaponvelocity +- [Razorback] 58 -> 22 damage vs air +- [T2 AA bots] Sightdistance 925 -> 1200 +- [Flagships] Reduced damage vs submarines with main cannon +- [Grunt] 270 -> 280 health + +# July, 30 +- Gunslinger movement class changed to 3x3 and hitbox adjusted +- Sprinter movement class changed to 3x3 and colvol adjusted to cover the funit fully at all angles +- Welder movement class changed to 3x3 and footprint adjusted +- Bulls movement class changed to 4x4 and slight increase in crush damage +- Arm minelayer movement class changed to 3x3 + +# July 2025 +- [T1 Mex] +41% hp +- [Conbots] +15% hp +- [T1 turrets (not aa, not popups)] -10% buildtime +- [Commander] 25 -> 30 energymake +- [T1 factories] -150 metalcost, -250 energycost, -1500 buildtime, 100 -> 150 buildpower +- [Missile trucks] taller hitbox +- [Grunt] -2,5% costs +- [Blitz] 99 -> 101 speed +- [Starlight] 13500 -> 18500 energycost +- [Bull] 65.1 -> 60 speed +- [Jaguar] 320 -> 300 range, -10% dps (lightning) +- [Tzar] 22000 -> 28000 energycost +- [Sprinter] -10% costs +- [Termite] -10% costs, 48.3 -> 50 speed +- [Juggernaut] 33.6 -> 37 speed +- [T2 AA bots] + - New weapons. + - Longrange (1300) missile + flak for Cortex + - Longrange (1200) missile + shortrange missile for Armada + - ~Double health + - +30% energycost +- [Flagships] + - Reduced firerate on big gun, slightly increased damage per shot. Together, ~25% dps nerf + - Bigger AoE and Impulse on big gun, reduced edgeeffectiveness to 0.15 + - +17% costs + +# June 2025 +- [T2 flak turrets] increased footprint from 2x2 to 3x3 +- Warrior movement class from 2x2 to 3x3 (pawn to fido spacing) + +# June 2025 +- [Legion changes] + - 2 new T3 units added: Myrmidon T3 all-terrain mech, Charybdis T3 Hovertank + - New models added to replace the placeholder models left in T2: infestor, spybot, radar bot, jammer bot + - Phobos cost reduced 150m2400E -> 140m2200E + - Decurion buildtime increased 4000-4800 to slow down repair rate, energy cost increased 2700 -> 3000 + - Arquebus cost reduced 900m18000E -> 800m16000E + - Thanatos speed reduced 50 -> 45, turnrate reduced 750 -> 300 + - Triton minigun dps reduced 135 -> 105 + - Medusa range increased 950 -> 1000 + - Keres health reduced 23000 -> 21000 + - Praetorian shotgun fires 10% faster + - Daedalus range increased 900 -> 950 + - Starfall cost increased 58000m660000E -> 63000m720000E, reloadtime increased 15s->18s, lowered damage vs shields + +# May 2025 +- [Legion changes] + - 2 new units added: Aquilon T2 aa bot, Chimera T2 pop-up turret + - Cluster plasma was rebalanced and is overall stronger than before + - Wildfire removed from T2 air lab, Skuttle removed from t2 bot lab, Behemoth removed from T3 gantry + - Script improvements to allow several units to reliably fire while turning: Alaris, Decurion, Quickshot, Phobos + - Mosquito stockpile count reduced 8->4, stockpile rate increased slightly + - Gladiator projectile speed increased 320->360 to improve accuracy + - Lance cost reduced 260->240m, speed increased slightly +- [Tiger, Turtle, Sumo] Mass 750, to make them transportable by light transports. +- [Spybot] Emp immune again. Max stuntime vs units 8 -> 10 +- [Banisher] Energycost 17000 -> 23000, Turnrate 300 -> 250, Acceleration 0.2269 -> 0.2 +- [Tzar] Buildcost 26100 -> 30000 +- [Lava]: Units in lava slow down up to 5x depending on their submersion level + +# April 2025 +- [Pawn] 1420 -> 1650 buildtime +- [Pawn] 52m 870e -> 54m 900e +- [Grunt] 210 -> 215 range +- [Spybots] Can paralyze buildings up to 20s +- [Antinuke buildings] Health 3650 -> 3300 (facilitating a full-length stun from spybot) +- [Shuriken + Abductor] Regain targetmoveerror, revert Shuriken reloadtime 1.3s -> 1.2s + +# March 2025 +- [Armada solar] Buildtime 2800 -> 2600 +- [Armada wind] Metalcost 37 -> 40 +- [Cortex wind] Metalcost 45 -> 43, Health 199 -> 220 +- [Armada tidal] Energycost 250 -> 200 +- [Armada asolar] Metalcost 370 -> 350 +- [Cortex cons] +5% BP +- [Exploiter] Buildtime 2720 -> 2900 +- [T2 radars] Metalcost 560 -> 400, Energycost 19000 -> 14000, Buildtime 11800 -> 8000 +- [Sneaky Pete] Cloaking removed +- [Jammer ships] Costs +130%, Health +130%, Speed 61 -> 40 +- [Spybots] Cloaking cost 100/50 -> 40/15, EMP immunity removed, Attack command for self-destruct added, Damage 56000 -> 5000, Paralyzetime 35/20 -> 8s +- [Banisher] Can now fire in all directions, Sightdistance 550 -> 650, Energycost 23000 -> 17000, Improved aim/tracking +- [Tremor] Damage 150 -> 200, AoE 200 -> 210, Weapon hits a wider area +- [Poison Arrow] Energycost 29000 -> 21000, Buildtime 22200 -> 19000, Sightdistance 385 -> 500 +- [Turtle] Sightdistance 372 -> 500 +- [Garpike/Pincer] Sightdistance 305 -> 500, Faster turning turrets +- [Hound] Lost gauss weapon switch, Metalcost 300 -> 285 +- [Recluse] Range 600 -> 575, Speed 52 -> 47 +- [Grunt] Metalcost 36 --> 43, Energycost 880 -> 840, Range 230 -> 210, Turnrate 1391 -> 1200 +- [Tick] Metalcost 17 -> 21, Energycost 340 -> 300, Health 61 -> 60 +- [Pawn] Metalcost 48 -> 52, Energycost 960 -> 870, Health 335 -> 370 +- [Rover] Health 89 -> 105 +- [Rascal] Health 75 -> 90 +- [Blitz] Health 690 -> 750, Ellipsoid hitbox, Sightrange 299 -> 350 +- [Incisor] Buildtime 1761 -> 2200, Sightrange 273 -> 330 +- [Shuriken] Reloadtime 1.2s -> 1.3s +- [Stormbringer] Drops bombs 17% closer together +- [Banshee] Health 485 -> 560, Turnrate increased, New weapon graphics with functionally similar stats +- Inaccuracy removed from all T1 laser weapons + +# January 2025 +- [Artillery] The High/Low Trajectory toggle has been removed and been automated. Both modes now share the same damage and AOE. Aiming low is preferred, but if there's no low trajectory targets in range or the manually selected target cannot be shot with low trajectory, high trajectory will be used instead for a short time. + - The following units are affected: + - [Gauntlet] (T1 Plasma Artillery Turret) + - [Agitator] (T1 Plasma Artillery Turret) + - [Rattlesnake] (T2 Plasma Popup Artillery) + - [Persecutor] (T2 Plasma Popup Artillery) + - [Vanguard] (T3 Mobile All-Terrain Artillery) +- [Angler] The T2 Cortex torpedo bomber now drops 1 large torpedo instead of 3 small ones. The large torpedo is slightly slower but has some aoe +- [Serpent] The Arm T2 battlesub now fires 2 medium torpedoes at once instead of 1 large one, overall dps unchanged but some aoe is added +- [Kraken] The Cor T2 battlesub's torpedo aoe increased + +# December 2024 +- [Impulse] Nukes + Plasma Cannons + Shiva + Catapult + Vanguard + Tzar + Fatboy + Banisher + Poison Arrow + Gunslinger + Ballistic Missile Launchers got impulse added to their weapon. High-HP units with low mass got a mass increase. +- [Tremor] Impulse 140% -> 80%, EdgeEffectiveness 90% -> 15% +- [Calamity + Raghnarok] +10% E/M cost, energycost and damage of individual shot increased, reloadtime increased (same dps as before), AoE lowered +- [Light Mine] Cloakcost 0.5 -> 1e/s, metalcost 5 -> 7, buildtime 50 -> 100 +- [Medium Mine] Cloakcost 1 -> 2e/s, metalcost 16 -> 25, buildtime 100 -> 300, EdgeEffectiveness 0.7 -> 0.5, Impulse 1 -> 0.8 +- [Heavy Mine] Cloakcost 1.5 -> 6e/s, metalcost 21 -> 50, buildtime 125 -> 700, EdgeEffectiveness 0.7 -> 0.5, Impulse 1 -> 0.8, damage 1390 -> 3000, AoE 300 -> 330 +- [Crawling Bombs] Use bigger (self-d) explosion on attack command. Dying to enemy fire still results in small blast (including while being transported). Transportable. +- [Crawling Bombs death explosions] AoE 432 -> 400, damage 3350 -> 2700, damage vs Crawlling Bombs 220 -> 400 (chain easier) +- [Roach] Speed 81 -> 76 +- [Skuttle] Metalcost 540 -> 755, energycost 26 000 -> 27 000, cloakcost 150 -> 15, cloakcost moving 400 -> 40 +- [Destroyers] Depthcharge turnrate increased -> hits crawling bombs reliably + +# October 2024 +- [All units] EMP resist for units is standardized, and units that had low emp resists now take full emp damage. Units that had between 50-95% emp resist now all have 80% emp resist. Units that had 95+% emp resist are now fully emp immune. +- [New units] Heavy T1 air transport for arm (Osprey) and for cor (Hephastus) moved to the basegame. These transports can carry the same weight as T2 transports but at a lower movespeed, and are able to transport the commander. Osprey costs 190m 4000E, has 110 speed, and 630 health. Hephaestus costs 190m 4000E, has 100 speed, and 800 health. +- [Stork/Hercules] T1 light air transports no longer able to transport the commander, and can now only carry units below 750 metal. Energy/metal/buildcost reverted to earlier values (68m 1300E 3850bp for Stork and 74m 1450E 4120bp for Hercules) +- [Shuriken] emp damage per shot reduced from 800-600, weapon can no longer fire at air units +- [Abductor] emp damage per shot reduced 22500->10500, beam duration 0.5->0.2s (improves accuracy), stuntime reduced from 15->6s +- [Liche] weapon no longer has an extra impulse multiplier +- [Skyhook] T2 cor air transport speed increased 210->235, health increased 1830->2200. Line of sight increased 260->500 +- [Webber] No longer able to target air units + +# September 2024 +- [All units] Gravity standardized for all projectiles to 130, so weapons will behave the same way across all maps. +- [Rocketeer + Aggravator] Reverted the last 2 balance changes: damage per shot 173->157, reloadtime 4->3.8s, speed/acceleration 5% higher +- [Lazarus + Graverobber] Metal cost increased 110->130, E cost unchanged, buildtime increased 2400->2800 +- [Mace] Health increased 900->1000 +- [Thug] Health increased 1000->1100, buildtime increased from 1970->2100 so it doesn't get repaired too quickly +- [Tremor] Reloadtime reverted to 0.5s, impulse reduced 30% +- [Vanguard] Range increased 1325->1450, health reduced 10000->8500. Tremor and Vanguard's higher ranges now let them outrange long range defences (Rattlesnake, Persecutor, Pulsar) + +# August 2024 +- [Tremor] firemodes unified to remove the toggle, and effective DPS in target area increased. Wasp bugfixed, and now has mild tracking on rockets, so will miss shots less often. +- [Dragon Claw/Maw] Collision volume and aim position will now change when the units open up, so it can be targeted when behind a wall + +# July 2024 +- [Minelayers] Minelayers now live up to the 'minesweeper' role properly, being able to detect enemy mines within 450 radius, and slowly clear them, with a moderate ranged weapon. +- [Naval Economy Buildings] Stats made equal to land versions. Specifically, T1 and T2 Energy Converters, Energy and Metal Storages, and the T2 mex have been harmonised for both Core and Arm. +- [Salamander] Energy cost increased 4775->7000 to make the unit more difficult to rush early on and put its cost more in line with other amphibs (energy being 20x metal cost). Range reduced from 360->340 to make it slightly more difficult to kite T1 units and commanders. +- [Whistler + Lasher] Ground range increased by 25 (525/550->550/575 range) +- [Centurion] Unit script improved so the unit should keep firing while turning +- [T2 Cruisers] Reloadtime of depthcharges reduced by 10% +- [T2 Lightning/Flamethrower Ships] Cost reduced 25%, health reduced 20%, speed increased 6%, dps reduced 20%. Flamethrower ship model size scaled down by 10% + +# June 2024 +- [Rocketeer + Aggravator] Speed and acceleration reduced by 5% +- [Whistler + Lasher] Changes from proposed units rework modoption moved to base game. {Whistler and Lasher weapon switches from ground and anti-air modes. Anti-air has 700 range and tracking missiles, while ground has 525/550 range and no tracking. Ground speeds increased by 10 (45->55, 42->52), ground dps increased by 40% (17->25)} +- [T1 Air Transports] E costs increased by 70%, buildtime increased by 20%. This change is to increase the time it takes to rush transports and share them to the team. +- [Herring] unit now switches between ground missiles and longer range tracking aa missiles (750 range). No change to ground missile stats. Unit now takes 50% damage from emp instead of 30%. +- [Dragon Claw/Maw] Damage taken while closed increased from 25%->33%. Dragon Claw range reduced 440->430 + +# March 2024 +- [Proposed Units Rework Modoption] Rework to Whistler and Lasher added to modoption, which has the units switch between longer range tracking aa missiles and non-tracking ground missiles. Mauser, Quaker, Stiletto changes removed from modoption. +- [Mauser + Quaker] Rework from proposed_unit_reworks modoption moved to main game with a few additional changes. This rework aims to give these units a more mobile and aggressive role, which reduces the role overlap with the heavier T2 veh artillery options. Their ranges are reduced by 120, speed increased by 20%, acceleration increased 50%, health increased 20%, and accuracy is improved. +- [Stiletto] Rework from proposed_unit_reworks modoption moved to main game. This rework aims to give the stiletto a more specialized role for disabling specific targets while being less efficient as a defensive option against groups. The unit's metal and energy costs are doubled, buildpower cost increased 50%, health increased by 30%, paralyze time increased from 10s->20s, bomb count reduced from 5->3, aoe reduced from 240->200, emp damage per bomb increased 4000->6000 +- [Banshee] Reloadtime reduced by 10% (DPS 34->38) +- [Dragon Claw] DPS reduced by 14% (185->159) +- [Razorback] Laser damage vs air reduced from 75% to 50% +- [Demon] Health reduced from 20000->18000 +- [Crocodile and Cayman (T1 hovertanks)] Crocodile cost reduced 290m2600E->270m2400E, Cayman cost reduced 320m3300E->300m3100E +- [Salamander] EMP resist reduced from 95%->90% +- [Epoch and Black Hydra] Projectiles for main cannons increased in size, damage, and aoe, with a reduced fire rate + +# February 2024 +- [Grunt] Speed reduced from 84->81 +- [Pawn] Speed increased from 84->87 +- [Demon] Buildtime cost increased from 90000->120000 +- [Thug/Mace] movement footprint increased to prevent them blocking each other's turrets when attacking +- [New Units] Demon (T3 cortex flamethrower mech), Salamander (T2 cortex amphibious tank, replacing Alligator) moved from release candidate modoption into the base game. Dragon rework (new model, flamethrower, light aa) moved to the base game. Flamethrower ship and Lightning ship moved from expandedT2sea modoption into the base game +- [Dragon] Fire rate of anti-air weapon reduced by half +- [Demon] DPS reduced from 2000->1600, unit no longer has amphibious +- [Lightning ship and Flamethrower ship] Light aa added so they can protect themselves from light air while raiding + +# January 2024 +- [Turtle] Added a weak anti-air turret so that an amphibious turtle attack is more difficult to counter +- [T1 subs] Eel (arm sub) speed increased 63->66, Orca (cor sub) speed reduced 60->57 +- [T1 frigates] Ellysaw (arm frigate) cost reduced 390m2600E->380m2550E, Riptide (cor frigate) cost increased 410m2700E->420m2800E +- [Mauser] Cost increased from 270m4100E -> 320m4900E +- [Quaker] Cost increased from 360m4000E -> 400m4400E +- [Supporter] Line of sight increased 500 -> 600 +- [Skater and Herring] 1000 range radar added to both units. The goal is to give players better intel surrounding their fleets when playing t1 sea, where the short range radar can help to create a middle ground between fighting blind and having full vision. + +# November 2023 +- [Release Candidate Modoption] Dragon rework is added to the list of units in the release candidates modoption. This updates the dragon model and changes its weapons. An anti-air turret is added so that the unit can deal with small amounts of T1 fighters, the front weapon is changed from a laser to a flamethrower, and the side turrets have been split from 2 medium to 4 smaller laser turrets. +- [Consul] Recluse removed from build list, Webber added to build list. +- [Air units] Armorclasses merged for all air units, meaning that fighters and bombers will take the same amount of damage as gunships. + - T1 fighters will kill other T1 fighters in 2 shots instead of 1 + - T1 fighters will kill T2 fighters in 3 shots instead of 2 + - Flak will kill T2 fighters in 2 shots instead of 1 + - To make up for the extra durability, the cost of T2 fighters is increased + - Nighthawk (Cor T2 fighter) cost increased from 105m3700E -> 135m4750E, and weapon damage set to 500/shot + - Highwind (Arm T2 fighter) cost increased from 120m4900E -> 140m5700E, and weapon damage set to 750/shot + - The commander no longer deals extra damage against bombers + - Chainsaw and Eradicator damage per shot against fighters and gunships increased to match with previous damage against bombers +- [Quaker] Reworked to be larger with a heavier weapon, so that the T2 veh artillery has a similar asymmetry to the T1 veh artillery. Cost changed from 280m 3300E -> 360m 4000E, health increased 830 -> 1000, max speed reduced 54 -> 48, Damage per shot increased 300 -> 420, aoe increased 129 -> 144, reloadtime increased 4.25s -> 5s +- [Omen] Cor T2 radar vehicle's movement speed increased 36 -> 48 to be closer to arm T2 radar vehicle (57 speed) +- [Crawling Bombs] Movement speed while underwater reduced to 2/3 speed on land +- [Consul] Hound removed from build list, Sprinter and Platypus added to build list +- [Twitcher] Termite added to build list +- [Termite] Cost reduced from 700m 12000E -> 600m 9000E +- [Turtle] Cost reduced from 750m 18000E -> 750m 15000E +- [T2 Amphib Tanks] Paralyze damage taken reduced to 25% +- [T2 Cruisers] Fires 1 depthcharge at a time instead of 2 round bursts, projectile sped up and tracking improved + +# October 2023 +- [Webber] Reclaim speed increased from 100->150. + +# August 2023 +- [Shellshocker] Reworked to be the lighter version of t1 veh artillery while cor remains the heavy version. Reloadtime decreased 6.1 -> 4.3, damage per shot decreased 260 -> 182, inaccuracy reduced to 50% current area, area of effect reduced to 50% current area (-30% radius). Model size and collision volume decreased by 10% +- [Wolverine] Cost increased from 155m 2300E -> 170m 2500E. Model size and collision volume increased by 15%. Max health 640->750, max velocity 51->48. +- [Rocketeer] Damage per shot increased 10% (157->173), reloadtime increased 5% (3.8->4) +- [Aggravator] Damage per shot increased 10% (157->173), reloadtime increased 5% (3.8->4) +- [Laser/Lightning Weapons] targetmovererror set to zero for T2+ units. + - Hitscan weapons no longer randomly miss small fast moving targets. + - Affected Armada Units: Platypus, Welder, Titan blue laser, Thor side EMP lasers, Starlight. + - Affected Cortex Units: Duck, Sumo, Mammoth, Termite, Behemoth red lasers, Juggernaut heat ray. + - This matches existing behavior of Jaguar, Razorback, Thor main cannon, and Cataphract. + +# July 2023 +- [Whistler] Range decreased from 600 -> 575 +- [Lasher] Damage per shot decreased 47 -> 43 +- [Shellshocker] Reloadtime increased 5.7s -> 6.1s +- [Wolverine] Reloadtime increased 6.6s -> 7.2s +- [Dolphin] Cost increased from 165m 1400E -> 175m 1500E +- [Herring] Cost decreased from 230m 1600E -> 210m 1400E +- [Corsair] Depthcharge reloadtime decreased 2.24->2s +- [Oppressor] Depthcharge reloadtime decreased 2->1.9s +- [Tremor] The weapon's spread is now proportional to the distance between the tremor and its target. Fire rate reduced from 3->2 shots per second, damage per shot increased 100->150. +- [Veh Cons] Brake rates significantly increased, to prevent drifting into blueprints. + +# June 2023 +- [Shiva] Weapon reloadtime reduced by 33% (3s->2s), damage per shot reduced 33% (900->600), turret turnrate increased. Script and hitbox adjusted to reduce friendly fire. +- [Karganeth] Cost increased by 50% (1650->2500m), damage per shot increased by 50% (120->180 per shot, dps 400->600), health increased by 25% (10000->12500), model size increased slightly. Missiles automatically retarget midflight after current target is destroyed. Goal is to differentiate Karganeth and Shiva by increasing Karganeth's size and making it better at fighting single targets units while Shiva is better at dealing with large amounts of small units. +- [Tremor] Weapon reworked, now has a higher aoe, firerate, and impulse but lower damage per shot. The weapon is now stronger against groups of small units while significantly changing its strength against large units. +- [Incisor] Hitbox adjusted so incisors will fire more effectively in close formations, but will still obstruct each other's fire in large groups. + +### Bugfixes +- [Mines and Fiends] Fiends can now attack and damage mines. +- [Longbow and Messenger] Fixed rare case of door animation getting stuck and being unable to attack. +- [Eel] Shift aimpoint of Eel so it no longer barely outranges torpedo launchers. +- [Pitbull] Pitbull no longer blocked by T1 walls, it will correctly deploy and fire over T1 walls. +- [Resurrection] Fixed bug where units could sometimes be resurrected at full health instead of at 5% health. + +# May 2023 +[Commander] default enabled modoption "comupdate": +- Now immune to the D-Gun. +- Health 3350->4000. +Removed passive health regeneration. +- Wreckage metal 2000->1250. +- Death explosion inflict less damage. +- No longer susceptible to special damage modifiers from certain units, such as Light Laser Towers. + +### Bugfixes +- [Viper] No longer closed and armored when under construction. +- [Pitbull] Pitbull now closes into armored position after construction. +- [Wrecks and Heaps] Pincer got a heap. Duck got a wreck. Karganeth got a heap. Garpike got a heap. Wolverine got a heap. +- [Recluse] Recluse got new cruise missile behavior to drastically reduce incidences of friendly fire on cliff corners. +- [Gunslinger] Minor unit script edit to reduce friendly fire incidents. +- [Mobile Jammers] Now correctly turn back on after being damaged or stunned, and correctly turn off when stunned. +- [Air Factories] Fixed bug where it thought it had an aircraft on the buildpad and would stop producing units. +- [Nukes] Animation is now interruptible if a nuke was not fired, so an accidentally dropped target does not lock the silo into a long animation. Animation time standardized to 8.5 second door open time, and 30 second door close time. +- [RFLRPC] Ragnarok now correctly has a 0.4 reload time at 0 XP. Both Ragnarok and Calamity have animations tied to XP gain, so they properly animate faster and gain firerate increases with XP gains. + +### Other +- [Supporter] Reloadtime reduced from 1s->0.93s +- [Roughneck] Projectile speed increased 450->800 +- [Viper] Animation tweaks. Retains heading when closing up. 6 frames (0.2 sec) added to deploy animation (total of 37 frames (1.23 seconds), to match pitbull time-to-first-shot. Time to close into armored state after going idle now set to 3 seconds, to match pitbull. + +# April 2023 +- [Grunt] Range reduced from 240->230 and health reduced from 290->270 +- [Pawn] Damage per shot reduced from 10->9 +- [Tick] Metal cost increased from 15->17 +- [T1 Vehicle Scouts] Damage per shot increased from 30->35 +- Targeting improvements to tiger tank and turtle tank + +# March 2023 +- [T3 Hovertanks] Depthcharges added and main turrets can no longer target underwater units +- [Thor] Speed nerf 60->54, spark forkdamage nerf 0.5->0.25 (now deals 750 damage as aoe instead of 1500) +- [Commanders] can no longer capture allied units +- [Seaplane Platforms] added sonar distance for armada: 600, cortex: 800 +- [factories] tiny radar ranges removed + added large ranges for t1 air: 500, t2 air: 1000, armada seaplane: 750 + +# February 2023 +- [missiles] antinukes/nukes/tacnukes/empmissiles no longer collide into enemy (air) units +- [Titan/Juggernaut] added foot stomp "weapon" +- [Advanced Exploiter] General fixing of cormexp behavior. Consistent rocket salvo of 5. No longer less armored when closed. Proper decoy for moho. Continues extracting metal when deployed and attacking. + +# January 2023 +- [Twin Guard and Beamer] Added 5 range to make sure that they can't be outranged by T1 rocket bots when firing at a different elevation. +- [Thor] EMP rocket now launches immediately. To compensate, overall missile flight time increased by 2.5 seconds. Model animation now reveals to opponent how many EMP missiles are loaded. +- [vehicle scouts] accuracies and turnrate increased, damage reduced slightly. Cortex more maneuverable but slightly slower than armada, with reduced health +- [Lightning Weapons] Increased consistency of fork damage. Lightning chaining now always occurs at the lighting bolt end point, instead of only when a unit is damaged. Removed double application of flanking damage multiplier. +- [Flanking Damage] Flanking damage changed from min=0.9, max=1.9 to min=1.0, max=2.0. Approximately 5% nerf to overall bonus damage from flanking. All damage deals full 100% frontal damage instead of 90% frontal damage. All unit health increased by 11% to compensate. + - Consequences include: + - Wrecks are 11% more durable. + - Autoheal is 11% nerfed. + - Impulse of weapons increased by 11%. + +# December 2022 +- [Dragon Maw/Claw] Units now remember the location of revealed Dragon Maws/Claws even if fog-of-war covers them up. +- [Marauder] Change from tank movement to bot movement. Script fixes and torso turn rate buff to allow attacking while moving. Add 0.25 sec deploy animation to marauder AA cannons. +- [catalyst] Range -18% Nerf (2750 -> 2250), Area of Effect -26% Nerf (512 -> 380), Damage +60% Buff (2500 -> 4000) + +### Bugfixes +- [artillery] Script changes to prevent units from aiming at "illegal" out-of-firing-arc headings. + +# October 2022 +- [Warden] AimFrom point adjusted to center of unit, so the HLT cannot shoot outside of its range circle. +- [Thor] Range reduced 640->540, damage per tick reduced 320->300. Lightning chaining buffed, which better matches original intended behavior (forkdamage 0.33->0.5, maxunits 2->5, range 60->125). Overall script changes to make the commandfire EMP rocket, main tesla cannon, and side EMP turrets no longer interfere with each other. EMP rocket now takes starburst trajectory with a 3 second command delay. + +# August 2022 +- [Beamer] Script fix for continuous laser buffed effective DPS by 25%. + +# August 2022 +- [Torpedo gunships] weapon changed to match up with other torpedoes (damage and speed), accuracy and targeting improved. + +# May 2022 +- [Sniper] speed: 30->33 + +# March 2022 +- [Sabre] reloadtime 12% slower, range -11% +- [Stiletto] slower turnrate, 15 -> 10 sec paralyze duration, lower flight altitude + +# January 2022 +- [EMP-spider] health 850 -> 1000, increased turret turn speed +- [T1 walls] Removed energy cost + land walls: reduced metal cost 11 -> 8 +- [T2 walls] Halved energy cost (~1000 -> 500) + rounded up metal cost 38/39 -> 40 +- [Thor] EMP missile weapon is by manual fire only + +# December 2021 +- [Fido] cost: 270m->300m, 5600->6300E +- [Sniper] speed: 35.4->30 +- [Zeus] unnerfed: dps 200->220 +- [Fatboy] cost: 1500->1400m, 12000->11000E +- [Maverick] cost: 700->650m, 12000->11000E + range increases with experience 3x faster +- [Recluse] health increased (1050->1250) and weapon range increased (550->600) Burst 4->3 (no dps loss) +- [EMP-spider] now able to reclaim (100 buildpower), cost: 175->250m, 3400->5000E + +# 2019 - 2021 + +### General +- Enabled ground deformation +- Wreckage/heaps have the same HP as regular unit HP (~33% increase) +- Increased maxslope for all units 50% (the slope it allows to be build on) +- Units gain xp (ranks) faster, also relatively more health/firerate compared the the same old xp +- Removed the energy/metal make/use/storage from most non eco units + +### Units +- Commander wreckage is now 2000 metal instead of 2500 +- Commander has 500 metal/energy storage (lose commander and default storage is also 500) +- Commander produces 2 metal (was 1.5) +- T1 metal extractors use 3 Energy to operate, also slightly reduced buildprice +- T2 metal extractors have 40% less metal storage (600) +- Ground scouts (vehicles/bot): 10% more LoS +- T1 vehicle artillery has faster projectile and does 2x more damage per shot, but also has 2x reloadtime. +- T2 vehicle artillery has 33% more health + same damage as t1 but with adjusted reload time to compensate +- Decreased cormaw damage 27% +- Arm Peewee damage reduced by 9% +- Core AK range reduced 240->225 and damage increased 6% +- Arm Warrior health and buildtime increased by 10% +- Arm Flash health increased by 5% and firing script improved +- Cor Instigator health increased by 2% +- Arm Stumpy turn rate reduced by 10% +- Cor Raider turn rate reduced by 10% +- Claw/Zeus: lightning chains again (up to 2 neighbouring units \*0.33) +- Zeus: does 10% less damage +- Juggernaut: main weapon does 4x old damage/reloadtime +- EMP launcher: reduced range 10% +- Spy paralyzetime 45 -> 35 sec +- Spy is much more resistant to emp damage +- T2 Bot/Air Constructors can build Advanced Fusion + cost: +60 metal +- T2 Vehicle/Air Constructors can make the T3 Experimental Gantry +- Arm Guardian: Removed special damage to ships and commander, reduced low trajectory aoe from 128 to 100, increased low trajectory default damage by 20%, increased high trajectory default damage by 9% +- Cor Punisher: Removed special damage to ships and commander, reduced low trajectory aoe from 140 to 120, increased low trajectory default damage by 20%, increased high trajectory default damage by 18%, increased reloadtime by 8% +- Arm Ambusher: Removed special damage to ships and commander +- Core Toaster: Removed special damage to ships and commander, low and high trajectory damage increased by 20%, high trajectory cooldown increased by 14% +- Arm Big Bertha: Increased E cost to fire from 3000 to 5000 +- Cor Intimidator: Increased E cost to fire from 3000 to 6000 +- Arm Vulcan: Reduced E cost to fire from 14500 to 10000 +- Cor Buzzsaw: Reduced E cost to fire from 15725 to 12000 + +### Air +- Banshee: changed weapon to machine gun with more accuracy, 5 burst (instead of 3) but little less average dps (-10%) +- Air Fighters can no longer attack ground +- T1 construction plane E cost -30% +- LLT, HLLT, BEAMER, RL, RAD now transportable with T2/Heavy Transporters +- Torpedo bombers: Added 800 range radar+sonar, +10% los, -13% max velocity, reduced flight altitude 25%, split main weapon into 3-round burst (500 damage each instead of 1500), Increased cost by 21% +- Torpedo gunships: Targeting/tracking improvements, +17% los, added 535 range sonar +- EMP bomber: 20% slower (still slightly faster than t1 fighters) + +### Sea +- Ship/Hover/Amphibious transports removed +- Arm and Cor corvette health +25, Cor corvette M cost reduced by 5 (150->145) +- Arm and Cor frigate damage, speed, and health increased +- Arm and Cor destroyer E cost increased 50% (7.5E/M instead of 5E/M), turret turnrates adjusted, and arm destroyer's reloadtime increased from 1.2 to 1.6 with its damage increased to keep the same dps +- Cor Battleship and Black Hydra laser dps increased by 15%, and minor improvements to their speed/health/cost +- T1 sub damage -30% +- T2 sub killer reworked: speed increased, range decreased, reloadtime decreased, damage per shot decreased +- T2 battle sub introduced: has a high cost, long range, high damage, long cooldown, and is slow +- Arm and Cor depthcharge turrets: +31% health, +13% range, increased build time +- Arm and Cor hovertank speeds increased (+5% arm, +10% core), cor hovertank health increased 6% + +### Renamed units +- Jeffy -> Ranger +- Pack0 -> Ferret +- Krogoth -> Korgoth +- Gaat Gun -> Warden + +### 10.24 (24/02/2019) +Balanced Annihilation 10.24, the game this has been based on. diff --git a/changelog.txt b/changelog.txt deleted file mode 100644 index 0c50146edb8..00000000000 --- a/changelog.txt +++ /dev/null @@ -1,739 +0,0 @@ -# August -• [Spectre] 12500 -> 9000 energycost, 165 -> 150 metalcost, 380 -> 450 health -• [T1, Seaplane Air Constructors] -35% energycost, -35% buildtime, -35% speed -• [LRPCs] 1100 -> 900 weaponvelocity -• [Nukes] 1.3x base damage, 0.45 -> 0 edgeeffectiveness -> More damage dealt at the center of explosion, less damage on the edges of the explosion -• [Sumo] 6500 -> 6000 health -• [Mammoth] 22.5 -> 23 speed -• [Sheldon] 50.4 -> 50 speed -• [Floating AA turrets] Stats made to match their land counterparts -• [Legion changes] - - Basic mex now similar stats as other T1 mexes, T1.5 mex removed - - Wind generator 45m -> 43m cost - - Solar 155m -> 150m cost - - Goblin 25m, 500e -> 30m, 420e cost - - Satyr 400e -> 500e cost, 1100 -> 1220 buildtime, can no longer fire vertically - - Phalanx 50 -> 44 speed - - Alaris weapon switched from gauss to a shotgun with the same range and a 10% higher dps. Unit should feel more responsive overall with a slightly higher acceleration, turnrate, turret turnrate, and weapon projectile speed. Unit speed reduced 102->99. - - Helios 69 -> 75 speed, 330 -> 320 range, 400 -> 370 turnrate - - Lance 3800e -> 3600e cost - - Prometheus friendly fire significantly reduced - - Inferno firing pattern changed to sector fire so its shots will have much less vertical spread, 1100 -> 1200 range - - Perdition stockpile time 50s -> 40s - - Rhapsis (T1 Medium AA Tower) 156 -> 180 DPS, 840 -> 950 range - - Pluto (T2 Microflak Tower) and Fulmen (Naval Microflak Tower) 800 -> 875 range - -# July -• [Stout, Brute] +10% buildtime, 1.1667s -> 1.2s reloadtime, 330 -> 350 sightdistance -• [Rover] 1000 -> 1100 buildtime -• [Pounder] 1500 -> 1400 health -• [Grunt] 500 -> 520 sightdistance, 42 -> 43 metalcost -• [Gunslinger] 1560 -> 1800 health, 49.5 -> 50 speed, 24 AoE added, 500 -> 600 weaponvelocity -• [Welder] 9500 -> 8000 buildtime, 2950 -> 3500 health, 47.4 -> 48 speed -• [Sumo] 15000 -> 12000 buildtime, 5940 -> 6500 health, 37.5 -> 38 speed, 0.16 -> 0.3 beamtime, 25 -> 55 dmg vs air -• [Turtle] 450 -> 600 weaponvelocity, Impulse added -• [Torpedo gunships] - - Puffin: 18000 -> 14000 buildtime, speed 271 -> 210, removed special damage vs commander - - Monsoon: 13000 -> 11000 buildtime, speed 266 -> 200, 5s -> 4s reloadtime -• [AA turrets] - - Flak turrets: 775 -> 850 range, 0.5333 -> 0.5 reloadtime - - Chainsaw / Eradicator: 1125 -> 1200 range - - Ferret: 840 -> 950 range, 176 -> 208 DPS, Stealth added, 360 -> 330 metalcost, 5800 -> 5000 buildtime, 1330 -> 1600 health - - SAM: 840 -> 950 range, 190 -> 225 DPS, 315 -> 350 metalcost, 6100 -> 5500 energycost, 5240 -> 4500 buildtime, 2800 -> 2500 health -• [T2 fighters] - - Highwind: +7% costs - - Nighthawk: +7% costs, 740 -> 690 range -• [Legion changes] - - Phobos health increased 800 -> 840, turnrate increased 720 -> 800 - - Karkinos cost reduced 330m2600E -> 310m2400E, heatray reloadtime reduced 2 -> 1.8s, shotgun range increased 240 -> 250 - - Dragon's Jaw no longer gains range with elevation - - Arquebus reloadtime increased 6 -> 7s, damage increased 750 -> 850 - - Thanatos reloadtime reduced 9 -> 8s - - Quickshot turret turn speed and weapon velocity increased - - Blindfold health reduced 890 -> 600, juno bomb aoe reduced 700 -> 500 - - Phoenix bugfix, it should no longer fire up into space - -# June -• [General] Hold ALT when upgrading metal extractors to ignore allied extractors. -• [All Builders] Idle mobile builders now auto-repair units within a range that depends on the movement state. -• [Commando] 1560 -> 1800 health, EMP resistance, weapon inaccuracy removed, weapon can target air, but does reduced dmg vs air (30%) -• [Legion changes] - - Phalanx cost increased 450m4750E -> 470m5000E - - Praetorian speed increased 63 -> 72, health reduced 25000 -> 22000, flak dps reduced 514 -> 385, missile battery changed from a burst of 3 -> 12 with smaller missiles (dps 100 -> 300), cluster cannon removed - - Aquilon (T2 aa bot) airlos increased 850 -> 1100, railgun range increased 1050->1150, reloadtime reduced 4s -> 3s (dps 56 -> 75), microflak range increased 650 -> 700, dps reduced 506 -> 300 - - Glaucus (scout hover) speed increased 96 -> 102 - - Thalassa (T2 cruiser) range increased 450 -> 500 - - Belcher sprayangle reduced 2500 -> 1500 - - Advanced solar buildtime 13580 -> 12500 - -# May -• [T1 bot constructors] +70 health -• [Pawn] Weapon 3 -> 2 dmg vs Air -• [Blitz] Weapon 3 -> 2 dmg vs Air, 750 -> 730 health -• [Rover] 950 -> 1000 buildtime -• [Stout] 75.9 -> 75 speed, 2900 -> 3100 buildtime, 1780 -> 1800 health -• [Brute] 72.9 -> 72 speed, 3310 -> 3500 buildtime, 1970 -> 2000 health -• [Pounder] 40.5 -> 40 speed, 3000 -> 3100 buildtime, 1490 -> 1500 health. Weapon projectile no longer overshoots its range, and doesn't gain extra range from elevation -• [Hound] 1280 -> 1200 health, 3.167 -> 3.3s reloadtime -• [Tiger] 70.5 -> 69 speed, 665 -> 690 metalcost -• [Archangel] 1.5s -> 1.3s reloadtime for longrange missile -• [Manticore] 1.6s -> 1.4s reloadtime for longrange missile -• [Razorback] -5% dps -• [Shiva] 48.3 -> 48 speed, 1550 -> 1600 metalcost -• [Stormbringer] 105 -> 110 damage, +5% m/e/bt -• [Skyhook] 200 -> 185 speed, 6400 -> 9000 energycost -• [Abductor] 225 -> 210 speed, 6600 -> 10000 energycost -• [Stronghold] 175 -> 160 speed, 11000 -> 13000 energycost -• [Battleships] Reduced damage vs subs -• [Cloaked Fusion] 3650 -> 3550 metalcost, 75e/s -> 50e/s cloakcost - -# April -• [Brakerate] - - Set to 90 elmos/s^2 for vehicles, if lower previously. - - Mostly helps slow artillery units to not skid forward, after they've reached their firing range. - - Set to 180 elmos/s^2 for bots, if lower previously. Only affects radar and jammer bots. -• [Sightdistance] - - Set to 330 for vehicles, if lower previously. - - Set to 380 for bots, if lower previously. Except crawling bombs, are kept at 260. - - Affects mostly cons and some artillery, plus notably Centurions, Fiends and rocket bots. -• [Legion changes] - - New modoption added (legionsimplifiedmexes) which rebalances T1 legion to use the same T1 mexes as arm/cor. Light T1 units are given a higher m cost and lower E cost and heavy T1 units are given a lower m cost and higher E cost - - Impulse removed from Goblin, Wheelie, and Hippocampus - - Wheelie cost reduced 25m370E->23m350E, range increased 160->168, bullet velocity increased to improve accuracy - - Phobos health increased 750->800 - - Barrage's napalm aoe reduced 75->60 - - Cacophony cost reduced 420m5500E->380m5500E, health reduced 2350->2200, dps reduced 270->255 - - Adv solar health increased 800->1100 - - Octeres (T1 Artillery ship) reloadtime reduced 11->10s, range increased 930->960 - - Decurion energycost increased 3000->3600 - - Lance reloadtime reduced 8->7.5s - - Arquebus cost reduced 800m16000E->750m15000E - - Javelin speed increased 65->68 - - Praetorian speed increased 60->63, acceleration and turnrate improved - -# March -• [Legion changes] - - Telchine (T2 amphib bot) script and targeting improvements, range increased 400->450 - - Incinerator firing E cost 500->300E/s - - Dolus (T2 radar/jammer ship) speed increased 36->42 - - Phoenix (T2 heatray bomber) movement behavior adjusted to reduce heatray range/damage exploits - -# February -• [Centurion] 330 -> 325 range -• [Hound] buildtime 6230 -> 6500 -• [Fatboy] energycost 15000 -> 20000, buildtime 28000 -> 32000 -• [Sprinter] range 220 -> 230 -• [Abductor] sightdistance 430 -> 520 -• [Minelayers] Transportable by basic transport -• [Mines] mincloakdistance 8 -> 30 -• [T2 radars] 820 -> 1000 sight, 355 -> 500 health -• [T1 radars] 90 -> 180 health -• [Castro] -13% m/e/bt cost -• [Lightning weapons,except Thor] Firing takes -10e for all, used to vary between -5e to -35e -• [Rez subs] - - Removed reclaimspeed reduction -> +20% faster reclaim. Matches its usual buildpower now - - Autoheal 2hp/s -> 5hp/s (lost its idleautoheal of 3hp/s after 10s) - - +16% energycost, metalcost -• [T1 AA ships, subs, and frigates] Autoheal removed -• Idleautoheal standardised to 5hp/s after 60 seconds without getting hit, for everything. - - Rezbots got a 5hp/s normal autoheal to replace their near-instant idleautoheal. -• [Legion changes] - - Carriers all start with half of their drones pre-built, with the cost of those drones added to the carrier - - T1 Drone health reduced 415->325, acceleration increased, drones retreat after taking 1 aa shot - - T2 Drone health reduced 2250->1650 - - Removed health scaling for drones when gaining xp - - Drones no longer have health decay while in the air when the carrier is alive but decay quickly once the carrier is dead - - T1 drones now have to return to carrier to reload after firing 12 shots - - Reduced overall range of drones and made them more tied to their actual ranges to prevent range extension abuse - - -# January 2026 -• [Incisor] 0.767s -> 0.8s reloadtime, 85.5 -> 85 speed, 2200 -> 2300 buildtime, 1040 -> 1100 energycost -• [Blitz, Pawn] 500 -> 600 weaponvelocity -• [Vehicle scouts] +10% reloadtime -• [Banshee, Roughneck] 800 -> 1000 weaponvelocity, 16 -> 40 AoE -• [Hornet] Missile tracks properly -• [T1 bombers] Sprayangle removed, Stormbringer -5 speed -• [Sprinter] 171 -> 160 metalcost, 4140 -> 3800 energycost, 500 -> 600 weaponvelocity -• [Sheldon] 2200 -> 2800 energycost, 410 -> 400 metalcost -• [Fatboy] 6.7333s -> 7s reloadtime, 0.85 -> 0.15 edge effectiveness, 240 -> 300 AoE, 11000 -> 15000 energycost -• [Tzar] 3 -> 3.5 reloadtime, 40.5 -> 39 speed -• [Bull] 60 -> 62 speed -• [Sumo, Battleships] +10% health -• [Spybots] 17600 / 22200 -> 12000 buildtime, change overrides the buildtime formula for them -• [Hound, Gunslinger, Crawling Bombs] buildtime not changed by the formula below -• [Hover platforms] -80m, -750e, -800bt cheaper -• [T1 airplants] -60m, -300bt cheaper -• [T2 constructors] +15% buildpower -• [Seaplane constructors] T2 airplant added to buildlist -• [Construction turrets] - - Metal cost: 210 -> 230 - - Energy cost: 2600 -> 3200 (floating version) -• [Factory buildpower] - - All T2 factories: -300 metalcost, 1.5x buildtime. Except Cortex Vehicleplant only -200 metalcost. - - T2 factories (bots, vehicles, navy) buildpower: 300 -> 600 - - T2 airplants buildpower: 200 -> 600 - - Seaplanes buildpower: 200 -> 300 - - T3 gantry buildpower: 600 -> 1800 -• [Units from t2, t3 and seaplane factories] - - New buildtime = old buildtime * 1.1 + (metalcost * 60 + energycost) / 20 - - Roughly 30% for most units. Less for units with already high bp costs like air, more than that for fast-building units like most ships -• [Advanced geothermals] +50% buildtime -• [Cortex fusion] - - Metal cost: 4500 -> 3600 - - Energy cost: 26000 -> 22000 - - Buildtime: 75400 -> 59000 - - Energy generation: 1100 -> 850 - - Health: 5000 -> 4300 -• [Armada fusion] - - Metal cost: 4300 -> 3350 - - Energy cost: 21000 -> 18000 - - Buildtime: 70000 -> 54000 - - Energy generation: 1000 -> 750 - - Health: 4450 -> 3800 -• [Cloaked fusion] - - Metal cost: 4700 -> 3650 - - Energy cost: 26000 -> 22000 - - Buildtime: 84400 -> 65000 - - Energy generation: 1050 -> 750 - - Cloak cost: 100 -> 75 - - Health: 4450 -> 3800 -• [Decoy Fusion] - - Metalcost: 370 -> 270 - - Health: 5200 -> 3800 -• [Advanced solars] - - Energy generation: 75 -> 80 -• [Shield Rework] - - Shields block projectiles, preventing them from bouncing unpredictably and sometimes into the backline. - - Things inside the shield are protected from blocked projectiles AoE. - - When a shield is near 0 capacity, the last hit over-damages the shield, requiring it to recharge that amount of excess capacity usage before coming back online. - - In addition, there's a minimum down time. - - Projectile types blocked by shields unchanged. -• [Resurrection] Resurrected units regain their old XP. - -• [Legion changes] Updates relevant Legion unit stats to reflect Season 3 changes. Changelog is as follows: - - Cluster weapon damages and reloadtimes increased by ~30%, cluster secondary munition damage increased ~50% with lowered projectile counts - - Napalm weapon leadlimits set to 0, meaning they will always fire at the current location of its target instead of its predicted location - - Commander aa weapon reduced to 300 range - - Medusa tracking reduced to make retargeting weaker - - Martyr speed nerfed to 220 from 230, turnrate nerfed to 750 from 800 - - Mosquito weapon AOE increased to 72 from 70, stockpile time reduced to 1.8 seconds from 2 seconds - - Spy bot became slightly cheaper and slower, buildtime reduced accordingly first to match other spybots - - Strider energy cost reduced to 5250 from 5400 - - Scylla 15% health buff in accordance with other battleships - - Prometheus speed increased to 52 from 51 - - Inferno reloadtime reduced to 7 seconds from 8 seconds - - Alaris energy cost increased to 850 from 800, buildtime increased to 1650 from 1600, reloadtime slightly increased to 2.3 seconds from 2.25 seconds - - Wheelie reloadtime increased by 10% in accordance with other scout vehicles - - Decreased Legion Advanced Solar Collector costs by 3% across the board - - Factory changes are identical to the other factions. Buildtime updates for units use the same formula as for other T2, T3, and seaplane units in the other two factions. - - Legion fusion: - Metal cost: 4900 -> 4000 - Energy cost: 27000 -> 25000 - Buildtime: 80000 -> 66000 - Energy generation: 1200 -> 950 - Health: 5400 -> 4600 - -# December 2025 -• Unified maximum water depth for non-amphib land units to 22 (previously varied between 22-30) -• Unified minimum water depth for non-heavy ships to 8 (previously varied between 8-10) -• [Legion changes] - - T2 shipyard, ships, seaplanes, and naval structures added - - Praetorian shotgun spread reduced 1900->1400 - - Decurion range reduced 380->360, now deals 25% instead of 100% damage vs air - - Hippocampus (scout ship) now deals 25% instead of 50% damage vs air - - Small napalm blobs now deal 60dps and last 7s, previous was 45dps for 10s - - Inferno reloadtime 9s->8s - - Perdition stockpile time 70s->50s, stockpile cost reduced 500m17000E->350m14000E, impact damage 2000->1200 damage, napalm deals 120dps for 15s (3000 combined damage) - - Napalm damage cap increased 100->120dps - - Martyr damage vs commanders -25% -> -50% - - Syracusia (Destroyer) health reduced 4000->3800 - - Thalassa (Cruiser) health increased 5400->5600 - - Scylla (Battleship) health increased 8000->9000 - - Corinth (T2 artillery ship) cost increased 12000m115kE->13000m125kE, speed reduced 10% - - Ionia (T2 floating turret) mg range increased 650->700 - -# November 2025 -• [Legion changes] - - Karkinos cost increased, health increased, shotgun slightly higher dps with 2-round burst - - Telchine cost reduction 660m19000E->600m13200E, firing angle increased, speed in water increased 30% - - Triton speed reduced 60->55, range reduced 600->550, speed in water increased 30% - - T1 shipyard, ships, and naval structures added - - Iapetus (aa ship) cost 330m4800E->250m3600E, model scaled down 10%, fire rate reduced 15%, health reduced 20% - - Argonaut (frigate) tracking reduced - - Hippocampus (scout ship) speed increased 93->97, acceleration increased 4% - - Ketea (sub) cost reduced 340m2600E->320m2400E, speed increased 54->57, health reduced 640->600 - -# August 2025 -• [Aircraft] Vision raised to 430, if it was lower previously -• [T1 Bombers] Random inaccuracy removed from their bombs -• [T2 Transports] Skyhook 235 -> 200 speed, Abductor 241 -> 225 speed -• [Hound] 292 -> 340 weaponvelocity -• [Razorback] 58 -> 22 damage vs air -• [T2 AA bots] Sightdistance 925 -> 1200 -• [Flagships] Reduced damage vs submarines with main cannon -• [Grunt] 270 -> 280 health - -# July, 30 -• Gunslinger movement class changed to 3x3 and hitbox adjusted -• Sprinter movement class changed to 3x3 and colvol adjusted to cover the funit fully at all angles -• Welder movement class changed to 3x3 and footprint adjusted -• Bulls movement class changed to 4x4 and slight increase in crush damage -• Arm minelayer movement class changed to 3x3 - -# July 2025 -• [T1 Mex] +41% hp -• [Conbots] +15% hp -• [T1 turrets (not aa, not popups)] -10% buildtime -• [Commander] 25 -> 30 energymake -• [T1 factories] -150 metalcost, -250 energycost, -1500 buildtime, 100 -> 150 buildpower -• [Missile trucks] taller hitbox -• [Grunt] -2,5% costs -• [Blitz] 99 -> 101 speed -• [Starlight] 13500 -> 18500 energycost -• [Bull] 65.1 -> 60 speed -• [Jaguar] 320 -> 300 range, -10% dps (lightning) -• [Tzar] 22000 -> 28000 energycost -• [Sprinter] -10% costs -• [Termite] -10% costs, 48.3 -> 50 speed -• [Juggernaut] 33.6 -> 37 speed -• [T2 AA bots] - - New weapons. - - Longrange (1300) missile + flak for Cortex - - Longrange (1200) missile + shortrange missile for Armada - - ~Double health - - +30% energycost -• [Flagships] - - Reduced firerate on big gun, slightly increased damage per shot. Together, ~25% dps nerf - - Bigger AoE and Impulse on big gun, reduced edgeeffectiveness to 0.15 - - +17% costs - -# June 2025 -• [T2 flak turrets] increased footprint from 2x2 to 3x3 -• Warrior movement class from 2x2 to 3x3 (pawn to fido spacing) - -# June 2025 -• [Legion changes] - - 2 new T3 units added: Myrmidon T3 all-terrain mech, Charybdis T3 Hovertank - - New models added to replace the placeholder models left in T2: infestor, spybot, radar bot, jammer bot - - Phobos cost reduced 150m2400E -> 140m2200E - - Decurion buildtime increased 4000-4800 to slow down repair rate, energy cost increased 2700 -> 3000 - - Arquebus cost reduced 900m18000E -> 800m16000E - - Thanatos speed reduced 50 -> 45, turnrate reduced 750 -> 300 - - Triton minigun dps reduced 135 -> 105 - - Medusa range increased 950 -> 1000 - - Keres health reduced 23000 -> 21000 - - Praetorian shotgun fires 10% faster - - Daedalus range increased 900 -> 950 - - Starfall cost increased 58000m660000E -> 63000m720000E, reloadtime increased 15s->18s, lowered damage vs shields - -# May 2025 -• [Legion changes] - - 2 new units added: Aquilon T2 aa bot, Chimera T2 pop-up turret - - Cluster plasma was rebalanced and is overall stronger than before - - Wildfire removed from T2 air lab, Skuttle removed from t2 bot lab, Behemoth removed from T3 gantry - - Script improvements to allow several units to reliably fire while turning: Alaris, Decurion, Quickshot, Phobos - - Mosquito stockpile count reduced 8->4, stockpile rate increased slightly - - Gladiator projectile speed increased 320->360 to improve accuracy - - Lance cost reduced 260->240m, speed increased slightly -• [Tiger, Turtle, Sumo] Mass 750, to make them transportable by light transports. -• [Spybot] Emp immune again. Max stuntime vs units 8 -> 10 -• [Banisher] Energycost 17000 -> 23000, Turnrate 300 -> 250, Acceleration 0.2269 -> 0.2 -• [Tzar] Buildcost 26100 -> 30000 -• [Lava]: Units in lava slow down up to 5x depending on their submersion level - -# April 2025 -• [Pawn] 1420 -> 1650 buildtime -• [Pawn] 52m 870e -> 54m 900e -• [Grunt] 210 -> 215 range -• [Spybots] Can paralyze buildings up to 20s -• [Antinuke buildings] Health 3650 -> 3300 (facilitating a full-length stun from spybot) -• [Shuriken + Abductor] Regain targetmoveerror, revert Shuriken reloadtime 1.3s -> 1.2s - -# March 2025 -• [Armada solar] Buildtime 2800 -> 2600 -• [Armada wind] Metalcost 37 -> 40 -• [Cortex wind] Metalcost 45 -> 43, Health 199 -> 220 -• [Armada tidal] Energycost 250 -> 200 -• [Armada asolar] Metalcost 370 -> 350 -• [Cortex cons] +5% BP -• [Exploiter] Buildtime 2720 -> 2900 -• [T2 radars] Metalcost 560 -> 400, Energycost 19000 -> 14000, Buildtime 11800 -> 8000 -• [Sneaky Pete] Cloaking removed -• [Jammer ships] Costs +130%, Health +130%, Speed 61 -> 40 -• [Spybots] Cloaking cost 100/50 -> 40/15, EMP immunity removed, Attack command for self-destruct added, Damage 56000 -> 5000, Paralyzetime 35/20 -> 8s -• [Banisher] Can now fire in all directions, Sightdistance 550 -> 650, Energycost 23000 -> 17000, Improved aim/tracking -• [Tremor] Damage 150 -> 200, AoE 200 -> 210, Weapon hits a wider area -• [Poison Arrow] Energycost 29000 -> 21000, Buildtime 22200 -> 19000, Sightdistance 385 -> 500 -• [Turtle] Sightdistance 372 -> 500 -• [Garpike/Pincer] Sightdistance 305 -> 500, Faster turning turrets -• [Hound] Lost gauss weapon switch, Metalcost 300 -> 285 -• [Recluse] Range 600 -> 575, Speed 52 -> 47 -• [Grunt] Metalcost 36 --> 43, Energycost 880 -> 840, Range 230 -> 210, Turnrate 1391 -> 1200 -• [Tick] Metalcost 17 -> 21, Energycost 340 -> 300, Health 61 -> 60 -• [Pawn] Metalcost 48 -> 52, Energycost 960 -> 870, Health 335 -> 370 -• [Rover] Health 89 -> 105 -• [Rascal] Health 75 -> 90 -• [Blitz] Health 690 -> 750, Ellipsoid hitbox, Sightrange 299 -> 350 -• [Incisor] Buildtime 1761 -> 2200, Sightrange 273 -> 330 -• [Shuriken] Reloadtime 1.2s -> 1.3s -• [Stormbringer] Drops bombs 17% closer together -• [Banshee] Health 485 -> 560, Turnrate increased, New weapon graphics with functionally similar stats -• Inaccuracy removed from all T1 laser weapons - -# January 2025 -• [Artillery] The High/Low Trajectory toggle has been removed and been automated. Both modes now share the same damage and AOE. Aiming low is preferred, but if there's no low trajectory targets in range or the manually selected target cannot be shot with low trajectory, high trajectory will be used instead for a short time. - The following units are affected: - - [Gauntlet] (T1 Plasma Artillery Turret) - - [Agitator] (T1 Plasma Artillery Turret) - - [Rattlesnake] (T2 Plasma Popup Artillery) - - [Persecutor] (T2 Plasma Popup Artillery) - - [Vanguard] (T3 Mobile All-Terrain Artillery) -• [Angler] The T2 Cortex torpedo bomber now drops 1 large torpedo instead of 3 small ones. The large torpedo is slightly slower but has some aoe -• [Serpent] The Arm T2 battlesub now fires 2 medium torpedoes at once instead of 1 large one, overall dps unchanged but some aoe is added -• [Kraken] The Cor T2 battlesub's torpedo aoe increased - -# December 2024 -• [Impulse] Nukes + Plasma Cannons + Shiva + Catapult + Vanguard + Tzar + Fatboy + Banisher + Poison Arrow + Gunslinger + Ballistic Missile Launchers got impulse added to their weapon. High-HP units with low mass got a mass increase. -• [Tremor] Impulse 140% -> 80%, EdgeEffectiveness 90% -> 15% -• [Calamity + Raghnarok] +10% E/M cost, energycost and damage of individual shot increased, reloadtime increased (same dps as before), AoE lowered -• [Light Mine] Cloakcost 0.5 -> 1e/s, metalcost 5 -> 7, buildtime 50 -> 100 -• [Medium Mine] Cloakcost 1 -> 2e/s, metalcost 16 -> 25, buildtime 100 -> 300, EdgeEffectiveness 0.7 -> 0.5, Impulse 1 -> 0.8 -• [Heavy Mine] Cloakcost 1.5 -> 6e/s, metalcost 21 -> 50, buildtime 125 -> 700, EdgeEffectiveness 0.7 -> 0.5, Impulse 1 -> 0.8, damage 1390 -> 3000, AoE 300 -> 330 -• [Crawling Bombs] Use bigger (self-d) explosion on attack command. Dying to enemy fire still results in small blast (including while being transported). Transportable. -• [Crawling Bombs death explosions] AoE 432 -> 400, damage 3350 -> 2700, damage vs Crawlling Bombs 220 -> 400 (chain easier) -• [Roach] Speed 81 -> 76 -• [Skuttle] Metalcost 540 -> 755, energycost 26 000 -> 27 000, cloakcost 150 -> 15, cloakcost moving 400 -> 40 -• [Destroyers] Depthcharge turnrate increased -> hits crawling bombs reliably - -# October 2024 -• [All units] EMP resist for units is standardized, and units that had low emp resists now take full emp damage. Units that had between 50-95% emp resist now all have 80% emp resist. Units that had 95+% emp resist are now fully emp immune. -• [New units] Heavy T1 air transport for arm (Osprey) and for cor (Hephastus) moved to the basegame. These transports can carry the same weight as T2 transports but at a lower movespeed, and are able to transport the commander. Osprey costs 190m 4000E, has 110 speed, and 630 health. Hephaestus costs 190m 4000E, has 100 speed, and 800 health. -• [Stork/Hercules] T1 light air transports no longer able to transport the commander, and can now only carry units below 750 metal. Energy/metal/buildcost reverted to earlier values (68m 1300E 3850bp for Stork and 74m 1450E 4120bp for Hercules) -• [Shuriken] emp damage per shot reduced from 800-600, weapon can no longer fire at air units -• [Abductor] emp damage per shot reduced 22500->10500, beam duration 0.5->0.2s (improves accuracy), stuntime reduced from 15->6s -• [Liche] weapon no longer has an extra impulse multiplier -• [Skyhook] T2 cor air transport speed increased 210->235, health increased 1830->2200. Line of sight increased 260->500 -• [Webber] No longer able to target air units - -# September 2024 -• [All units] Gravity standardized for all projectiles to 130, so weapons will behave the same way across all maps. -• [Rocketeer + Aggravator] Reverted the last 2 balance changes: damage per shot 173->157, reloadtime 4->3.8s, speed/acceleration 5% higher -• [Lazarus + Graverobber] Metal cost increased 110->130, E cost unchanged, buildtime increased 2400->2800 -• [Mace] Health increased 900->1000 -• [Thug] Health increased 1000->1100, buildtime increased from 1970->2100 so it doesn't get repaired too quickly -• [Tremor] Reloadtime reverted to 0.5s, impulse reduced 30% -• [Vanguard] Range increased 1325->1450, health reduced 10000->8500. Tremor and Vanguard's higher ranges now let them outrange long range defences (Rattlesnake, Persecutor, Pulsar) - -# August 2024 -• [Tremor] firemodes unified to remove the toggle, and effective DPS in target area increased. Wasp bugfixed, and now has mild tracking on rockets, so will miss shots less often. -• [Dragon Claw/Maw] Collision volume and aim position will now change when the units open up, so it can be targeted when behind a wall - -# July 2024 -• [Minelayers] Minelayers now live up to the 'minesweeper' role properly, being able to detect enemy mines within 450 radius, and slowly clear them, with a moderate ranged weapon. -• [Naval Economy Buildings] Stats made equal to land versions. Specifically, T1 and T2 Energy Converters, Energy and Metal Storages, and the T2 mex have been harmonised for both Core and Arm. -• [Salamander] Energy cost increased 4775->7000 to make the unit more difficult to rush early on and put its cost more in line with other amphibs (energy being 20x metal cost). Range reduced from 360->340 to make it slightly more difficult to kite T1 units and commanders. -• [Whistler + Lasher] Ground range increased by 25 (525/550->550/575 range) -• [Centurion] Unit script improved so the unit should keep firing while turning -• [T2 Cruisers] Reloadtime of depthcharges reduced by 10% -• [T2 Lightning/Flamethrower Ships] Cost reduced 25%, health reduced 20%, speed increased 6%, dps reduced 20%. Flamethrower ship model size scaled down by 10% - -# June 2024 -• [Rocketeer + Aggravator] Speed and acceleration reduced by 5% -• [Whistler + Lasher] Changes from proposed units rework modoption moved to base game. {Whistler and Lasher weapon switches from ground and anti-air modes. Anti-air has 700 range and tracking missiles, while ground has 525/550 range and no tracking. Ground speeds increased by 10 (45->55, 42->52), ground dps increased by 40% (17->25)} -• [T1 Air Transports] E costs increased by 70%, buildtime increased by 20%. This change is to increase the time it takes to rush transports and share them to the team. -• [Herring] unit now switches between ground missiles and longer range tracking aa missiles (750 range). No change to ground missile stats. Unit now takes 50% damage from emp instead of 30%. -• [Dragon Claw/Maw] Damage taken while closed increased from 25%->33%. Dragon Claw range reduced 440->430 - -# March 2024 -• [Proposed Units Rework Modoption] Rework to Whistler and Lasher added to modoption, which has the units switch between longer range tracking aa missiles and non-tracking ground missiles. Mauser, Quaker, Stiletto changes removed from modoption. -• [Mauser + Quaker] Rework from proposed_unit_reworks modoption moved to main game with a few additional changes. This rework aims to give these units a more mobile and aggressive role, which reduces the role overlap with the heavier T2 veh artillery options. Their ranges are reduced by 120, speed increased by 20%, acceleration increased 50%, health increased 20%, and accuracy is improved. -• [Stiletto] Rework from proposed_unit_reworks modoption moved to main game. This rework aims to give the stiletto a more specialized role for disabling specific targets while being less efficient as a defensive option against groups. The unit's metal and energy costs are doubled, buildpower cost increased 50%, health increased by 30%, paralyze time increased from 10s->20s, bomb count reduced from 5->3, aoe reduced from 240->200, emp damage per bomb increased 4000->6000 -• [Banshee] Reloadtime reduced by 10% (DPS 34->38) -• [Dragon Claw] DPS reduced by 14% (185->159) -• [Razorback] Laser damage vs air reduced from 75% to 50% -• [Demon] Health reduced from 20000->18000 -• [Crocodile and Cayman (T1 hovertanks)] Crocodile cost reduced 290m2600E->270m2400E, Cayman cost reduced 320m3300E->300m3100E -• [Salamander] EMP resist reduced from 95%->90% -• [Epoch and Black Hydra] Projectiles for main cannons increased in size, damage, and aoe, with a reduced fire rate - -# February 2024 -• [Grunt] Speed reduced from 84->81 -• [Pawn] Speed increased from 84->87 -• [Demon] Buildtime cost increased from 90000->120000 -• [Thug/Mace] movement footprint increased to prevent them blocking each other's turrets when attacking -• [New Units] Demon (T3 cortex flamethrower mech), Salamander (T2 cortex amphibious tank, replacing Alligator) moved from release candidate modoption into the base game. Dragon rework (new model, flamethrower, light aa) moved to the base game. Flamethrower ship and Lightning ship moved from expandedT2sea modoption into the base game -• [Dragon] Fire rate of anti-air weapon reduced by half -• [Demon] DPS reduced from 2000->1600, unit no longer has amphibious -• [Lightning ship and Flamethrower ship] Light aa added so they can protect themselves from light air while raiding - -# January 2024 -• [Turtle] Added a weak anti-air turret so that an amphibious turtle attack is more difficult to counter -• [T1 subs] Eel (arm sub) speed increased 63->66, Orca (cor sub) speed reduced 60->57 -• [T1 frigates] Ellysaw (arm frigate) cost reduced 390m2600E->380m2550E, Riptide (cor frigate) cost increased 410m2700E->420m2800E -• [Mauser] Cost increased from 270m4100E -> 320m4900E -• [Quaker] Cost increased from 360m4000E -> 400m4400E -• [Supporter] Line of sight increased 500 -> 600 -• [Skater and Herring] 1000 range radar added to both units. The goal is to give players better intel surrounding their fleets when playing t1 sea, where the short range radar can help to create a middle ground between fighting blind and having full vision. - -# November 2023 -• [Release Candidate Modoption] Dragon rework is added to the list of units in the release candidates modoption. This updates the dragon model and changes its weapons. An anti-air turret is added so that the unit can deal with small amounts of T1 fighters, the front weapon is changed from a laser to a flamethrower, and the side turrets have been split from 2 medium to 4 smaller laser turrets. -• [Consul] Recluse removed from build list, Webber added to build list. -• [Air units] Armorclasses merged for all air units, meaning that fighters and bombers will take the same amount of damage as gunships. - - T1 fighters will kill other T1 fighters in 2 shots instead of 1 - - T1 fighters will kill T2 fighters in 3 shots instead of 2 - - Flak will kill T2 fighters in 2 shots instead of 1 - - To make up for the extra durability, the cost of T2 fighters is increased - - Nighthawk (Cor T2 fighter) cost increased from 105m3700E -> 135m4750E, and weapon damage set to 500/shot - - Highwind (Arm T2 fighter) cost increased from 120m4900E -> 140m5700E, and weapon damage set to 750/shot - - The commander no longer deals extra damage against bombers - - Chainsaw and Eradicator damage per shot against fighters and gunships increased to match with previous damage against bombers -• [Quaker] Reworked to be larger with a heavier weapon, so that the T2 veh artillery has a similar asymmetry to the T1 veh artillery. Cost changed from 280m 3300E -> 360m 4000E, health increased 830 -> 1000, max speed reduced 54 -> 48, Damage per shot increased 300 -> 420, aoe increased 129 -> 144, reloadtime increased 4.25s -> 5s -• [Omen] Cor T2 radar vehicle's movement speed increased 36 -> 48 to be closer to arm T2 radar vehicle (57 speed) -• [Crawling Bombs] Movement speed while underwater reduced to 2/3 speed on land -• [Consul] Hound removed from build list, Sprinter and Platypus added to build list -• [Twitcher] Termite added to build list -• [Termite] Cost reduced from 700m 12000E -> 600m 9000E -• [Turtle] Cost reduced from 750m 18000E -> 750m 15000E -• [T2 Amphib Tanks] Paralyze damage taken reduced to 25% -• [T2 Cruisers] Fires 1 depthcharge at a time instead of 2 round bursts, projectile sped up and tracking improved - -# October 2023 -• [Webber] Reclaim speed increased from 100->150. - -# August 2023 -• [Shellshocker] Reworked to be the lighter version of t1 veh artillery while cor remains the heavy version. Reloadtime decreased 6.1 -> 4.3, damage per shot decreased 260 -> 182, inaccuracy reduced to 50% current area, area of effect reduced to 50% current area (-30% radius). Model size and collision volume decreased by 10% -• [Wolverine] Cost increased from 155m 2300E -> 170m 2500E. Model size and collision volume increased by 15%. Max health 640->750, max velocity 51->48. -• [Rocketeer] Damage per shot increased 10% (157->173), reloadtime increased 5% (3.8->4) -• [Aggravator] Damage per shot increased 10% (157->173), reloadtime increased 5% (3.8->4) -• [Laser/Lightning Weapons] targetmovererror set to zero for T2+ units. - - Hitscan weapons no longer randomly miss small fast moving targets. - - Affected Armada Units: Platypus, Welder, Titan blue laser, Thor side EMP lasers, Starlight. - - Affected Cortex Units: Duck, Sumo, Mammoth, Termite, Behemoth red lasers, Juggernaut heat ray. - - This matches existing behavior of Jaguar, Razorback, Thor main cannon, and Cataphract. - -# July 2023 -• [Whistler] Range decreased from 600 -> 575 -• [Lasher] Damage per shot decreased 47 -> 43 -• [Shellshocker] Reloadtime increased 5.7s -> 6.1s -• [Wolverine] Reloadtime increased 6.6s -> 7.2s -• [Dolphin] Cost increased from 165m 1400E -> 175m 1500E -• [Herring] Cost decreased from 230m 1600E -> 210m 1400E -• [Corsair] Depthcharge reloadtime decreased 2.24->2s -• [Oppressor] Depthcharge reloadtime decreased 2->1.9s -• [Tremor] The weapon's spread is now proportional to the distance between the tremor and its target. Fire rate reduced from 3->2 shots per second, damage per shot increased 100->150. -• [Veh Cons] Brake rates significantly increased, to prevent drifting into blueprints. - -# June 2023 - • [Shiva] Weapon reloadtime reduced by 33% (3s->2s), damage per shot reduced 33% (900->600), turret turnrate increased. Script and hitbox adjusted to reduce friendly fire. - • [Karganeth] Cost increased by 50% (1650->2500m), damage per shot increased by 50% (120->180 per shot, dps 400->600), health increased by 25% (10000->12500), model size increased slightly. Missiles automatically retarget midflight after current target is destroyed. Goal is to differentiate Karganeth and Shiva by increasing Karganeth's size and making it better at fighting single targets units while Shiva is better at dealing with large amounts of small units. - • [Tremor] Weapon reworked, now has a higher aoe, firerate, and impulse but lower damage per shot. The weapon is now stronger against groups of small units while significantly changing its strength against large units. - • [Incisor] Hitbox adjusted so incisors will fire more effectively in close formations, but will still obstruct each other's fire in large groups. - - -Bugfixes - • [Mines and Fiends] Fiends can now attack and damage mines. - • [Longbow and Messenger] Fixed rare case of door animation getting stuck and being unable to attack. - • [Eel] Shift aimpoint of Eel so it no longer barely outranges torpedo launchers. - • [Pitbull] Pitbull no longer blocked by T1 walls, it will correctly deploy and fire over T1 walls. - • [Resurrection] Fixed bug where units could sometimes be resurrected at full health instead of at 5% health. - - -# May 2023 -[Commander] default enabled modoption "comupdate": - • Now immune to the D-Gun. - • Health 3350->4000. +Removed passive health regeneration. - • Wreckage metal 2000->1250. - • Death explosion inflict less damage. - • No longer susceptible to special damage modifiers from certain units, such as Light Laser Towers. - - -Bugfixes - • [Viper] No longer closed and armored when under construction. - • [Pitbull] Pitbull now closes into armored position after construction. - • [Wrecks and Heaps] Pincer got a heap. Duck got a wreck. Karganeth got a heap. Garpike got a heap. Wolverine got a heap. - • [Recluse] Recluse got new cruise missile behavior to drastically reduce incidences of friendly fire on cliff corners. - • [Gunslinger] Minor unit script edit to reduce friendly fire incidents. - • [Mobile Jammers] Now correctly turn back on after being damaged or stunned, and correctly turn off when stunned. - • [Air Factories] Fixed bug where it thought it had an aircraft on the buildpad and would stop producing units. - • [Nukes] Animation is now interruptible if a nuke was not fired, so an accidentally dropped target does not lock the silo into a long animation. Animation time standardized to 8.5 second door open time, and 30 second door close time. - • [RFLRPC] Ragnarok now correctly has a 0.4 reload time at 0 XP. Both Ragnarok and Calamity have animations tied to XP gain, so they properly animate faster and gain firerate increases with XP gains. - - -Other - • [Supporter] Reloadtime reduced from 1s->0.93s - • [Roughneck] Projectile speed increased 450->800 - • [Viper] Animation tweaks. Retains heading when closing up. 6 frames (0.2 sec) added to deploy animation (total of 37 frames (1.23 seconds), to match pitbull time-to-first-shot. Time to close into armored state after going idle now set to 3 seconds, to match pitbull. - - -# April 2023 - - • [Grunt] Range reduced from 240->230 and health reduced from 290->270 - • [Pawn] Damage per shot reduced from 10->9 - • [Tick] Metal cost increased from 15->17 - • [T1 Vehicle Scouts] Damage per shot increased from 30->35 - • Targeting improvements to tiger tank and turtle tank - - -# March 2023 - - • [T3 Hovertanks] Depthcharges added and main turrets can no longer target underwater units - • [Thor] Speed nerf 60->54, spark forkdamage nerf 0.5->0.25 (now deals 750 damage as aoe instead of 1500) - • [Commanders] can no longer capture allied units - • [Seaplane Platforms] added sonar distance for armada: 600, cortex: 800 - • [factories] tiny radar ranges removed + added large ranges for t1 air: 500, t2 air: 1000, armada seaplane: 750 - - -# February 2023 - - • [missiles] antinukes/nukes/tacnukes/empmissiles no longer collide into enemy (air) units - • [Titan/Juggernaut] added foot stomp "weapon" - • [Advanced Exploiter] General fixing of cormexp behavior. Consistent rocket salvo of 5. No longer less armored when closed. Proper decoy for moho. Continues extracting metal when deployed and attacking. - - -# January 2023 - - • [Twin Guard and Beamer] Added 5 range to make sure that they can't be outranged by T1 rocket bots when firing at a different elevation. - • [Thor] EMP rocket now launches immediately. To compensate, overall missile flight time increased by 2.5 seconds. Model animation now reveals to opponent how many EMP missiles are loaded. - • [vehicle scouts] accuracies and turnrate increased, damage reduced slightly. Cortex more maneuverable but slightly slower than armada, with reduced health - • [Lightning Weapons] Increased consistency of fork damage. Lightning chaining now always occurs at the lighting bolt end point, instead of only when a unit is damaged. Removed double application of flanking damage multiplier. - • [Flanking Damage] Flanking damage changed from min=0.9, max=1.9 to min=1.0, max=2.0. Approximately 5% nerf to overall bonus damage from flanking. All damage deals full 100% frontal damage instead of 90% frontal damage. All unit health increased by 11% to compensate. -Consequences include: - • Wrecks are 11% more durable. - • Autoheal is 11% nerfed. - • Impulse of weapons increased by 11%. - - -# December 2022 - - • [Dragon Maw/Claw] Units now remember the location of revealed Dragon Maws/Claws even if fog-of-war covers them up. - • [Marauder] Change from tank movement to bot movement. Script fixes and torso turn rate buff to allow attacking while moving. Add 0.25 sec deploy animation to marauder AA cannons. - • [catalyst] Range -18% Nerf (2750 -> 2250), Area of Effect -26% Nerf (512 -> 380), Damage +60% Buff (2500 -> 4000) - - -Bugfixes - • [artillery] Script changes to prevent units from aiming at "illegal" out-of-firing-arc headings. - - -# October 2022 - - • [Warden] AimFrom point adjusted to center of unit, so the HLT cannot shoot outside of its range circle. - • [Thor] Range reduced 640->540, damage per tick reduced 320->300. Lightning chaining buffed, which better matches original intended behavior (forkdamage 0.33->0.5, maxunits 2->5, range 60->125). Overall script changes to make the commandfire EMP rocket, main tesla cannon, and side EMP turrets no longer interfere with each other. EMP rocket now takes starburst trajectory with a 3 second command delay. - - -# August 2022 - - • [Beamer] Script fix for continuous laser buffed effective DPS by 25%. - - -# August 2022 - - • [Torpedo gunships] weapon changed to match up with other torpedoes (damage and speed), accuracy and targeting improved. - - -# May 2022 - - • [Sniper] speed: 30->33 - - -# March 2022 - - • [Sabre] reloadtime 12% slower, range -11% - • [Stiletto] slower turnrate, 15 -> 10 sec paralyze duration, lower flight altitude - - -# January 2022 - - • [EMP-spider] health 850 -> 1000, increased turret turn speed - • [T1 walls] Removed energy cost + land walls: reduced metal cost 11 -> 8 - • [T2 walls] Halved energy cost (~1000 -> 500) + rounded up metal cost 38/39 -> 40 - • [Thor] EMP missile weapon is by manual fire only - - -# December 2021 - - • [Fido] cost: 270m->300m, 5600->6300E - • [Sniper] speed: 35.4->30 - • [Zeus] unnerfed: dps 200->220 - • [Fatboy] cost: 1500->1400m, 12000->11000E - • [Maverick] cost: 700->650m, 12000->11000E + range increases with experience 3x faster - • [Recluse] health increased (1050->1250) and weapon range increased (550->600) Burst 4->3 (no dps loss) - • [EMP-spider] now able to reclaim (100 buildpower), cost: 175->250m, 3400->5000E - - -# 2019 - 2021 - -General - • Enabled ground deformation - • Wreckage/heaps have the same HP as regular unit HP (~33% increase) - • Increased maxslope for all units 50% (the slope it allows to be build on) - • Units gain xp (ranks) faster, also relatively more health/firerate compared the the same old xp - • Removed the energy/metal make/use/storage from most non eco units - -Units - • Commander wreckage is now 2000 metal instead of 2500 - • Commander has 500 metal/energy storage (lose commander and default storage is also 500) - • Commander produces 2 metal (was 1.5) - • T1 metal extractors use 3 Energy to operate, also slightly reduced buildprice - • T2 metal extractors have 40% less metal storage (600) - • Ground scouts (vehicles/bot): 10% more LoS - • T1 vehicle artillery has faster projectile and does 2x more damage per shot, but also has 2x reloadtime. - • T2 vehicle artillery has 33% more health + same damage as t1 but with adjusted reload time to compensate - • Decreased cormaw damage 27% - • Arm Peewee damage reduced by 9% - • Core AK range reduced 240->225 and damage increased 6% - • Arm Warrior health and buildtime increased by 10% - • Arm Flash health increased by 5% and firing script improved - • Cor Instigator health increased by 2% - • Arm Stumpy turn rate reduced by 10% - • Cor Raider turn rate reduced by 10% - • Claw/Zeus: lightning chains again (up to 2 neighbouring units *0.33) - • Zeus: does 10% less damage - • Juggernaut: main weapon does 4x old damage/reloadtime - • EMP launcher: reduced range 10% - • Spy paralyzetime 45 -> 35 sec - • Spy is much more resistant to emp damage - • T2 Bot/Air Constructors can build Advanced Fusion + cost: +60 metal - • T2 Vehicle/Air Constructors can make the T3 Experimental Gantry - • Arm Guardian: Removed special damage to ships and commander, reduced low trajectory aoe from 128 to 100, increased low trajectory default damage by 20%, increased high trajectory default damage by 9% - • Cor Punisher: Removed special damage to ships and commander, reduced low trajectory aoe from 140 to 120, increased low trajectory default damage by 20%, increased high trajectory default damage by 18%, increased reloadtime by 8% - • Arm Ambusher: Removed special damage to ships and commander - • Core Toaster: Removed special damage to ships and commander, low and high trajectory damage increased by 20%, high trajectory cooldown increased by 14% - • Arm Big Bertha: Increased E cost to fire from 3000 to 5000 - • Cor Intimidator: Increased E cost to fire from 3000 to 6000 - • Arm Vulcan: Reduced E cost to fire from 14500 to 10000 - • Cor Buzzsaw: Reduced E cost to fire from 15725 to 12000 - -Air - • Banshee: changed weapon to machine gun with more accuracy, 5 burst (instead of 3) but little less average dps (-10%) - • Air Fighters can no longer attack ground - • T1 construction plane E cost -30% - • LLT, HLLT, BEAMER, RL, RAD now transportable with T2/Heavy Transporters - • Torpedo bombers: Added 800 range radar+sonar, +10% los, -13% max velocity, reduced flight altitude 25%, split main weapon into 3-round burst (500 damage each instead of 1500), Increased cost by 21% - • Torpedo gunships: Targeting/tracking improvements, +17% los, added 535 range sonar - • EMP bomber: 20% slower (still slightly faster than t1 fighters) - -Sea - • Ship/Hover/Amphibious transports removed - • Arm and Cor corvette health +25, Cor corvette M cost reduced by 5 (150->145) - • Arm and Cor frigate damage, speed, and health increased - • Arm and Cor destroyer E cost increased 50% (7.5E/M instead of 5E/M), turret turnrates adjusted, and arm destroyer's reloadtime increased from 1.2 to 1.6 with its damage increased to keep the same dps - • Cor Battleship and Black Hydra laser dps increased by 15%, and minor improvements to their speed/health/cost - • T1 sub damage -30% - • T2 sub killer reworked: speed increased, range decreased, reloadtime decreased, damage per shot decreased - • T2 battle sub introduced: has a high cost, long range, high damage, long cooldown, and is slow - • Arm and Cor depthcharge turrets: +31% health, +13% range, increased build time - • Arm and Cor hovertank speeds increased (+5% arm, +10% core), cor hovertank health increased 6% - - -Renamed units: - • Jeffy -> Ranger - • Pack0 -> Ferret - • Krogoth -> Korgoth - • Gaat Gun -> Warden - - -10.24 -24/02/2019 - -Balanced Annihilation 10.24, the game this has been based on. diff --git a/common/autoramp_profile.lua b/common/autoramp_profile.lua new file mode 100644 index 00000000000..db4fd0adc65 --- /dev/null +++ b/common/autoramp_profile.lua @@ -0,0 +1,563 @@ +-- autoramp_profile.lua +-- Pure autoramp terrain computation, shared by the synced gadget (which applies +-- it to the heightmap at full resolution) and the unsynced widget (which runs +-- it on a coarser grid to draw the WYSIWYG hover preview). No Spring height +-- writes, no GL, no math.random — all randomness comes from the seeded +-- permutation table, so both consumers and every client agree bit-for-bit. +-- +-- Pipeline (one call, footprint = brush circle): +-- 1. read original heights into a window grid via opts.getHeight +-- 2. steepest ascent/descent march from the click → hTop / hBot, plus the +-- march path length → the original cliff's representative slope +-- 3. signed chamfer distance d from the (h == hMid) iso-contour +-- 4. anchor shift dOff from opts.startMode: +-- "average" — face pivots on the mid contour (bites half / spills half) +-- "extend" — top lip preserved: the face spills outward over the low +-- side, never biting into the mesa top +-- "subtract" — bottom lip preserved: the face carves into the mesa, +-- never burying the low side +-- 5. per cell: face = hMid + softCap((dPerturbed - dOff) * tan(angle)), +-- blended by contour-band and brush-ring weights, minus ridged gullies, +-- plus the talus wedge +-- +-- compute(opts) -> result table, or nil + reason ("no_cliff" | "no_span" | +-- "no_contour"). Result: +-- { n, ox, oz, cellSize, orig = {..}, newH = {..}, hTop, hBot, hMid } +-- Arrays are dense [iz*n+ix+1]; nil = off-map. newH == orig where unchanged. + +local M = {} + +local floor = math.floor +local abs = math.abs +local min = math.min +local max = math.max +local sqrt = math.sqrt +local tan = math.tan +local pi = math.pi + +-- ── Seeded noise (self-contained copy of the brush's perlin/fbm/ridged) ────── +local cachedPerm = nil +local cachedPermSeed = nil + +local function buildPermTable(seed) + seed = seed or 0 + if cachedPerm and cachedPermSeed == seed then + return cachedPerm + end + local perm = cachedPerm or {} + for i = 0, 255 do + perm[i] = i + end + local s = seed + for i = 255, 1, -1 do + s = (s * 1103515245 + 12345) % 2147483648 + local j = s % (i + 1) + perm[i], perm[j] = perm[j], perm[i] + end + for i = 0, 255 do + perm[i + 256] = perm[i] + end + cachedPerm = perm + cachedPermSeed = seed + return perm +end + +local function fade(t) + return t * t * t * (t * (t * 6 - 15) + 10) +end + +local function lerp(t, a, b) + return a + t * (b - a) +end + +local function grad2d(hash, x, y) + local h = hash % 4 + if h == 0 then + return x + y + elseif h == 1 then + return -x + y + elseif h == 2 then + return x - y + else + return -x - y + end +end + +local function perlinNoise2D(x, y, perm) + local xi = floor(x) % 256 + local yi = floor(y) % 256 + local xf = x - floor(x) + local yf = y - floor(y) + local u = fade(xf) + local v = fade(yf) + local aa = perm[perm[xi] + yi] + local ab = perm[perm[xi] + yi + 1] + local ba = perm[perm[xi + 1] + yi] + local bb = perm[perm[xi + 1] + yi + 1] + return lerp( + v, + lerp(u, grad2d(aa, xf, yf), grad2d(ba, xf - 1, yf)), + lerp(u, grad2d(ab, xf, yf - 1), grad2d(bb, xf - 1, yf - 1)) + ) +end + +local function fbmNoise(x, y, perm, octaves, persistence, lacunarity) + local total = 0 + local amplitude = 1 + local frequency = 1 + local maxVal = 0 + for _ = 1, octaves do + total = total + perlinNoise2D(x * frequency, y * frequency, perm) * amplitude + maxVal = maxVal + amplitude + amplitude = amplitude * persistence + frequency = frequency * lacunarity + end + return total / maxVal +end + +local function ridgedNoise(x, y, perm, octaves, persistence, lacunarity) + local total = 0 + local amplitude = 1 + local frequency = 1 + local maxVal = 0 + for _ = 1, octaves do + local val = perlinNoise2D(x * frequency, y * frequency, perm) + val = 1 - abs(val) + val = val * val + total = total + val * amplitude + maxVal = maxVal + amplitude + amplitude = amplitude * persistence + frequency = frequency * lacunarity + end + return total / maxVal +end + +-- softCap(v, cap, k): identity far below cap, flattens onto cap over the last +-- k units with C1 continuity (quadratic smooth-min). +local function softCap(v, cap, k) + local d = cap - v + if d >= k then + return v + end + if d <= -k then + return cap + end + local t = (d + k) / (2 * k) + return cap - k * t * t +end + +-- Minimum cliff height (elmos) worth restyling; fixed in world units so the +-- coarse preview grid and the full-resolution apply agree on rejection. +local MIN_SPAN = 12 + +function M.compute(opts) + local cellSize = opts.cellSize + local mapSizeX = opts.mapSizeX + local mapSizeZ = opts.mapSizeZ + local getHeight = opts.getHeight + local centerX = opts.centerX + local centerZ = opts.centerZ + local radius = opts.radius + local angleDeg = opts.angleDeg + local falloffK = opts.falloffK + local edgeNoiseK = opts.edgeNoiseK + local erosionK = opts.erosionK + local talusK = opts.talusK + local seed = opts.seed + local startMode = opts.startMode or "average" + + local tanA = tan(angleDeg * pi / 180) + + local half = floor(radius / cellSize + 0.5) + if half < 4 then + half = 4 + end + local n = half * 2 + 1 + local ccx = floor(centerX / cellSize + 0.5) + local ccz = floor(centerZ / cellSize + 0.5) + local ox = ccx - half -- world cell of window column 0 + local oz = ccz - half + + -- 1) Original heights. nil = off-map; barriers for the distance transform. + local h = {} + for iz = 0, n - 1 do + local z = (oz + iz) * cellSize + if z >= 0 and z <= mapSizeZ then + local rowBase = iz * n + for ix = 0, n - 1 do + local x = (ox + ix) * cellSize + if x >= 0 and x <= mapSizeX then + h[rowBase + ix + 1] = getHeight(x, z) + end + end + end + end + + -- 2) Probe: the march starts at the steep cell NEAREST the click anywhere + -- inside the brush circle — the click itself does not need to land on the + -- face (top-down camera over a mesa, face on the far side, etc.). If no + -- cell within the radius is steep enough, there is no cliff to restyle. + local function cellSlopeAt(ix, iz) + local i = iz * n + ix + 1 + local hc = h[i] + if not hc then + return 0 + end + local s = 0 + if ix > 0 and h[i - 1] then + local dv = abs(hc - h[i - 1]) + if dv > s then + s = dv + end + end + if ix < n - 1 and h[i + 1] then + local dv = abs(hc - h[i + 1]) + if dv > s then + s = dv + end + end + if iz > 0 and h[i - n] then + local dv = abs(hc - h[i - n]) + if dv > s then + s = dv + end + end + if iz < n - 1 and h[i + n] then + local dv = abs(hc - h[i + n]) + if dv > s then + s = dv + end + end + return s / cellSize + end + + local MIN_PROBE_SLOPE = 0.25 -- ~14°: below this a cell doesn't count as cliff + local radiusSq = radius * radius + local bestIx, bestIz, bestDistSq + for iz = 0, n - 1 do + local dz = (iz - half) * cellSize + for ix = 0, n - 1 do + local dx = (ix - half) * cellSize + local dSq = dx * dx + dz * dz + if dSq < radiusSq and (not bestDistSq or dSq < bestDistSq) then + if cellSlopeAt(ix, iz) >= MIN_PROBE_SLOPE then + bestIx, bestIz, bestDistSq = ix, iz, dSq + end + end + end + end + if not bestIx then + return nil, "no_cliff" + end + + -- Steepest ascent/descent march to the plateau heights. Returns the plateau + -- height and the EUCLIDEAN start→stop distance: the walk itself zigzags on + -- noisy faces, and using its accumulated path length made real cliffs read + -- 2–3x gentler than they are, flipping the extend/subtract anchor direction. + local function march(dirUp) + local ix, iz = bestIx, bestIz + local cur = h[iz * n + ix + 1] + local flatGain = cellSize * 0.176 -- tan(10°) per axial step + for _ = 1, n do + local bx, bz, bh + for dz = -1, 1 do + for dx = -1, 1 do + if dx ~= 0 or dz ~= 0 then + local jx, jz = ix + dx, iz + dz + if jx >= 0 and jx < n and jz >= 0 and jz < n then + local hn = h[jz * n + jx + 1] + if hn and ((dirUp and hn > (bh or cur)) or (not dirUp and hn < (bh or cur))) then + bx, bz, bh = jx, jz, hn + end + end + end + end + end + if not bx then + break + end + local gain = abs(bh - cur) + local stepCells = (bx ~= ix and bz ~= iz) and 1.41421 or 1 + ix, iz, cur = bx, bz, bh + if gain < flatGain * stepCells then + break -- slope fell below ~10°: plateau reached + end + end + local ddx = (ix - bestIx) * cellSize + local ddz = (iz - bestIz) * cellSize + return cur, sqrt(ddx * ddx + ddz * ddz) + end + + local hTop, lenUp = march(true) + local hBot, lenDown = march(false) + local span = hTop - hBot + if span < MIN_SPAN then + return nil, "no_span" + end + local hMid = (hTop + hBot) * 0.5 + + -- 3) Signed chamfer distance from the hMid iso-contour of the ORIGINAL + -- heights. Positive above the mid line, negative below. + local INF = 1e9 + local dist = {} + local dSign = {} + for iz = 0, n - 1 do + local rowBase = iz * n + for ix = 0, n - 1 do + local i = rowBase + ix + 1 + local hv = h[i] + if hv then + dSign[i] = (hv >= hMid) and 1 or -1 + dist[i] = INF + end + end + end + local contourFound = false + for iz = 0, n - 1 do + local rowBase = iz * n + for ix = 0, n - 1 do + local i = rowBase + ix + 1 + local s = dSign[i] + if s then + if + (ix > 0 and dSign[i - 1] and dSign[i - 1] ~= s) + or (ix < n - 1 and dSign[i + 1] and dSign[i + 1] ~= s) + or (iz > 0 and dSign[i - n] and dSign[i - n] ~= s) + or (iz < n - 1 and dSign[i + n] and dSign[i + n] ~= s) + then + dist[i] = cellSize * 0.5 + contourFound = true + end + end + end + end + if not contourFound then + return nil, "no_contour" + end + local D1 = cellSize + local D2 = cellSize * 1.41421 + for iz = 0, n - 1 do + local rowBase = iz * n + for ix = 0, n - 1 do + local i = rowBase + ix + 1 + local dv = dist[i] + if dv then + if ix > 0 and dist[i - 1] and dist[i - 1] + D1 < dv then + dv = dist[i - 1] + D1 + end + if iz > 0 then + local j = i - n + if dist[j] and dist[j] + D1 < dv then + dv = dist[j] + D1 + end + if ix > 0 and dist[j - 1] and dist[j - 1] + D2 < dv then + dv = dist[j - 1] + D2 + end + if ix < n - 1 and dist[j + 1] and dist[j + 1] + D2 < dv then + dv = dist[j + 1] + D2 + end + end + dist[i] = dv + end + end + end + for iz = n - 1, 0, -1 do + local rowBase = iz * n + for ix = n - 1, 0, -1 do + local i = rowBase + ix + 1 + local dv = dist[i] + if dv then + if ix < n - 1 and dist[i + 1] and dist[i + 1] + D1 < dv then + dv = dist[i + 1] + D1 + end + if iz < n - 1 then + local j = i + n + if dist[j] and dist[j] + D1 < dv then + dv = dist[j] + D1 + end + if ix < n - 1 and dist[j + 1] and dist[j + 1] + D2 < dv then + dv = dist[j + 1] + D2 + end + if ix > 0 and dist[j - 1] and dist[j - 1] + D2 < dv then + dv = dist[j - 1] + D2 + end + end + dist[i] = dv + end + end + end + + -- 4) Anchor + tunables. All derive from the brush radius and cliff span so + -- the look scales with the feature being edited. + local perm = buildPermTable(seed) + local capT = hTop - hMid -- > 0 + local capB = hMid - hBot -- > 0 + local dTop = capT / tanA -- new face half-width above the mid line + local dBot = capB / tanA + + -- Original cliff slope from the march: anchors extend/subtract so the + -- preserved lip sits where the ORIGINAL face met its plateau. + local tanOrig = span / max(cellSize, lenUp + lenDown) + if tanOrig < 0.15 then + tanOrig = 0.15 + elseif tanOrig > 20 then + tanOrig = 20 + end + local dOff = 0 + if startMode == "extend" then + dOff = capT / tanOrig - dTop -- new top lip lands on the original top lip + elseif startMode == "subtract" then + dOff = dBot - capB / tanOrig -- new bottom lip lands on the original bottom lip + end + + local shoulderT = max(cellSize, capT * (0.15 + 0.85 * falloffK)) -- soft-clamp knee (height units) + local shoulderB = max(cellSize, capB * (0.15 + 0.85 * falloffK)) + local dFaceT = dTop + shoulderT / tanA + local dFaceB = dBot + shoulderB / tanA + local blendLen = max(cellSize * 2, falloffK * radius * 0.35) + local edgeAmp = edgeNoiseK * radius * 0.22 + local edgeScale = max(48, radius * 0.45) + local grooveScale = max(24, radius * 0.12) + local grooveDepth = erosionK * span * 0.16 + -- Talus is a DEPOSITION SURFACE, not an additive wedge: a concave scree + -- cone leaning on the face base at the natural angle of repose, composited + -- with max() so repeated clicks converge onto it instead of stacking. + local talusHeight = talusK * span * 0.45 -- pile height where it leans on the face + local TAN_REPOSE = 0.62 -- ~32°, natural scree repose slope + local talusLen = max(cellSize * 3, talusHeight / TAN_REPOSE) -- fan run-out length + local talusNScale = max(24, radius * 0.18) -- lobe wavelength along the fan + local rimIn = radius * 0.72 -- brush-ring blend start + local rimSpan = radius - rimIn + local rejHi = dOff + dFaceT + blendLen + edgeAmp + local rejLo = dOff - (max(dFaceB + blendLen, dBot + talusLen) + edgeAmp) + + local newH = {} + + for iz = 0, n - 1 do + local rowBase = iz * n + local z = (oz + iz) * cellSize + for ix = 0, n - 1 do + local i = rowBase + ix + 1 + local orig = h[i] + local dRaw = orig and dist[i] + if orig then + newH[i] = orig + end + if dRaw and dRaw < INF then + local x = (ox + ix) * cellSize + local dx = x - centerX + local dz = z - centerZ + local r = sqrt(dx * dx + dz * dz) + local d = dRaw * dSign[i] + if r < radius and d < rejHi and d > rejLo then + local wRing = 1 + if r > rimIn then + local t = (r - rimIn) / rimSpan + wRing = 1 - t * t * (3 - 2 * t) + end + -- Wavy lips: perturb the across-face coordinate, which shifts + -- the face, the shoulders and the talus line together. + local dn = d + if edgeAmp > 0 then + dn = d + fbmNoise(x / edgeScale, z / edgeScale, perm, 3, 0.5, 2.0) * edgeAmp + end + local ds = dn - dOff -- face-relative across coordinate + local v = ds * tanA + if v >= 0 then + v = softCap(v, capT, shoulderT) + else + v = -softCap(-v, capB, shoulderB) + end + local target = hMid + v + -- Hard mode guarantee, independent of how well the anchor was + -- estimated: extend only ever raises terrain, subtract only + -- ever lowers it. (Gullies and talus still texture on top.) + if startMode == "extend" then + if target < orig then + target = orig + end + elseif startMode == "subtract" then + if target > orig then + target = orig + end + end + -- Band weight: full on the face, fades into untouched plateau. + local aw = (ds >= 0) and (ds - dFaceT) or (-ds - dFaceB) + local wBand = 1 + if aw > 0 then + if aw >= blendLen then + wBand = 0 + else + local t = aw / blendLen + wBand = 1 - t * t * (3 - 2 * t) + end + end + -- Gullies: ridged noise stretched along the original downslope + -- direction reads as erosion channels cut into the face. + if grooveDepth > 0 and wBand > 0 then + local gx, gz = 0, 0 + if ix > 0 and ix < n - 1 and h[i - 1] and h[i + 1] then + gx = h[i + 1] - h[i - 1] + end + if iz > 0 and iz < n - 1 and h[i - n] and h[i + n] then + gz = h[i + n] - h[i - n] + end + local gm = sqrt(gx * gx + gz * gz) + if gm > 0.5 then + gx, gz = gx / gm, gz / gm + local u = (-gz * x + gx * z) / grooveScale + local w = (gx * x + gz * z) / (grooveScale * 3.5) + local rg = ridgedNoise(u, w, perm, 3, 0.5, 2.0) + local capSide = (v >= 0) and capT or capB + local faceMask = 0 + if capSide > 1 then + faceMask = 1 - min(1, abs(v) / capSide) + end + target = target - grooveDepth * rg * faceMask * wBand + end + end + local out = orig + (target - orig) * wBand + -- Talus: raise the ground onto the scree deposition surface where + -- that surface is higher. u runs 0 at the fan's outer edge → 1 + -- at the face base; u^1.6 gives the concave profile of settled + -- debris (steep against the cliff, feathering to zero slope at + -- the run-out), fbm lobes break the fan into natural tongues, + -- and the cap keeps it below the mid line. max() compositing + -- makes repeated clicks converge instead of stacking material. + if talusHeight > 0 then + local u = (ds + dBot + talusLen) / talusLen + if u > 0 then + if u > 1.35 then + u = 1.35 -- scree may lean partway up the face + end + local tn = fbmNoise(x / talusNScale + 313.7, z / talusNScale - 157.3, perm, 2, 0.5, 2.0) + local surf = hBot + talusHeight * (u ^ 1.6) * (0.75 + 0.45 * tn) + local capH = hMid - span * 0.05 + if surf > capH then + surf = capH + end + if surf > out then + out = surf + end + end + end + newH[i] = orig + (out - orig) * wRing + end + end + end + end + + return { + n = n, + ox = ox, + oz = oz, + cellSize = cellSize, + orig = h, + newH = newH, + hTop = hTop, + hBot = hBot, + hMid = hMid, + } +end + +return M diff --git a/common/configs/keybind_catalog.json b/common/configs/keybind_catalog.json new file mode 100644 index 00000000000..c52662d3a11 --- /dev/null +++ b/common/configs/keybind_catalog.json @@ -0,0 +1,874 @@ +[ + { + "hidden": [ + "attack_range_dec", + "buildmenu_pregame_deselect", + "chat", + "chatswitchally", + "chatswitchspec", + "cloak", + "customgameinfo_close", + "dynamicsky", + "edit_backspace", + "edit_complete", + "edit_delete", + "edit_end", + "edit_escape", + "edit_home", + "edit_next_char", + "edit_next_line", + "edit_next_word", + "edit_prev_char", + "edit_prev_line", + "edit_prev_word", + "edit_return", + "losradar", + "movereset", + "moverotate", + "moveslow", + "movetilt", + "pastetext", + "quitforce", + "quitmenu", + "quitmessage", + "reloadforce", + "selectbox_any", + "selectbox_append", + "selectbox_deselect", + "selectbox_mobile", + "selectloop_add", + "selectloop_invert", + "teamstatus_close", + "toggle_allied_upgrade", + "togglecammode", + "togglelos", + "toggleoverview", + "track", + "trackmode", + "viewfps", + "viewfree", + "viewrot", + "viewspring", + "viewta" + ] + }, + { + "category": "categories.selection", + "items": [ + { + "action": "selectcomm append", + "label": "actions.selection.commAppend" + }, + { + "action": "selectcomm focus", + "label": "actions.selection.commFocus" + }, + { + "action": "select AllMap++_ClearSelection_SelectAll+", + "label": "actions.massSelect.all" + }, + { + "action": "select AllMap+_Builder_Idle+_ClearSelection_SelectOne+", + "label": "actions.massSelect.builders" + }, + { + "action": "select AllMap+_InPrevSel+_ClearSelection_SelectAll+", + "label": "actions.massSelect.sameType" + }, + { + "action": "select Visible+_InPrevSel+_ClearSelection_SelectAll+", + "label": "actions.massSelect.sameTypeVisible" + }, + { + "action": "select PrevSelection+_Not_Building_Not_RelativeHealth_60+_ClearSelection_SelectAll+", + "label": "actions.massSelect.damaged" + }, + { + "action": "select PrevSelection++_ClearSelection_SelectPart_50+", + "label": "actions.massSelect.half" + }, + { + "action": "select AllMap+_Transport_Idle+_ClearSelection_SelectAll+", + "label": "actions.massSelect.idleTransports" + }, + { + "action": "select Visible+_Waiting+_ClearSelection_SelectAll+", + "label": "actions.massSelect.waitingVisible" + }, + { + "action": "select AllMap++_ClearSelection_SelectNum_0+", + "label": "actions.massSelect.deselectAll" + }, + { + "action": "selectbox_idle", + "label": "actions.selection.boxIdle", + "alwaysModifier": "any" + }, + { + "action": "selectbox_same", + "label": "actions.selection.boxSame", + "alwaysModifier": "any" + } + ] + }, + { + "category": "categories.orders", + "items": [ + { + "action": "move", + "label": "commands.move", + "alwaysModifier": "shift" + }, + { + "action": "attack", + "label": "commands.attack", + "alwaysModifier": "shift" + }, + { + "action": "settarget", + "label": "commands.settarget", + "alwaysModifier": "shift" + }, + { + "action": "repair", + "label": "commands.repair", + "alwaysModifier": "shift" + }, + { + "action": "reclaim", + "label": "commands.reclaim", + "alwaysModifier": "shift" + }, + { + "action": "resurrect", + "label": "commands.resurrect", + "alwaysModifier": "shift" + }, + { + "action": "fight", + "label": "commands.fight", + "alwaysModifier": "shift" + }, + { + "action": "patrol", + "label": "commands.patrol", + "alwaysModifier": "shift" + }, + { + "action": "wantcloak", + "label": "actions.orders.cloak", + "alwaysModifier": "any" + }, + { + "action": "stop", + "label": "commands.stop", + "alwaysModifier": "shift" + }, + { + "action": "wait", + "label": "commands.wait" + }, + { + "action": "wait queued", + "label": "actions.orders.waitQueued" + }, + { + "action": "canceltarget", + "label": "commands.canceltarget" + }, + { + "action": "manualfire", + "label": "commands.manualfire", + "alwaysModifier": "shift" + }, + { + "action": "selfd", + "label": "actions.orders.selfDestruct" + }, + { + "action": "selfd queued", + "label": "actions.orders.selfDestructQueued" + }, + { + "action": "areaattack", + "label": "commands.areaattack", + "alwaysModifier": "shift" + }, + { + "action": "guard", + "label": "commands.guard", + "alwaysModifier": "shift" + }, + { + "action": "capture", + "label": "commands.capture", + "alwaysModifier": "shift" + }, + { + "action": "restore", + "label": "commands.restore", + "alwaysModifier": "shift" + }, + { + "action": "settargetnoground", + "label": "actions.orders.setTargetNoGround", + "alwaysModifier": "shift" + }, + { + "action": "loadunits", + "label": "commands.loadunits", + "alwaysModifier": "shift" + }, + { + "action": "unloadunits", + "label": "commands.unloadunits", + "alwaysModifier": "shift" + }, + { + "action": "gatherwait", + "label": "actions.orders.gatherWait", + "alwaysModifier": "shift" + }, + { + "action": "manuallaunch", + "label": "commands.manuallaunch", + "alwaysModifier": "shift" + }, + { + "action": "stopproduction", + "label": "commands.stopproduction", + "alwaysModifier": "shift" + } + ] + }, + { + "category": "categories.queues", + "items": [ + { + "action": "commandinsert prepend_between", + "label": "actions.queues.prepend", + "alwaysModifier": "any" + }, + { + "action": "command_skip_current", + "label": "actions.queues.skipCurrent" + }, + { + "action": "command_cancel_last", + "label": "actions.queues.cancelLast" + } + ] + }, + { + "category": "categories.unitStates", + "items": [ + { + "action": "onoff", + "label": "actions.unitStates.onoffToggle" + }, + { + "action": "onoff 0", + "label": "actions.unitStates.onoffOff" + }, + { + "action": "onoff 1", + "label": "actions.unitStates.onoffOn" + }, + { + "action": "repeat 0", + "label": "actions.unitStates.repeatOff", + "description": "commands.repeat_tooltip" + }, + { + "action": "repeat 1", + "label": "actions.unitStates.repeatOn", + "description": "commands.repeat_tooltip" + }, + { + "prefix": "trajectory_toggle ", + "label": "actions.unitStates.trajectory", + "members": [ + "0", + "1", + "2" + ] + }, + { + "action": "firestate 0", + "label": "actions.unitStates.fireHold", + "description": "commands.firestate_hold_fire_descr" + }, + { + "action": "firestate 1", + "label": "actions.unitStates.fireReturn", + "description": "commands.firestate_return_fire_descr" + }, + { + "action": "firestate 2", + "label": "actions.unitStates.fireAtWill", + "description": "commands.firestate_fire_at_will_descr" + }, + { + "action": "movestate 0", + "label": "actions.unitStates.moveHold", + "description": "commands.movestate_tooltip" + }, + { + "action": "movestate 1", + "label": "actions.unitStates.moveManeuver", + "description": "commands.movestate_tooltip" + }, + { + "action": "movestate 2", + "label": "actions.unitStates.moveRoam", + "description": "commands.movestate_tooltip" + } + ] + }, + { + "category": "categories.controlGroups", + "items": [ + { + "prefix": "group select ", + "label": "actions.controlGroups.select", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "prefix": "group focus ", + "label": "actions.controlGroups.focus", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "prefix": "group set ", + "label": "actions.controlGroups.assign", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "prefix": "group add ", + "label": "actions.controlGroups.add", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "prefix": "group selectadd ", + "label": "actions.controlGroups.selectAdd", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "prefix": "group selecttoggle ", + "label": "actions.controlGroups.selectToggle", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "action": "group unset", + "label": "actions.controlGroups.clear" + }, + { + "prefix": "add_to_autogroup ", + "label": "actions.controlGroups.addAuto", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "action": "remove_from_autogroup", + "label": "actions.massSelect.removeAutoGroup" + }, + { + "action": "remove_one_unit_from_group", + "label": "actions.controlGroups.removeOne" + }, + { + "prefix": "load_autogroup_preset ", + "label": "actions.controlGroups.preset", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + } + ] + }, + { + "category": "categories.buildHotkeys", + "items": [ + { + "action": "areamex", + "label": "commands.areamex" + }, + { + "prefix": "buildunit_", + "label": "actions.buildHotkeys.unit", + "unit": true + }, + { + "action": "buildfacing inc", + "label": "actions.buildOrders.rotate", + "alwaysModifier": "shift" + }, + { + "action": "buildfacing dec", + "label": "actions.buildOrders.rotateBack", + "alwaysModifier": "shift" + }, + { + "action": "buildspacing inc", + "label": "actions.issueBuildOrders.spacingUp", + "alwaysModifier": "shift" + }, + { + "action": "buildspacing dec", + "label": "actions.issueBuildOrders.spacingDown", + "alwaysModifier": "shift" + }, + { + "action": "buildsplit", + "label": "actions.buildHotkeys.split", + "alwaysModifier": "any" + }, + { + "action": "factoryqueuemode", + "label": "actions.factory.queueMode" + }, + { + "prefix": "factoryguard ", + "label": "commands.factoryguard", + "members": [ + "0", + "1" + ] + } + ] + }, + { + "category": "categories.gridMenu", + "items": [ + { + "action": "gridmenu_category 1", + "label": "ui.buildMenu.category_econ", + "alwaysModifier": "shift" + }, + { + "action": "gridmenu_category 2", + "label": "ui.buildMenu.category_combat", + "alwaysModifier": "shift" + }, + { + "action": "gridmenu_category 3", + "label": "ui.buildMenu.category_utility", + "alwaysModifier": "shift" + }, + { + "action": "gridmenu_category 4", + "label": "ui.buildMenu.category_production", + "alwaysModifier": "shift" + }, + { + "prefix": "gridmenu_key", + "label": "actions.gridMenu.buildKey", + "alwaysModifier": "any", + "members": [ + " 1 1", + " 1 2", + " 1 3", + " 1 4", + " 2 1", + " 2 2", + " 2 3", + " 2 4", + " 3 1", + " 3 2", + " 3 3", + " 3 4" + ] + }, + { + "action": "gridmenu_next_page", + "label": "actions.gridMenu.nextPage" + }, + { + "action": "gridmenu_cycle_builder", + "label": "actions.gridMenu.cycleBuilder" + } + ], + "layout": "grid" + }, + { + "category": "categories.blueprints", + "items": [ + { + "action": "blueprint_create", + "label": "commands.blueprint_create" + }, + { + "action": "blueprint_place", + "label": "commands.blueprint_place" + }, + { + "action": "blueprint_delete", + "label": "actions.blueprints.delete" + }, + { + "action": "blueprint_next", + "label": "actions.blueprints.next" + }, + { + "action": "blueprint_prev", + "label": "actions.blueprints.prev" + }, + { + "prefix": "blueprint_" + } + ] + }, + { + "category": "categories.camera", + "items": [ + { + "action": "cameraflip", + "label": "actions.camera.flip" + }, + { + "action": "moveforward", + "label": "actions.camera.moveForward", + "alwaysModifier": "any" + }, + { + "action": "moveback", + "label": "actions.camera.moveBack", + "alwaysModifier": "any" + }, + { + "action": "moveleft", + "label": "actions.camera.moveLeft", + "alwaysModifier": "any" + }, + { + "action": "moveright", + "label": "actions.camera.moveRight", + "alwaysModifier": "any" + }, + { + "action": "moveup", + "label": "actions.camera.moveUp", + "alwaysModifier": "any" + }, + { + "action": "movedown", + "label": "actions.camera.moveDown", + "alwaysModifier": "any" + }, + { + "action": "movefast", + "label": "actions.camera.moveFast", + "alwaysModifier": "any" + }, + { + "prefix": "set_camera_anchor ", + "label": "actions.camera.setAnchor", + "members": [ + "1", + "2", + "3", + "4" + ] + }, + { + "prefix": "focus_camera_anchor ", + "label": "actions.camera.jumpAnchor", + "members": [ + "1", + "2", + "3", + "4" + ] + }, + { + "action": "lastmsgpos", + "label": "actions.cameraModes.mapmarks" + }, + { + "action": "cycleselected next", + "label": "actions.camera.cycleSelectedNext" + }, + { + "action": "cycleselected prev", + "label": "actions.camera.cycleSelectedPrev" + }, + { + "action": "pip1_switch", + "label": "actions.camera.pipSwitch" + }, + { + "action": "pip1_copy", + "label": "actions.camera.pipCopy" + }, + { + "action": "pip1_track", + "label": "actions.camera.pipTrack" + } + ] + }, + { + "category": "categories.mapViews", + "items": [ + { + "action": "showelevation", + "label": "actions.cameraModes.heightmap" + }, + { + "action": "showpathtraversability", + "label": "actions.cameraModes.traversability" + }, + { + "action": "showmetalmap", + "label": "actions.cameraModes.resourceSpots" + } + ] + }, + { + "category": "categories.interfaceDisplay", + "items": [ + { + "action": "options", + "label": "actions.interfaceDisplay.settings" + }, + { + "action": "luaui selector", + "label": "actions.interfaceDisplay.widgetSelector" + }, + { + "action": "hideinterface", + "label": "actions.cameraModes.interface" + }, + { + "action": "unit_stats", + "label": "actions.interfaceDisplay.unitStats" + }, + { + "action": "savegame", + "label": "actions.interfaceDisplay.saveGame" + }, + { + "action": "screenshot png", + "label": "actions.interfaceDisplay.screenshot" + }, + { + "action": "attack_range_inc", + "label": "actions.interfaceDisplay.firingRange" + }, + { + "action": "chain force forcestart | say !cv forcestart", + "label": "actions.gameControl.voteForcestart" + } + ] + }, + { + "category": "categories.drawing", + "items": [ + { + "action": "drawinmap", + "label": "actions.drawing.drawInMap", + "alwaysModifier": "any" + }, + { + "action": "drawlabel", + "label": "actions.drawing.drawLabel" + }, + { + "action": "clearmapmarks", + "label": "actions.console.erase" + } + ] + }, + { + "category": "categories.sound", + "items": [ + { + "action": "mutesound", + "label": "actions.sound.mute" + }, + { + "action": "snd_volume_increase", + "label": "actions.sound.volumeUp" + }, + { + "action": "snd_volume_decrease", + "label": "actions.sound.volumeDown" + } + ] + }, + { + "category": "categories.gameControl", + "items": [ + { + "action": "pause", + "label": "actions.console.pause", + "alwaysModifier": "any" + }, + { + "action": "increasespeed", + "label": "actions.gameControl.increaseSpeed" + }, + { + "action": "decreasespeed", + "label": "actions.gameControl.decreaseSpeed" + }, + { + "prefix": "specteam ", + "label": "actions.spectating.spectate", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8" + ] + } + ] + }, + { + "category": "categories.other", + "items": [ + { + "action": "selectloop", + "label": "actions.selection.loopActivate", + "alwaysModifier": "any" + }, + { + "prefix": "factory_preset load ", + "label": "actions.factory.loadPreset", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "prefix": "factory_preset save ", + "label": "actions.factory.savePreset", + "members": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "action": "factory_preset_show", + "label": "actions.factory.showPresets", + "alwaysModifier": "any" + }, + { + "action": "factory_preset_toggle", + "label": "actions.factory.togglePresets" + }, + { + "action": "fov_inc 5", + "label": "actions.camera.fovIncrease" + }, + { + "action": "fov_dec 5", + "label": "actions.camera.fovDecrease" + } + ] + } +] diff --git a/common/configs/keybind_catalog.schema.json b/common/configs/keybind_catalog.schema.json new file mode 100644 index 00000000000..c175132d94f --- /dev/null +++ b/common/configs/keybind_catalog.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/beyond-all-reason/Beyond-All-Reason/master/common/configs/keybind_catalog.schema.json", + "title": "Keybind catalog", + "description": "Ordered, cross-surface catalog of keybindable commands grouped into categories. Shared by the in-game editor and external lobbies. Holds i18n keys and bind-action ids, not resolved strings or actual key bindings.", + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "description": "Category of keybindable commands.", + "required": ["category", "items"], + "additionalProperties": false, + "properties": { + "category": { + "type": "string", + "description": "i18n key for the category title." + }, + "items": { + "type": "array", + "items": { "$ref": "#/definitions/item" } + }, + "layout": { "type": "string", "enum": ["grid"], "description": "Renders this category as the grid menu's own 3x4 layout instead of a flat list, so the keys read the way they sit on screen." } + } + }, + { + "type": "object", + "description": "Actions bound but intentionally never shown, matched by exact id (not prefix).", + "required": ["hidden"], + "additionalProperties": false, + "properties": { + "hidden": { + "type": "array", + "items": { "type": "string", "description": "Exact action id to suppress." } + } + } + } + ] + }, + "definitions": { + "item": { + "oneOf": [ + { + "type": "object", + "description": "Editable binding the user can rebind.", + "required": ["action", "label"], + "additionalProperties": false, + "properties": { + "action": { "type": "string", "description": "Bind command (command + space-separated args), exactly as passed to /bind and reported by GetKeyBindings." }, + "label": { "type": "string", "description": "i18n key for the display label." }, + "description": { "type": "string", "description": "i18n key for a sentence saying what the action does, for a tooltip. Optional: without one a surface may fall back to the command card's tooltip (commands._tooltip) or the engine's command description (cmd.)." }, + "icon": { "type": "string", "description": "VFS path of a picture for the action, drawn on its key in the keyboard overview and wherever else a surface has room for one. Optional: without one an order shows the cursor it is already known by, and anything else shows no picture." }, + "alwaysModifier": { "type": "string", "enum": ["any", "shift"], "description": "Modifier this action always tolerates, so a surface neither shows it nor lets the player pick it. \"any\" binds with the engine's Any+ qualifier and fires whatever is held. \"shift\" has no engine equivalent, so the binding is written twice, bare and Shift+, and both halves move together. Fixed per action rather than chosen." } + } + }, + { + "type": "object", + "description": "Prefix group: claims every bound action whose id starts with this prefix. With a label the arg after the prefix is interpolated as %{n} (or its two whitespace-split tokens as %{row}/%{col}); without one, rows show the raw id. With unit=true the arg is resolved to a unit's translated name.", + "required": ["prefix"], + "additionalProperties": false, + "properties": { + "prefix": { "type": "string", "description": "Action id prefix." }, + "label": { "type": "string", "description": "i18n key for the display label, interpolated per matched action." }, + "unit": { "type": "boolean", "description": "When true, the arg after the prefix is a unit codename resolved to its translated human name." }, + "members": { "type": "array", "items": { "type": "string" }, "description": "The args this family covers, appended to the prefix to form each action. Listing them makes the rows exist whether or not anything is bound, so unbinding one leaves it there to bind again. Omit for families that cannot be enumerated (buildunit_ is per unit) and they are discovered from what is bound." }, + "icon": { "type": "string", "description": "VFS path of a picture shared by every action in the family, drawn on its key in the keyboard overview. Optional." }, + "alwaysModifier": { "type": "string", "enum": ["any"], "description": "Modifier this action always tolerates, so a surface neither shows it nor lets the player pick it. \"any\" binds with the engine's Any+ qualifier and fires whatever is held. Fixed per action rather than chosen." } + } + } + ] + } + } +} diff --git a/common/configs/keybind_defaults.json b/common/configs/keybind_defaults.json new file mode 100644 index 00000000000..cccfb377946 --- /dev/null +++ b/common/configs/keybind_defaults.json @@ -0,0 +1,6062 @@ +{ + "version": 1, + "priority": [], + "profiles": [ + { + "name": "Grid", + "description": "ui.keybinds.presets.grid", + "fakeMeta": "space", + "binds": [ + { + "keyset": "esc", + "action": "select AllMap++_ClearSelection_SelectNum_0+" + }, + { + "keyset": "esc", + "action": "quitmessage" + }, + { + "keyset": "Shift+esc", + "action": "quitmenu" + }, + { + "keyset": "Ctrl+Shift+esc", + "action": "quitforce" + }, + { + "keyset": "Alt+Shift+esc", + "action": "reloadforce" + }, + { + "keyset": "Any+escape", + "action": "edit_escape" + }, + { + "keyset": "Any+pause", + "action": "pause" + }, + { + "keyset": "esc", + "action": "teamstatus_close" + }, + { + "keyset": "esc", + "action": "customgameinfo_close" + }, + { + "keyset": "esc", + "action": "buildmenu_pregame_deselect" + }, + { + "keyset": "Any+sc_z", + "action": "selectbox_same" + }, + { + "keyset": "Any+space", + "action": "selectbox_idle" + }, + { + "keyset": "Any+shift", + "action": "selectbox_append" + }, + { + "keyset": "Any+shift", + "action": "selectbox_any" + }, + { + "keyset": "Any+ctrl", + "action": "selectbox_deselect" + }, + { + "keyset": "Any+alt", + "action": "selectbox_mobile" + }, + { + "keyset": "Any+space", + "action": "selectloop" + }, + { + "keyset": "Any+ctrl", + "action": "selectloop_invert" + }, + { + "keyset": "Any+shift", + "action": "selectloop_add" + }, + { + "keyset": "Any+space", + "action": "buildsplit" + }, + { + "keyset": "Any+space", + "action": "commandinsert prepend_between" + }, + { + "keyset": "alt+sc_.", + "action": "attack_range_inc" + }, + { + "keyset": "alt+sc_comma", + "action": "attack_range_dec" + }, + { + "keyset": "Any+enter", + "action": "chat" + }, + { + "keyset": "Alt+ctrl+sc_a", + "action": "chatswitchally" + }, + { + "keyset": "Alt+ctrl+sc_s", + "action": "chatswitchspec" + }, + { + "keyset": "Any+tab", + "action": "edit_complete" + }, + { + "keyset": "Any+backspace", + "action": "edit_backspace" + }, + { + "keyset": "Any+delete", + "action": "edit_delete" + }, + { + "keyset": "Any+home", + "action": "edit_home" + }, + { + "keyset": "Alt+left", + "action": "edit_home" + }, + { + "keyset": "Any+end", + "action": "edit_end" + }, + { + "keyset": "Alt+right", + "action": "edit_end" + }, + { + "keyset": "Any+up", + "action": "edit_prev_line" + }, + { + "keyset": "Any+down", + "action": "edit_next_line" + }, + { + "keyset": "Any+left", + "action": "edit_prev_char" + }, + { + "keyset": "Any+right", + "action": "edit_next_char" + }, + { + "keyset": "Ctrl+left", + "action": "edit_prev_word" + }, + { + "keyset": "Ctrl+right", + "action": "edit_next_word" + }, + { + "keyset": "Any+enter", + "action": "edit_return" + }, + { + "keyset": "Ctrl+v", + "action": "pastetext" + }, + { + "keyset": "Any+up", + "action": "moveforward" + }, + { + "keyset": "Any+down", + "action": "moveback" + }, + { + "keyset": "Any+right", + "action": "moveright" + }, + { + "keyset": "Any+left", + "action": "moveleft" + }, + { + "keyset": "Any+pageup", + "action": "moveup" + }, + { + "keyset": "Any+pagedown", + "action": "movedown" + }, + { + "keyset": "Any+alt", + "action": "movereset" + }, + { + "keyset": "Any+alt", + "action": "moverotate" + }, + { + "keyset": "Any+ctrl", + "action": "movetilt" + }, + { + "keyset": "ctrl+sc_o", + "action": "fov_dec 5" + }, + { + "keyset": "ctrl+sc_p", + "action": "fov_inc 5" + }, + { + "keyset": "sc_numpad1", + "action": "fov_dec 5" + }, + { + "keyset": "sc_numpad7", + "action": "fov_inc 5" + }, + { + "keyset": "Meta+ctrl+tab", + "action": "pip1_copy" + }, + { + "keyset": "Meta+tab", + "action": "pip1_switch" + }, + { + "keyset": "Alt+sc_t", + "action": "pip1_track" + }, + { + "keyset": "Any+alt", + "action": "toggle_allied_upgrade" + }, + { + "keyset": "sc_z", + "action": "gridmenu_category 1" + }, + { + "keyset": "sc_x", + "action": "gridmenu_category 2" + }, + { + "keyset": "sc_c", + "action": "gridmenu_category 3" + }, + { + "keyset": "sc_v", + "action": "gridmenu_category 4" + }, + { + "keyset": "Shift+sc_z", + "action": "gridmenu_category 1" + }, + { + "keyset": "Shift+sc_x", + "action": "gridmenu_category 2" + }, + { + "keyset": "Shift+sc_c", + "action": "gridmenu_category 3" + }, + { + "keyset": "Shift+sc_v", + "action": "gridmenu_category 4" + }, + { + "keyset": "Any+sc_z", + "action": "gridmenu_key 1 1" + }, + { + "keyset": "Any+sc_x", + "action": "gridmenu_key 1 2" + }, + { + "keyset": "Any+sc_c", + "action": "gridmenu_key 1 3" + }, + { + "keyset": "Any+sc_v", + "action": "gridmenu_key 1 4" + }, + { + "keyset": "Any+sc_a", + "action": "gridmenu_key 2 1" + }, + { + "keyset": "Any+sc_s", + "action": "gridmenu_key 2 2" + }, + { + "keyset": "Any+sc_d", + "action": "gridmenu_key 2 3" + }, + { + "keyset": "Any+sc_f", + "action": "gridmenu_key 2 4" + }, + { + "keyset": "Any+sc_q", + "action": "gridmenu_key 3 1" + }, + { + "keyset": "Any+sc_w", + "action": "gridmenu_key 3 2" + }, + { + "keyset": "Any+sc_e", + "action": "gridmenu_key 3 3" + }, + { + "keyset": "Any+sc_r", + "action": "gridmenu_key 3 4" + }, + { + "keyset": "sc_b", + "action": "gridmenu_next_page" + }, + { + "keyset": "sc_.", + "action": "gridmenu_cycle_builder" + }, + { + "keyset": "sc_.", + "action": "cycleselected next" + }, + { + "keyset": "sc_comma", + "action": "cycleselected prev" + }, + { + "keyset": "1", + "action": "specteam 0" + }, + { + "keyset": "2", + "action": "specteam 1" + }, + { + "keyset": "3", + "action": "specteam 2" + }, + { + "keyset": "4", + "action": "specteam 3" + }, + { + "keyset": "5", + "action": "specteam 4" + }, + { + "keyset": "6", + "action": "specteam 5" + }, + { + "keyset": "7", + "action": "specteam 6" + }, + { + "keyset": "8", + "action": "specteam 7" + }, + { + "keyset": "9", + "action": "specteam 8" + }, + { + "keyset": "Alt+0", + "action": "add_to_autogroup 0" + }, + { + "keyset": "Alt+1", + "action": "add_to_autogroup 1" + }, + { + "keyset": "Alt+2", + "action": "add_to_autogroup 2" + }, + { + "keyset": "Alt+3", + "action": "add_to_autogroup 3" + }, + { + "keyset": "Alt+4", + "action": "add_to_autogroup 4" + }, + { + "keyset": "Alt+5", + "action": "add_to_autogroup 5" + }, + { + "keyset": "Alt+6", + "action": "add_to_autogroup 6" + }, + { + "keyset": "Alt+7", + "action": "add_to_autogroup 7" + }, + { + "keyset": "Alt+8", + "action": "add_to_autogroup 8" + }, + { + "keyset": "Alt+9", + "action": "add_to_autogroup 9" + }, + { + "keyset": "Shift+Alt+0", + "action": "load_autogroup_preset 0" + }, + { + "keyset": "Shift+Alt+1", + "action": "load_autogroup_preset 1" + }, + { + "keyset": "Shift+Alt+2", + "action": "load_autogroup_preset 2" + }, + { + "keyset": "Shift+Alt+3", + "action": "load_autogroup_preset 3" + }, + { + "keyset": "Shift+Alt+4", + "action": "load_autogroup_preset 4" + }, + { + "keyset": "Shift+Alt+5", + "action": "load_autogroup_preset 5" + }, + { + "keyset": "Shift+Alt+6", + "action": "load_autogroup_preset 6" + }, + { + "keyset": "Shift+Alt+7", + "action": "load_autogroup_preset 7" + }, + { + "keyset": "Shift+Alt+8", + "action": "load_autogroup_preset 8" + }, + { + "keyset": "Shift+Alt+9", + "action": "load_autogroup_preset 9" + }, + { + "keyset": "0,0", + "action": "group focus 0" + }, + { + "keyset": "1,1", + "action": "group focus 1" + }, + { + "keyset": "2,2", + "action": "group focus 2" + }, + { + "keyset": "3,3", + "action": "group focus 3" + }, + { + "keyset": "4,4", + "action": "group focus 4" + }, + { + "keyset": "5,5", + "action": "group focus 5" + }, + { + "keyset": "6,6", + "action": "group focus 6" + }, + { + "keyset": "7,7", + "action": "group focus 7" + }, + { + "keyset": "8,8", + "action": "group focus 8" + }, + { + "keyset": "9,9", + "action": "group focus 9" + }, + { + "keyset": "0", + "action": "group select 0" + }, + { + "keyset": "1", + "action": "group select 1" + }, + { + "keyset": "2", + "action": "group select 2" + }, + { + "keyset": "3", + "action": "group select 3" + }, + { + "keyset": "4", + "action": "group select 4" + }, + { + "keyset": "5", + "action": "group select 5" + }, + { + "keyset": "6", + "action": "group select 6" + }, + { + "keyset": "7", + "action": "group select 7" + }, + { + "keyset": "8", + "action": "group select 8" + }, + { + "keyset": "9", + "action": "group select 9" + }, + { + "keyset": "Ctrl+0", + "action": "group set 0" + }, + { + "keyset": "Ctrl+1", + "action": "group set 1" + }, + { + "keyset": "Ctrl+2", + "action": "group set 2" + }, + { + "keyset": "Ctrl+3", + "action": "group set 3" + }, + { + "keyset": "Ctrl+4", + "action": "group set 4" + }, + { + "keyset": "Ctrl+5", + "action": "group set 5" + }, + { + "keyset": "Ctrl+6", + "action": "group set 6" + }, + { + "keyset": "Ctrl+7", + "action": "group set 7" + }, + { + "keyset": "Ctrl+8", + "action": "group set 8" + }, + { + "keyset": "Ctrl+9", + "action": "group set 9" + }, + { + "keyset": "Shift+0", + "action": "group selectadd 0" + }, + { + "keyset": "Shift+1", + "action": "group selectadd 1" + }, + { + "keyset": "Shift+2", + "action": "group selectadd 2" + }, + { + "keyset": "Shift+3", + "action": "group selectadd 3" + }, + { + "keyset": "Shift+4", + "action": "group selectadd 4" + }, + { + "keyset": "Shift+5", + "action": "group selectadd 5" + }, + { + "keyset": "Shift+6", + "action": "group selectadd 6" + }, + { + "keyset": "Shift+7", + "action": "group selectadd 7" + }, + { + "keyset": "Shift+8", + "action": "group selectadd 8" + }, + { + "keyset": "Shift+9", + "action": "group selectadd 9" + }, + { + "keyset": "Ctrl+Shift+0", + "action": "group add 0" + }, + { + "keyset": "Ctrl+Shift+1", + "action": "group add 1" + }, + { + "keyset": "Ctrl+Shift+2", + "action": "group add 2" + }, + { + "keyset": "Ctrl+Shift+3", + "action": "group add 3" + }, + { + "keyset": "Ctrl+Shift+4", + "action": "group add 4" + }, + { + "keyset": "Ctrl+Shift+5", + "action": "group add 5" + }, + { + "keyset": "Ctrl+Shift+6", + "action": "group add 6" + }, + { + "keyset": "Ctrl+Shift+7", + "action": "group add 7" + }, + { + "keyset": "Ctrl+Shift+8", + "action": "group add 8" + }, + { + "keyset": "Ctrl+Shift+9", + "action": "group add 9" + }, + { + "keyset": "Ctrl+Alt+0", + "action": "group selecttoggle 0" + }, + { + "keyset": "Ctrl+Alt+1", + "action": "group selecttoggle 1" + }, + { + "keyset": "Ctrl+Alt+2", + "action": "group selecttoggle 2" + }, + { + "keyset": "Ctrl+Alt+3", + "action": "group selecttoggle 3" + }, + { + "keyset": "Ctrl+Alt+4", + "action": "group selecttoggle 4" + }, + { + "keyset": "Ctrl+Alt+5", + "action": "group selecttoggle 5" + }, + { + "keyset": "Ctrl+Alt+6", + "action": "group selecttoggle 6" + }, + { + "keyset": "Ctrl+Alt+7", + "action": "group selecttoggle 7" + }, + { + "keyset": "Ctrl+Alt+8", + "action": "group selecttoggle 8" + }, + { + "keyset": "Ctrl+Alt+9", + "action": "group selecttoggle 9" + }, + { + "keyset": "meta+alt+0", + "action": "factory_preset save 0" + }, + { + "keyset": "meta+alt+1", + "action": "factory_preset save 1" + }, + { + "keyset": "meta+alt+2", + "action": "factory_preset save 2" + }, + { + "keyset": "meta+alt+3", + "action": "factory_preset save 3" + }, + { + "keyset": "meta+alt+4", + "action": "factory_preset save 4" + }, + { + "keyset": "meta+alt+5", + "action": "factory_preset save 5" + }, + { + "keyset": "meta+alt+6", + "action": "factory_preset save 6" + }, + { + "keyset": "meta+alt+7", + "action": "factory_preset save 7" + }, + { + "keyset": "meta+alt+8", + "action": "factory_preset save 8" + }, + { + "keyset": "meta+alt+9", + "action": "factory_preset save 9" + }, + { + "keyset": "meta+0", + "action": "factory_preset load 0" + }, + { + "keyset": "meta+1", + "action": "factory_preset load 1" + }, + { + "keyset": "meta+2", + "action": "factory_preset load 2" + }, + { + "keyset": "meta+3", + "action": "factory_preset load 3" + }, + { + "keyset": "meta+4", + "action": "factory_preset load 4" + }, + { + "keyset": "meta+5", + "action": "factory_preset load 5" + }, + { + "keyset": "meta+6", + "action": "factory_preset load 6" + }, + { + "keyset": "meta+7", + "action": "factory_preset load 7" + }, + { + "keyset": "meta+8", + "action": "factory_preset load 8" + }, + { + "keyset": "meta+9", + "action": "factory_preset load 9" + }, + { + "keyset": "any+sc_space", + "action": "factory_preset_show" + }, + { + "keyset": "Alt+sc_=", + "action": "increasespeed" + }, + { + "keyset": "Alt+sc_-", + "action": "decreasespeed" + }, + { + "keyset": "Alt+numpad+", + "action": "increasespeed" + }, + { + "keyset": "Alt+numpad-", + "action": "decreasespeed" + }, + { + "keyset": "sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "Shift+sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Shift+sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Shift+Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "Shift+Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "sc_a", + "action": "attack" + }, + { + "keyset": "Shift+sc_a", + "action": "attack" + }, + { + "keyset": "Ctrl+sc_a", + "action": "areaattack" + }, + { + "keyset": "Ctrl+Shift+sc_a", + "action": "areaattack" + }, + { + "keyset": "Ctrl+sc_b", + "action": "selfd" + }, + { + "keyset": "Ctrl+Shift+sc_b", + "action": "selfd queued" + }, + { + "keyset": "sc_d", + "action": "manualfire" + }, + { + "keyset": "Shift+sc_d", + "action": "manualfire" + }, + { + "keyset": "sc_d", + "action": "manuallaunch" + }, + { + "keyset": "Shift+sc_d", + "action": "manuallaunch" + }, + { + "keyset": "sc_e", + "action": "reclaim" + }, + { + "keyset": "Shift+sc_e", + "action": "reclaim" + }, + { + "keyset": "sc_f", + "action": "fight" + }, + { + "keyset": "Shift+sc_f", + "action": "fight" + }, + { + "keyset": "Alt+sc_f", + "action": "chain force forcestart | say !cv forcestart" + }, + { + "keyset": "sc_g", + "action": "stopproduction" + }, + { + "keyset": "Shift+sc_g", + "action": "stopproduction" + }, + { + "keyset": "sc_g", + "action": "stop" + }, + { + "keyset": "Shift+sc_g", + "action": "stop" + }, + { + "keyset": "sc_h", + "action": "patrol" + }, + { + "keyset": "Shift+sc_h", + "action": "patrol" + }, + { + "keyset": "sc_i", + "action": "unit_stats" + }, + { + "keyset": "sc_j", + "action": "loadunits" + }, + { + "keyset": "Shift+sc_j", + "action": "loadunits" + }, + { + "keyset": "sc_k", + "action": "cloak" + }, + { + "keyset": "Shift+sc_k", + "action": "cloak" + }, + { + "keyset": "sc_k", + "action": "wantcloak" + }, + { + "keyset": "Any+sc_k", + "action": "wantcloak" + }, + { + "keyset": "sc_m", + "action": "restore" + }, + { + "keyset": "Shift+sc_m", + "action": "restore" + }, + { + "keyset": "sc_n", + "action": "command_skip_current" + }, + { + "keyset": "Ctrl+sc_n", + "action": "command_cancel_last" + }, + { + "keyset": "sc_o", + "action": "guard" + }, + { + "keyset": "Shift+sc_o", + "action": "guard" + }, + { + "keyset": "sc_p", + "action": "gatherwait" + }, + { + "keyset": "Shift+sc_p", + "action": "gatherwait" + }, + { + "keyset": "sc_r", + "action": "repair" + }, + { + "keyset": "Shift+sc_r", + "action": "repair" + }, + { + "keyset": "sc_s", + "action": "settarget" + }, + { + "keyset": "Shift+sc_s", + "action": "settarget" + }, + { + "keyset": "Alt+sc_s", + "action": "settargetnoground" + }, + { + "keyset": "Shift+Alt+sc_s", + "action": "settargetnoground" + }, + { + "keyset": "Ctrl+sc_s", + "action": "canceltarget" + }, + { + "keyset": "sc_u", + "action": "unloadunits" + }, + { + "keyset": "Shift+sc_u", + "action": "unloadunits" + }, + { + "keyset": "sc_w", + "action": "resurrect" + }, + { + "keyset": "Shift+sc_w", + "action": "resurrect" + }, + { + "keyset": "sc_w", + "action": "capture" + }, + { + "keyset": "Shift+sc_w", + "action": "capture" + }, + { + "keyset": "sc_y", + "action": "wait" + }, + { + "keyset": "Shift+sc_y", + "action": "wait queued" + }, + { + "keyset": "sc_b,sc_b", + "action": "onoff 0" + }, + { + "keyset": "sc_b", + "action": "onoff 1" + }, + { + "keyset": "Shift+sc_b,Shift+sc_b", + "action": "onoff 0" + }, + { + "keyset": "Shift+sc_b", + "action": "onoff 1" + }, + { + "keyset": "sc_b,sc_b,sc_b", + "action": "trajectory_toggle 1" + }, + { + "keyset": "sc_b,sc_b", + "action": "trajectory_toggle 0" + }, + { + "keyset": "sc_b", + "action": "trajectory_toggle 2" + }, + { + "keyset": "Shift+sc_b,Shift+sc_b,Shift+sc_b", + "action": "trajectory_toggle 1" + }, + { + "keyset": "Shift+sc_b,Shift+sc_b", + "action": "trajectory_toggle 0" + }, + { + "keyset": "Shift+sc_b", + "action": "trajectory_toggle 2" + }, + { + "keyset": "sc_l,sc_l,sc_l", + "action": "firestate 1" + }, + { + "keyset": "sc_l,sc_l", + "action": "firestate 0" + }, + { + "keyset": "sc_l", + "action": "firestate 2" + }, + { + "keyset": "Shift+sc_l,Shift+sc_l,Shift+sc_l", + "action": "firestate 1" + }, + { + "keyset": "Shift+sc_l,Shift+sc_l", + "action": "firestate 0" + }, + { + "keyset": "Shift+sc_l", + "action": "firestate 2" + }, + { + "keyset": "sc_;,sc_;,sc_;", + "action": "movestate 1" + }, + { + "keyset": "sc_;,sc_;", + "action": "movestate 0" + }, + { + "keyset": "sc_;", + "action": "movestate 2" + }, + { + "keyset": "Shift+sc_;,Shift+sc_;,Shift+sc_;", + "action": "movestate 1" + }, + { + "keyset": "Shift+sc_;,Shift+sc_;", + "action": "movestate 0" + }, + { + "keyset": "Shift+sc_;", + "action": "movestate 2" + }, + { + "keyset": "sc_t,sc_t", + "action": "repeat 0" + }, + { + "keyset": "sc_t", + "action": "repeat 1" + }, + { + "keyset": "Shift+sc_t,Shift+sc_t", + "action": "repeat 0" + }, + { + "keyset": "Shift+sc_t", + "action": "repeat 1" + }, + { + "keyset": "Ctrl+sc_g,Ctrl+sc_g", + "action": "factoryguard 0" + }, + { + "keyset": "Ctrl+sc_g", + "action": "factoryguard 1" + }, + { + "keyset": "Alt+sc_o", + "action": "cameraflip" + }, + { + "keyset": "Ctrl+f7", + "action": "hideinterface" + }, + { + "keyset": "Any+f5", + "action": "lastmsgpos" + }, + { + "keyset": "Any+f6", + "action": "showpathtraversability" + }, + { + "keyset": "Any+f7", + "action": "showmetalmap" + }, + { + "keyset": "Any+f8", + "action": "showelevation" + }, + { + "keyset": "f10", + "action": "options" + }, + { + "keyset": "f11", + "action": "luaui selector" + }, + { + "keyset": "Any+f12", + "action": "screenshot png" + }, + { + "keyset": "Ctrl+sc_`", + "action": "group unset" + }, + { + "keyset": "Alt+sc_`", + "action": "remove_from_autogroup" + }, + { + "keyset": "sc_`,sc_`", + "action": "drawlabel" + }, + { + "keyset": "sc_`", + "action": "drawinmap" + }, + { + "keyset": "Ctrl+sc_e", + "action": "select AllMap++_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+tab", + "action": "select AllMap+_Builder_Idle+_ClearSelection_SelectOne+" + }, + { + "keyset": "Shift+tab", + "action": "selectcomm append" + }, + { + "keyset": "tab", + "action": "selectcomm focus" + }, + { + "keyset": "sc_q", + "action": "select Visible+_InPrevSel+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_q", + "action": "select PrevSelection++_ClearSelection_SelectPart_50+" + }, + { + "keyset": "Ctrl+sc_w", + "action": "select AllMap+_InPrevSel+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_r", + "action": "select AllMap+_Transport_Idle+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_y", + "action": "select Visible+_Waiting+_ClearSelection_SelectAll+" + }, + { + "keyset": "Alt+sc_q", + "action": "select PrevSelection+_Not_Building_Not_RelativeHealth_60+_ClearSelection_SelectAll+" + }, + { + "keyset": "numpad2", + "action": "moveback" + }, + { + "keyset": "numpad6", + "action": "moveright" + }, + { + "keyset": "numpad4", + "action": "moveleft" + }, + { + "keyset": "numpad8", + "action": "moveforward" + }, + { + "keyset": "numpad9", + "action": "moveup" + }, + { + "keyset": "numpad3", + "action": "movedown" + }, + { + "keyset": "numpad1", + "action": "movefast" + }, + { + "keyset": "backspace", + "action": "mutesound" + }, + { + "keyset": "numpad+", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_=", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_-", + "action": "snd_volume_decrease" + }, + { + "keyset": "numpad-", + "action": "snd_volume_decrease" + }, + { + "keyset": "Ctrl+F1", + "action": "set_camera_anchor 1" + }, + { + "keyset": "Ctrl+F2", + "action": "set_camera_anchor 2" + }, + { + "keyset": "Ctrl+F3", + "action": "set_camera_anchor 3" + }, + { + "keyset": "Ctrl+F4", + "action": "set_camera_anchor 4" + }, + { + "keyset": "F1", + "action": "focus_camera_anchor 1" + }, + { + "keyset": "F2", + "action": "focus_camera_anchor 2" + }, + { + "keyset": "F3", + "action": "focus_camera_anchor 3" + }, + { + "keyset": "F4", + "action": "focus_camera_anchor 4" + }, + { + "keyset": "Alt+sc_b", + "action": "blueprint_place" + }, + { + "keyset": "Alt+sc_t", + "action": "blueprint_create" + }, + { + "keyset": "Alt+sc_y", + "action": "blueprint_delete" + }, + { + "keyset": "Alt+sc_[", + "action": "blueprint_prev" + }, + { + "keyset": "Alt+sc_]", + "action": "blueprint_next" + }, + { + "keyset": "Alt+sc_g", + "action": "factoryqueuemode" + } + ] + }, + { + "name": "Grid (60% Keyboard)", + "description": "ui.keybinds.presets.grid60", + "fakeMeta": "space", + "binds": [ + { + "keyset": "esc", + "action": "select AllMap++_ClearSelection_SelectNum_0+" + }, + { + "keyset": "esc", + "action": "quitmessage" + }, + { + "keyset": "Shift+esc", + "action": "quitmenu" + }, + { + "keyset": "Ctrl+Shift+esc", + "action": "quitforce" + }, + { + "keyset": "Alt+Shift+esc", + "action": "reloadforce" + }, + { + "keyset": "Any+escape", + "action": "edit_escape" + }, + { + "keyset": "Any+pause", + "action": "pause" + }, + { + "keyset": "esc", + "action": "teamstatus_close" + }, + { + "keyset": "esc", + "action": "customgameinfo_close" + }, + { + "keyset": "esc", + "action": "buildmenu_pregame_deselect" + }, + { + "keyset": "Any+sc_z", + "action": "selectbox_same" + }, + { + "keyset": "Any+space", + "action": "selectbox_idle" + }, + { + "keyset": "Any+shift", + "action": "selectbox_append" + }, + { + "keyset": "Any+shift", + "action": "selectbox_any" + }, + { + "keyset": "Any+ctrl", + "action": "selectbox_deselect" + }, + { + "keyset": "Any+alt", + "action": "selectbox_mobile" + }, + { + "keyset": "Any+space", + "action": "selectloop" + }, + { + "keyset": "Any+ctrl", + "action": "selectloop_invert" + }, + { + "keyset": "Any+shift", + "action": "selectloop_add" + }, + { + "keyset": "Any+space", + "action": "buildsplit" + }, + { + "keyset": "Any+space", + "action": "commandinsert prepend_between" + }, + { + "keyset": "alt+sc_.", + "action": "attack_range_inc" + }, + { + "keyset": "alt+sc_comma", + "action": "attack_range_dec" + }, + { + "keyset": "Any+enter", + "action": "chat" + }, + { + "keyset": "Alt+ctrl+sc_a", + "action": "chatswitchally" + }, + { + "keyset": "Alt+ctrl+sc_s", + "action": "chatswitchspec" + }, + { + "keyset": "Any+tab", + "action": "edit_complete" + }, + { + "keyset": "Any+backspace", + "action": "edit_backspace" + }, + { + "keyset": "Any+delete", + "action": "edit_delete" + }, + { + "keyset": "Any+home", + "action": "edit_home" + }, + { + "keyset": "Alt+left", + "action": "edit_home" + }, + { + "keyset": "Any+end", + "action": "edit_end" + }, + { + "keyset": "Alt+right", + "action": "edit_end" + }, + { + "keyset": "Any+up", + "action": "edit_prev_line" + }, + { + "keyset": "Any+down", + "action": "edit_next_line" + }, + { + "keyset": "Any+left", + "action": "edit_prev_char" + }, + { + "keyset": "Any+right", + "action": "edit_next_char" + }, + { + "keyset": "Ctrl+left", + "action": "edit_prev_word" + }, + { + "keyset": "Ctrl+right", + "action": "edit_next_word" + }, + { + "keyset": "Any+enter", + "action": "edit_return" + }, + { + "keyset": "Ctrl+v", + "action": "pastetext" + }, + { + "keyset": "Any+up", + "action": "moveforward" + }, + { + "keyset": "Any+down", + "action": "moveback" + }, + { + "keyset": "Any+right", + "action": "moveright" + }, + { + "keyset": "Any+left", + "action": "moveleft" + }, + { + "keyset": "Any+pageup", + "action": "moveup" + }, + { + "keyset": "Any+pagedown", + "action": "movedown" + }, + { + "keyset": "Any+alt", + "action": "movereset" + }, + { + "keyset": "Any+alt", + "action": "moverotate" + }, + { + "keyset": "Any+ctrl", + "action": "movetilt" + }, + { + "keyset": "ctrl+sc_o", + "action": "fov_dec 5" + }, + { + "keyset": "ctrl+sc_p", + "action": "fov_inc 5" + }, + { + "keyset": "sc_numpad1", + "action": "fov_dec 5" + }, + { + "keyset": "sc_numpad7", + "action": "fov_inc 5" + }, + { + "keyset": "Meta+ctrl+tab", + "action": "pip1_copy" + }, + { + "keyset": "Meta+tab", + "action": "pip1_switch" + }, + { + "keyset": "Alt+sc_t", + "action": "pip1_track" + }, + { + "keyset": "Any+alt", + "action": "toggle_allied_upgrade" + }, + { + "keyset": "sc_z", + "action": "gridmenu_category 1" + }, + { + "keyset": "sc_x", + "action": "gridmenu_category 2" + }, + { + "keyset": "sc_c", + "action": "gridmenu_category 3" + }, + { + "keyset": "sc_v", + "action": "gridmenu_category 4" + }, + { + "keyset": "Shift+sc_z", + "action": "gridmenu_category 1" + }, + { + "keyset": "Shift+sc_x", + "action": "gridmenu_category 2" + }, + { + "keyset": "Shift+sc_c", + "action": "gridmenu_category 3" + }, + { + "keyset": "Shift+sc_v", + "action": "gridmenu_category 4" + }, + { + "keyset": "Any+sc_z", + "action": "gridmenu_key 1 1" + }, + { + "keyset": "Any+sc_x", + "action": "gridmenu_key 1 2" + }, + { + "keyset": "Any+sc_c", + "action": "gridmenu_key 1 3" + }, + { + "keyset": "Any+sc_v", + "action": "gridmenu_key 1 4" + }, + { + "keyset": "Any+sc_a", + "action": "gridmenu_key 2 1" + }, + { + "keyset": "Any+sc_s", + "action": "gridmenu_key 2 2" + }, + { + "keyset": "Any+sc_d", + "action": "gridmenu_key 2 3" + }, + { + "keyset": "Any+sc_f", + "action": "gridmenu_key 2 4" + }, + { + "keyset": "Any+sc_q", + "action": "gridmenu_key 3 1" + }, + { + "keyset": "Any+sc_w", + "action": "gridmenu_key 3 2" + }, + { + "keyset": "Any+sc_e", + "action": "gridmenu_key 3 3" + }, + { + "keyset": "Any+sc_r", + "action": "gridmenu_key 3 4" + }, + { + "keyset": "sc_b", + "action": "gridmenu_next_page" + }, + { + "keyset": "sc_.", + "action": "gridmenu_cycle_builder" + }, + { + "keyset": "sc_.", + "action": "cycleselected next" + }, + { + "keyset": "sc_comma", + "action": "cycleselected prev" + }, + { + "keyset": "1", + "action": "specteam 0" + }, + { + "keyset": "2", + "action": "specteam 1" + }, + { + "keyset": "3", + "action": "specteam 2" + }, + { + "keyset": "4", + "action": "specteam 3" + }, + { + "keyset": "5", + "action": "specteam 4" + }, + { + "keyset": "6", + "action": "specteam 5" + }, + { + "keyset": "7", + "action": "specteam 6" + }, + { + "keyset": "8", + "action": "specteam 7" + }, + { + "keyset": "9", + "action": "specteam 8" + }, + { + "keyset": "Alt+0", + "action": "add_to_autogroup 0" + }, + { + "keyset": "Alt+1", + "action": "add_to_autogroup 1" + }, + { + "keyset": "Alt+2", + "action": "add_to_autogroup 2" + }, + { + "keyset": "Alt+3", + "action": "add_to_autogroup 3" + }, + { + "keyset": "Alt+4", + "action": "add_to_autogroup 4" + }, + { + "keyset": "Alt+5", + "action": "add_to_autogroup 5" + }, + { + "keyset": "Alt+6", + "action": "add_to_autogroup 6" + }, + { + "keyset": "Alt+7", + "action": "add_to_autogroup 7" + }, + { + "keyset": "Alt+8", + "action": "add_to_autogroup 8" + }, + { + "keyset": "Alt+9", + "action": "add_to_autogroup 9" + }, + { + "keyset": "Shift+Alt+0", + "action": "load_autogroup_preset 0" + }, + { + "keyset": "Shift+Alt+1", + "action": "load_autogroup_preset 1" + }, + { + "keyset": "Shift+Alt+2", + "action": "load_autogroup_preset 2" + }, + { + "keyset": "Shift+Alt+3", + "action": "load_autogroup_preset 3" + }, + { + "keyset": "Shift+Alt+4", + "action": "load_autogroup_preset 4" + }, + { + "keyset": "Shift+Alt+5", + "action": "load_autogroup_preset 5" + }, + { + "keyset": "Shift+Alt+6", + "action": "load_autogroup_preset 6" + }, + { + "keyset": "Shift+Alt+7", + "action": "load_autogroup_preset 7" + }, + { + "keyset": "Shift+Alt+8", + "action": "load_autogroup_preset 8" + }, + { + "keyset": "Shift+Alt+9", + "action": "load_autogroup_preset 9" + }, + { + "keyset": "0,0", + "action": "group focus 0" + }, + { + "keyset": "1,1", + "action": "group focus 1" + }, + { + "keyset": "2,2", + "action": "group focus 2" + }, + { + "keyset": "3,3", + "action": "group focus 3" + }, + { + "keyset": "4,4", + "action": "group focus 4" + }, + { + "keyset": "5,5", + "action": "group focus 5" + }, + { + "keyset": "6,6", + "action": "group focus 6" + }, + { + "keyset": "7,7", + "action": "group focus 7" + }, + { + "keyset": "8,8", + "action": "group focus 8" + }, + { + "keyset": "9,9", + "action": "group focus 9" + }, + { + "keyset": "0", + "action": "group select 0" + }, + { + "keyset": "1", + "action": "group select 1" + }, + { + "keyset": "2", + "action": "group select 2" + }, + { + "keyset": "3", + "action": "group select 3" + }, + { + "keyset": "4", + "action": "group select 4" + }, + { + "keyset": "5", + "action": "group select 5" + }, + { + "keyset": "6", + "action": "group select 6" + }, + { + "keyset": "7", + "action": "group select 7" + }, + { + "keyset": "8", + "action": "group select 8" + }, + { + "keyset": "9", + "action": "group select 9" + }, + { + "keyset": "Ctrl+0", + "action": "group set 0" + }, + { + "keyset": "Ctrl+1", + "action": "group set 1" + }, + { + "keyset": "Ctrl+2", + "action": "group set 2" + }, + { + "keyset": "Ctrl+3", + "action": "group set 3" + }, + { + "keyset": "Ctrl+4", + "action": "group set 4" + }, + { + "keyset": "Ctrl+5", + "action": "group set 5" + }, + { + "keyset": "Ctrl+6", + "action": "group set 6" + }, + { + "keyset": "Ctrl+7", + "action": "group set 7" + }, + { + "keyset": "Ctrl+8", + "action": "group set 8" + }, + { + "keyset": "Ctrl+9", + "action": "group set 9" + }, + { + "keyset": "Shift+0", + "action": "group selectadd 0" + }, + { + "keyset": "Shift+1", + "action": "group selectadd 1" + }, + { + "keyset": "Shift+2", + "action": "group selectadd 2" + }, + { + "keyset": "Shift+3", + "action": "group selectadd 3" + }, + { + "keyset": "Shift+4", + "action": "group selectadd 4" + }, + { + "keyset": "Shift+5", + "action": "group selectadd 5" + }, + { + "keyset": "Shift+6", + "action": "group selectadd 6" + }, + { + "keyset": "Shift+7", + "action": "group selectadd 7" + }, + { + "keyset": "Shift+8", + "action": "group selectadd 8" + }, + { + "keyset": "Shift+9", + "action": "group selectadd 9" + }, + { + "keyset": "Ctrl+Shift+0", + "action": "group add 0" + }, + { + "keyset": "Ctrl+Shift+1", + "action": "group add 1" + }, + { + "keyset": "Ctrl+Shift+2", + "action": "group add 2" + }, + { + "keyset": "Ctrl+Shift+3", + "action": "group add 3" + }, + { + "keyset": "Ctrl+Shift+4", + "action": "group add 4" + }, + { + "keyset": "Ctrl+Shift+5", + "action": "group add 5" + }, + { + "keyset": "Ctrl+Shift+6", + "action": "group add 6" + }, + { + "keyset": "Ctrl+Shift+7", + "action": "group add 7" + }, + { + "keyset": "Ctrl+Shift+8", + "action": "group add 8" + }, + { + "keyset": "Ctrl+Shift+9", + "action": "group add 9" + }, + { + "keyset": "Ctrl+Alt+0", + "action": "group selecttoggle 0" + }, + { + "keyset": "Ctrl+Alt+1", + "action": "group selecttoggle 1" + }, + { + "keyset": "Ctrl+Alt+2", + "action": "group selecttoggle 2" + }, + { + "keyset": "Ctrl+Alt+3", + "action": "group selecttoggle 3" + }, + { + "keyset": "Ctrl+Alt+4", + "action": "group selecttoggle 4" + }, + { + "keyset": "Ctrl+Alt+5", + "action": "group selecttoggle 5" + }, + { + "keyset": "Ctrl+Alt+6", + "action": "group selecttoggle 6" + }, + { + "keyset": "Ctrl+Alt+7", + "action": "group selecttoggle 7" + }, + { + "keyset": "Ctrl+Alt+8", + "action": "group selecttoggle 8" + }, + { + "keyset": "Ctrl+Alt+9", + "action": "group selecttoggle 9" + }, + { + "keyset": "Alt+sc_=", + "action": "increasespeed" + }, + { + "keyset": "Alt+sc_-", + "action": "decreasespeed" + }, + { + "keyset": "Alt+numpad+", + "action": "increasespeed" + }, + { + "keyset": "Alt+numpad-", + "action": "decreasespeed" + }, + { + "keyset": "sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "Shift+sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Shift+sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Shift+Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "Shift+Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "sc_a", + "action": "attack" + }, + { + "keyset": "Shift+sc_a", + "action": "attack" + }, + { + "keyset": "Ctrl+sc_a", + "action": "areaattack" + }, + { + "keyset": "Ctrl+Shift+sc_a", + "action": "areaattack" + }, + { + "keyset": "Ctrl+sc_b", + "action": "selfd" + }, + { + "keyset": "Ctrl+Shift+sc_b", + "action": "selfd queued" + }, + { + "keyset": "sc_d", + "action": "manualfire" + }, + { + "keyset": "Shift+sc_d", + "action": "manualfire" + }, + { + "keyset": "sc_d", + "action": "manuallaunch" + }, + { + "keyset": "Shift+sc_d", + "action": "manuallaunch" + }, + { + "keyset": "sc_e", + "action": "reclaim" + }, + { + "keyset": "Shift+sc_e", + "action": "reclaim" + }, + { + "keyset": "sc_f", + "action": "fight" + }, + { + "keyset": "Shift+sc_f", + "action": "fight" + }, + { + "keyset": "Alt+sc_f", + "action": "chain force forcestart | say !cv forcestart" + }, + { + "keyset": "sc_g", + "action": "stopproduction" + }, + { + "keyset": "Shift+sc_g", + "action": "stopproduction" + }, + { + "keyset": "sc_g", + "action": "stop" + }, + { + "keyset": "Shift+sc_g", + "action": "stop" + }, + { + "keyset": "sc_h", + "action": "patrol" + }, + { + "keyset": "Shift+sc_h", + "action": "patrol" + }, + { + "keyset": "sc_i", + "action": "unit_stats" + }, + { + "keyset": "sc_j", + "action": "loadunits" + }, + { + "keyset": "Shift+sc_j", + "action": "loadunits" + }, + { + "keyset": "sc_k", + "action": "cloak" + }, + { + "keyset": "Shift+sc_k", + "action": "cloak" + }, + { + "keyset": "sc_k", + "action": "wantcloak" + }, + { + "keyset": "Any+sc_k", + "action": "wantcloak" + }, + { + "keyset": "sc_m", + "action": "restore" + }, + { + "keyset": "Shift+sc_m", + "action": "restore" + }, + { + "keyset": "sc_n", + "action": "command_skip_current" + }, + { + "keyset": "Ctrl+sc_n", + "action": "command_cancel_last" + }, + { + "keyset": "sc_p", + "action": "gatherwait" + }, + { + "keyset": "Shift+sc_p", + "action": "gatherwait" + }, + { + "keyset": "sc_o", + "action": "guard" + }, + { + "keyset": "Shift+sc_o", + "action": "guard" + }, + { + "keyset": "sc_r", + "action": "repair" + }, + { + "keyset": "Shift+sc_r", + "action": "repair" + }, + { + "keyset": "sc_s", + "action": "settarget" + }, + { + "keyset": "Shift+sc_s", + "action": "settarget" + }, + { + "keyset": "Alt+sc_s", + "action": "settargetnoground" + }, + { + "keyset": "Shift+Alt+sc_s", + "action": "settargetnoground" + }, + { + "keyset": "Ctrl+sc_s", + "action": "canceltarget" + }, + { + "keyset": "sc_u", + "action": "unloadunits" + }, + { + "keyset": "Shift+sc_u", + "action": "unloadunits" + }, + { + "keyset": "sc_w", + "action": "resurrect" + }, + { + "keyset": "Shift+sc_w", + "action": "resurrect" + }, + { + "keyset": "sc_w", + "action": "capture" + }, + { + "keyset": "Shift+sc_w", + "action": "capture" + }, + { + "keyset": "sc_y", + "action": "wait" + }, + { + "keyset": "Shift+sc_y", + "action": "wait queued" + }, + { + "keyset": "sc_b,sc_b", + "action": "onoff 0" + }, + { + "keyset": "sc_b", + "action": "onoff 1" + }, + { + "keyset": "Shift+sc_b,Shift+sc_b", + "action": "onoff 0" + }, + { + "keyset": "Shift+sc_b", + "action": "onoff 1" + }, + { + "keyset": "sc_b,sc_b,sc_b", + "action": "trajectory_toggle 1" + }, + { + "keyset": "sc_b,sc_b", + "action": "trajectory_toggle 0" + }, + { + "keyset": "sc_b", + "action": "trajectory_toggle 2" + }, + { + "keyset": "Shift+sc_b,Shift+sc_b,Shift+sc_b", + "action": "trajectory_toggle 1" + }, + { + "keyset": "Shift+sc_b,Shift+sc_b", + "action": "trajectory_toggle 0" + }, + { + "keyset": "Shift+sc_b", + "action": "trajectory_toggle 2" + }, + { + "keyset": "sc_l,sc_l,sc_l", + "action": "firestate 1" + }, + { + "keyset": "sc_l,sc_l", + "action": "firestate 0" + }, + { + "keyset": "sc_l", + "action": "firestate 2" + }, + { + "keyset": "Shift+sc_l,Shift+sc_l,Shift+sc_l", + "action": "firestate 1" + }, + { + "keyset": "Shift+sc_l,Shift+sc_l", + "action": "firestate 0" + }, + { + "keyset": "Shift+sc_l", + "action": "firestate 2" + }, + { + "keyset": "sc_;,sc_;,sc_;", + "action": "movestate 1" + }, + { + "keyset": "sc_;,sc_;", + "action": "movestate 0" + }, + { + "keyset": "sc_;", + "action": "movestate 2" + }, + { + "keyset": "Shift+sc_;,Shift+sc_;,Shift+sc_;", + "action": "movestate 1" + }, + { + "keyset": "Shift+sc_;,Shift+sc_;", + "action": "movestate 0" + }, + { + "keyset": "Shift+sc_;", + "action": "movestate 2" + }, + { + "keyset": "sc_t,sc_t", + "action": "repeat 0" + }, + { + "keyset": "sc_t", + "action": "repeat 1" + }, + { + "keyset": "Shift+sc_t,Shift+sc_t", + "action": "repeat 0" + }, + { + "keyset": "Shift+sc_t", + "action": "repeat 1" + }, + { + "keyset": "Ctrl+sc_g,Ctrl+sc_g", + "action": "factoryguard 0" + }, + { + "keyset": "Ctrl+sc_g", + "action": "factoryguard 1" + }, + { + "keyset": "Alt+sc_o", + "action": "cameraflip" + }, + { + "keyset": "Ctrl+meta+7", + "action": "hideinterface" + }, + { + "keyset": "meta+5", + "action": "lastmsgpos" + }, + { + "keyset": "meta+6", + "action": "showpathtraversability" + }, + { + "keyset": "meta+7", + "action": "showmetalmap" + }, + { + "keyset": "meta+8", + "action": "showelevation" + }, + { + "keyset": "f10", + "action": "options" + }, + { + "keyset": "f11", + "action": "luaui selector" + }, + { + "keyset": "Any+f12", + "action": "screenshot png" + }, + { + "keyset": "Ctrl+meta+sc_q", + "action": "group unset" + }, + { + "keyset": "Alt+sc_q", + "action": "remove_from_autogroup" + }, + { + "keyset": "meta+sc_q,meta+sc_q", + "action": "drawlabel" + }, + { + "keyset": "meta+sc_q", + "action": "drawinmap" + }, + { + "keyset": "Ctrl+sc_e", + "action": "select AllMap++_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+tab", + "action": "select AllMap+_Builder_Idle+_ClearSelection_SelectOne+" + }, + { + "keyset": "Shift+tab", + "action": "selectcomm append" + }, + { + "keyset": "tab", + "action": "selectcomm focus" + }, + { + "keyset": "sc_q", + "action": "select Visible+_InPrevSel+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_q", + "action": "select PrevSelection++_ClearSelection_SelectPart_50+" + }, + { + "keyset": "Ctrl+sc_w", + "action": "select AllMap+_InPrevSel+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_r", + "action": "select AllMap+_Transport_Idle+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_y", + "action": "select Visible+_Waiting+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+Alt+sc_q", + "action": "select PrevSelection+_Not_Building_Not_RelativeHealth_60+_ClearSelection_SelectAll+" + }, + { + "keyset": "numpad2", + "action": "moveback" + }, + { + "keyset": "numpad6", + "action": "moveright" + }, + { + "keyset": "numpad4", + "action": "moveleft" + }, + { + "keyset": "numpad8", + "action": "moveforward" + }, + { + "keyset": "numpad9", + "action": "moveup" + }, + { + "keyset": "numpad3", + "action": "movedown" + }, + { + "keyset": "numpad1", + "action": "movefast" + }, + { + "keyset": "backspace", + "action": "mutesound" + }, + { + "keyset": "numpad+", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_=", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_-", + "action": "snd_volume_decrease" + }, + { + "keyset": "numpad-", + "action": "snd_volume_decrease" + }, + { + "keyset": "Ctrl+meta+1", + "action": "set_camera_anchor 1" + }, + { + "keyset": "Ctrl+meta+2", + "action": "set_camera_anchor 2" + }, + { + "keyset": "Ctrl+meta+3", + "action": "set_camera_anchor 3" + }, + { + "keyset": "Ctrl+meta+4", + "action": "set_camera_anchor 4" + }, + { + "keyset": "meta+1", + "action": "focus_camera_anchor 1" + }, + { + "keyset": "meta+2", + "action": "focus_camera_anchor 2" + }, + { + "keyset": "meta+3", + "action": "focus_camera_anchor 3" + }, + { + "keyset": "meta+4", + "action": "focus_camera_anchor 4" + }, + { + "keyset": "Alt+sc_b", + "action": "blueprint_place" + }, + { + "keyset": "Alt+sc_t", + "action": "blueprint_create" + }, + { + "keyset": "Alt+sc_y", + "action": "blueprint_delete" + }, + { + "keyset": "Alt+sc_[", + "action": "blueprint_prev" + }, + { + "keyset": "Alt+sc_]", + "action": "blueprint_next" + } + ] + }, + { + "name": "Legacy", + "description": "ui.keybinds.presets.legacy", + "fakeMeta": "space", + "binds": [ + { + "keyset": "esc", + "action": "select AllMap++_ClearSelection_SelectNum_0+" + }, + { + "keyset": "esc", + "action": "quitmessage" + }, + { + "keyset": "Shift+esc", + "action": "quitmenu" + }, + { + "keyset": "Ctrl+Shift+esc", + "action": "quitforce" + }, + { + "keyset": "Alt+Shift+esc", + "action": "reloadforce" + }, + { + "keyset": "Any+escape", + "action": "edit_escape" + }, + { + "keyset": "Any+pause", + "action": "pause" + }, + { + "keyset": "esc", + "action": "teamstatus_close" + }, + { + "keyset": "esc", + "action": "customgameinfo_close" + }, + { + "keyset": "esc", + "action": "buildmenu_pregame_deselect" + }, + { + "keyset": "Any+sc_z", + "action": "selectbox_same" + }, + { + "keyset": "Any+space", + "action": "selectbox_idle" + }, + { + "keyset": "Any+shift", + "action": "selectbox_append" + }, + { + "keyset": "Any+shift", + "action": "selectbox_any" + }, + { + "keyset": "Any+ctrl", + "action": "selectbox_deselect" + }, + { + "keyset": "Any+alt", + "action": "selectbox_mobile" + }, + { + "keyset": "Any+space", + "action": "selectloop" + }, + { + "keyset": "Any+ctrl", + "action": "selectloop_invert" + }, + { + "keyset": "Any+shift", + "action": "selectloop_add" + }, + { + "keyset": "Any+space", + "action": "buildsplit" + }, + { + "keyset": "Any+space", + "action": "commandinsert prepend_between" + }, + { + "keyset": "alt+sc_.", + "action": "attack_range_inc" + }, + { + "keyset": "alt+sc_comma", + "action": "attack_range_dec" + }, + { + "keyset": "Any+enter", + "action": "chat" + }, + { + "keyset": "Alt+ctrl+sc_a", + "action": "chatswitchally" + }, + { + "keyset": "Alt+ctrl+sc_s", + "action": "chatswitchspec" + }, + { + "keyset": "Any+tab", + "action": "edit_complete" + }, + { + "keyset": "Any+backspace", + "action": "edit_backspace" + }, + { + "keyset": "Any+delete", + "action": "edit_delete" + }, + { + "keyset": "Any+home", + "action": "edit_home" + }, + { + "keyset": "Alt+left", + "action": "edit_home" + }, + { + "keyset": "Any+end", + "action": "edit_end" + }, + { + "keyset": "Alt+right", + "action": "edit_end" + }, + { + "keyset": "Any+up", + "action": "edit_prev_line" + }, + { + "keyset": "Any+down", + "action": "edit_next_line" + }, + { + "keyset": "Any+left", + "action": "edit_prev_char" + }, + { + "keyset": "Any+right", + "action": "edit_next_char" + }, + { + "keyset": "Ctrl+left", + "action": "edit_prev_word" + }, + { + "keyset": "Ctrl+right", + "action": "edit_next_word" + }, + { + "keyset": "Any+enter", + "action": "edit_return" + }, + { + "keyset": "Ctrl+v", + "action": "pastetext" + }, + { + "keyset": "Any+up", + "action": "moveforward" + }, + { + "keyset": "Any+down", + "action": "moveback" + }, + { + "keyset": "Any+right", + "action": "moveright" + }, + { + "keyset": "Any+left", + "action": "moveleft" + }, + { + "keyset": "Any+pageup", + "action": "moveup" + }, + { + "keyset": "Any+pagedown", + "action": "movedown" + }, + { + "keyset": "Any+alt", + "action": "movereset" + }, + { + "keyset": "Any+alt", + "action": "moverotate" + }, + { + "keyset": "Any+ctrl", + "action": "movetilt" + }, + { + "keyset": "ctrl+sc_o", + "action": "fov_dec 5" + }, + { + "keyset": "ctrl+sc_p", + "action": "fov_inc 5" + }, + { + "keyset": "sc_numpad1", + "action": "fov_dec 5" + }, + { + "keyset": "sc_numpad7", + "action": "fov_inc 5" + }, + { + "keyset": "Meta+ctrl+tab", + "action": "pip1_copy" + }, + { + "keyset": "Meta+tab", + "action": "pip1_switch" + }, + { + "keyset": "Alt+sc_t", + "action": "pip1_track" + }, + { + "keyset": "Any+alt", + "action": "toggle_allied_upgrade" + }, + { + "keyset": "1", + "action": "specteam 0" + }, + { + "keyset": "2", + "action": "specteam 1" + }, + { + "keyset": "3", + "action": "specteam 2" + }, + { + "keyset": "4", + "action": "specteam 3" + }, + { + "keyset": "5", + "action": "specteam 4" + }, + { + "keyset": "6", + "action": "specteam 5" + }, + { + "keyset": "7", + "action": "specteam 6" + }, + { + "keyset": "8", + "action": "specteam 7" + }, + { + "keyset": "9", + "action": "specteam 8" + }, + { + "keyset": "Alt+0", + "action": "add_to_autogroup 0" + }, + { + "keyset": "Alt+1", + "action": "add_to_autogroup 1" + }, + { + "keyset": "Alt+2", + "action": "add_to_autogroup 2" + }, + { + "keyset": "Alt+3", + "action": "add_to_autogroup 3" + }, + { + "keyset": "Alt+4", + "action": "add_to_autogroup 4" + }, + { + "keyset": "Alt+5", + "action": "add_to_autogroup 5" + }, + { + "keyset": "Alt+6", + "action": "add_to_autogroup 6" + }, + { + "keyset": "Alt+7", + "action": "add_to_autogroup 7" + }, + { + "keyset": "Alt+8", + "action": "add_to_autogroup 8" + }, + { + "keyset": "Alt+9", + "action": "add_to_autogroup 9" + }, + { + "keyset": "Shift+Alt+0", + "action": "load_autogroup_preset 0" + }, + { + "keyset": "Shift+Alt+1", + "action": "load_autogroup_preset 1" + }, + { + "keyset": "Shift+Alt+2", + "action": "load_autogroup_preset 2" + }, + { + "keyset": "Shift+Alt+3", + "action": "load_autogroup_preset 3" + }, + { + "keyset": "Shift+Alt+4", + "action": "load_autogroup_preset 4" + }, + { + "keyset": "Shift+Alt+5", + "action": "load_autogroup_preset 5" + }, + { + "keyset": "Shift+Alt+6", + "action": "load_autogroup_preset 6" + }, + { + "keyset": "Shift+Alt+7", + "action": "load_autogroup_preset 7" + }, + { + "keyset": "Shift+Alt+8", + "action": "load_autogroup_preset 8" + }, + { + "keyset": "Shift+Alt+9", + "action": "load_autogroup_preset 9" + }, + { + "keyset": "0,0", + "action": "group focus 0" + }, + { + "keyset": "1,1", + "action": "group focus 1" + }, + { + "keyset": "2,2", + "action": "group focus 2" + }, + { + "keyset": "3,3", + "action": "group focus 3" + }, + { + "keyset": "4,4", + "action": "group focus 4" + }, + { + "keyset": "5,5", + "action": "group focus 5" + }, + { + "keyset": "6,6", + "action": "group focus 6" + }, + { + "keyset": "7,7", + "action": "group focus 7" + }, + { + "keyset": "8,8", + "action": "group focus 8" + }, + { + "keyset": "9,9", + "action": "group focus 9" + }, + { + "keyset": "0", + "action": "group select 0" + }, + { + "keyset": "1", + "action": "group select 1" + }, + { + "keyset": "2", + "action": "group select 2" + }, + { + "keyset": "3", + "action": "group select 3" + }, + { + "keyset": "4", + "action": "group select 4" + }, + { + "keyset": "5", + "action": "group select 5" + }, + { + "keyset": "6", + "action": "group select 6" + }, + { + "keyset": "7", + "action": "group select 7" + }, + { + "keyset": "8", + "action": "group select 8" + }, + { + "keyset": "9", + "action": "group select 9" + }, + { + "keyset": "Ctrl+0", + "action": "group set 0" + }, + { + "keyset": "Ctrl+1", + "action": "group set 1" + }, + { + "keyset": "Ctrl+2", + "action": "group set 2" + }, + { + "keyset": "Ctrl+3", + "action": "group set 3" + }, + { + "keyset": "Ctrl+4", + "action": "group set 4" + }, + { + "keyset": "Ctrl+5", + "action": "group set 5" + }, + { + "keyset": "Ctrl+6", + "action": "group set 6" + }, + { + "keyset": "Ctrl+7", + "action": "group set 7" + }, + { + "keyset": "Ctrl+8", + "action": "group set 8" + }, + { + "keyset": "Ctrl+9", + "action": "group set 9" + }, + { + "keyset": "Shift+0", + "action": "group selectadd 0" + }, + { + "keyset": "Shift+1", + "action": "group selectadd 1" + }, + { + "keyset": "Shift+2", + "action": "group selectadd 2" + }, + { + "keyset": "Shift+3", + "action": "group selectadd 3" + }, + { + "keyset": "Shift+4", + "action": "group selectadd 4" + }, + { + "keyset": "Shift+5", + "action": "group selectadd 5" + }, + { + "keyset": "Shift+6", + "action": "group selectadd 6" + }, + { + "keyset": "Shift+7", + "action": "group selectadd 7" + }, + { + "keyset": "Shift+8", + "action": "group selectadd 8" + }, + { + "keyset": "Shift+9", + "action": "group selectadd 9" + }, + { + "keyset": "Ctrl+Shift+0", + "action": "group add 0" + }, + { + "keyset": "Ctrl+Shift+1", + "action": "group add 1" + }, + { + "keyset": "Ctrl+Shift+2", + "action": "group add 2" + }, + { + "keyset": "Ctrl+Shift+3", + "action": "group add 3" + }, + { + "keyset": "Ctrl+Shift+4", + "action": "group add 4" + }, + { + "keyset": "Ctrl+Shift+5", + "action": "group add 5" + }, + { + "keyset": "Ctrl+Shift+6", + "action": "group add 6" + }, + { + "keyset": "Ctrl+Shift+7", + "action": "group add 7" + }, + { + "keyset": "Ctrl+Shift+8", + "action": "group add 8" + }, + { + "keyset": "Ctrl+Shift+9", + "action": "group add 9" + }, + { + "keyset": "Ctrl+Alt+0", + "action": "group selecttoggle 0" + }, + { + "keyset": "Ctrl+Alt+1", + "action": "group selecttoggle 1" + }, + { + "keyset": "Ctrl+Alt+2", + "action": "group selecttoggle 2" + }, + { + "keyset": "Ctrl+Alt+3", + "action": "group selecttoggle 3" + }, + { + "keyset": "Ctrl+Alt+4", + "action": "group selecttoggle 4" + }, + { + "keyset": "Ctrl+Alt+5", + "action": "group selecttoggle 5" + }, + { + "keyset": "Ctrl+Alt+6", + "action": "group selecttoggle 6" + }, + { + "keyset": "Ctrl+Alt+7", + "action": "group selecttoggle 7" + }, + { + "keyset": "Ctrl+Alt+8", + "action": "group selecttoggle 8" + }, + { + "keyset": "Ctrl+Alt+9", + "action": "group selecttoggle 9" + }, + { + "keyset": "meta+alt+0", + "action": "factory_preset save 0" + }, + { + "keyset": "meta+alt+1", + "action": "factory_preset save 1" + }, + { + "keyset": "meta+alt+2", + "action": "factory_preset save 2" + }, + { + "keyset": "meta+alt+3", + "action": "factory_preset save 3" + }, + { + "keyset": "meta+alt+4", + "action": "factory_preset save 4" + }, + { + "keyset": "meta+alt+5", + "action": "factory_preset save 5" + }, + { + "keyset": "meta+alt+6", + "action": "factory_preset save 6" + }, + { + "keyset": "meta+alt+7", + "action": "factory_preset save 7" + }, + { + "keyset": "meta+alt+8", + "action": "factory_preset save 8" + }, + { + "keyset": "meta+alt+9", + "action": "factory_preset save 9" + }, + { + "keyset": "meta+0", + "action": "factory_preset load 0" + }, + { + "keyset": "meta+1", + "action": "factory_preset load 1" + }, + { + "keyset": "meta+2", + "action": "factory_preset load 2" + }, + { + "keyset": "meta+3", + "action": "factory_preset load 3" + }, + { + "keyset": "meta+4", + "action": "factory_preset load 4" + }, + { + "keyset": "meta+5", + "action": "factory_preset load 5" + }, + { + "keyset": "meta+6", + "action": "factory_preset load 6" + }, + { + "keyset": "meta+7", + "action": "factory_preset load 7" + }, + { + "keyset": "meta+8", + "action": "factory_preset load 8" + }, + { + "keyset": "meta+9", + "action": "factory_preset load 9" + }, + { + "keyset": "any+sc_space", + "action": "factory_preset_show" + }, + { + "keyset": "Shift+backspace", + "action": "togglecammode" + }, + { + "keyset": "Ctrl+backspace", + "action": "togglecammode" + }, + { + "keyset": "Any+tab", + "action": "toggleoverview" + }, + { + "keyset": "Alt+sc_=", + "action": "increasespeed" + }, + { + "keyset": "Alt+sc_-", + "action": "decreasespeed" + }, + { + "keyset": "Alt+numpad+", + "action": "increasespeed" + }, + { + "keyset": "Alt+numpad-", + "action": "decreasespeed" + }, + { + "keyset": "sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "Shift+sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Shift+sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Shift+Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "Shift+Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "sc_a", + "action": "attack" + }, + { + "keyset": "Shift+sc_a", + "action": "attack" + }, + { + "keyset": "Alt+sc_a", + "action": "areaattack" + }, + { + "keyset": "Alt+Shift+sc_a", + "action": "areaattack" + }, + { + "keyset": "sc_d", + "action": "manualfire" + }, + { + "keyset": "Shift+sc_d", + "action": "manualfire" + }, + { + "keyset": "sc_d", + "action": "manuallaunch" + }, + { + "keyset": "Shift+sc_d", + "action": "manuallaunch" + }, + { + "keyset": "Ctrl+sc_d", + "action": "selfd" + }, + { + "keyset": "Ctrl+Shift+sc_d", + "action": "selfd queued" + }, + { + "keyset": "sc_e", + "action": "reclaim" + }, + { + "keyset": "Shift+sc_e", + "action": "reclaim" + }, + { + "keyset": "sc_f", + "action": "fight" + }, + { + "keyset": "Shift+sc_f", + "action": "fight" + }, + { + "keyset": "Alt+sc_f", + "action": "chain force forcestart | say !cv forcestart" + }, + { + "keyset": "sc_g", + "action": "guard" + }, + { + "keyset": "Shift+sc_g", + "action": "guard" + }, + { + "keyset": "sc_j", + "action": "canceltarget" + }, + { + "keyset": "sc_k", + "action": "cloak" + }, + { + "keyset": "Shift+sc_k", + "action": "cloak" + }, + { + "keyset": "sc_k", + "action": "wantcloak" + }, + { + "keyset": "Any+sc_k", + "action": "wantcloak" + }, + { + "keyset": "sc_l", + "action": "loadunits" + }, + { + "keyset": "Shift+sc_l", + "action": "loadunits" + }, + { + "keyset": "sc_m", + "action": "move" + }, + { + "keyset": "Shift+sc_m", + "action": "move" + }, + { + "keyset": "sc_n", + "action": "command_skip_current" + }, + { + "keyset": "Ctrl+sc_n", + "action": "command_cancel_last" + }, + { + "keyset": "sc_p", + "action": "patrol" + }, + { + "keyset": "Shift+sc_p", + "action": "patrol" + }, + { + "keyset": "sc_q,sc_q", + "action": "drawlabel" + }, + { + "keyset": "sc_q", + "action": "drawinmap" + }, + { + "keyset": "sc_r", + "action": "repair" + }, + { + "keyset": "Shift+sc_r", + "action": "repair" + }, + { + "keyset": "Ctrl+sc_r", + "action": "resurrect" + }, + { + "keyset": "sc_s", + "action": "stop" + }, + { + "keyset": "Shift+sc_s", + "action": "stop" + }, + { + "keyset": "Ctrl+sc_s", + "action": "stopproduction" + }, + { + "keyset": "sc_u", + "action": "unloadunits" + }, + { + "keyset": "Shift+sc_u", + "action": "unloadunits" + }, + { + "keyset": "sc_w", + "action": "wait" + }, + { + "keyset": "Shift+sc_w", + "action": "wait queued" + }, + { + "keyset": "sc_x", + "action": "onoff" + }, + { + "keyset": "Shift+sc_x", + "action": "onoff" + }, + { + "keyset": "Any+sc_l", + "action": "togglelos" + }, + { + "keyset": "Ctrl+sc_t", + "action": "trackmode" + }, + { + "keyset": "Any+sc_t", + "action": "track" + }, + { + "keyset": "Any+ctrl", + "action": "moveslow" + }, + { + "keyset": "Any+shift", + "action": "movefast" + }, + { + "keyset": "Ctrl+f1", + "action": "viewfps" + }, + { + "keyset": "Ctrl+f2", + "action": "viewta" + }, + { + "keyset": "Ctrl+f3", + "action": "viewspring" + }, + { + "keyset": "Ctrl+f4", + "action": "viewrot" + }, + { + "keyset": "Ctrl+f5", + "action": "viewfree" + }, + { + "keyset": "Any+f1", + "action": "showelevation" + }, + { + "keyset": "Any+f2", + "action": "showpathtraversability" + }, + { + "keyset": "Any+f3", + "action": "lastmsgpos" + }, + { + "keyset": "Any+f4", + "action": "showmetalmap" + }, + { + "keyset": "Any+f5", + "action": "hideinterface" + }, + { + "keyset": "Any+f6", + "action": "mutesound" + }, + { + "keyset": "Any+f7", + "action": "dynamicsky" + }, + { + "keyset": "f10", + "action": "options" + }, + { + "keyset": "f11", + "action": "luaui selector" + }, + { + "keyset": "Any+f12", + "action": "screenshot png" + }, + { + "keyset": "Ctrl+Shift+f8", + "action": "savegame" + }, + { + "keyset": "sc_`,sc_`", + "action": "drawlabel" + }, + { + "keyset": "sc_`", + "action": "drawinmap" + }, + { + "keyset": "Ctrl+sc_a", + "action": "select AllMap++_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_b", + "action": "select AllMap+_Builder_Idle+_ClearSelection_SelectOne+" + }, + { + "keyset": "Ctrl+sc_c", + "action": "selectcomm focus" + }, + { + "keyset": "Ctrl+sc_v", + "action": "select AllMap+_Not_Builder_InPrevSel_Not_InHotkeyGroup+_SelectAll+" + }, + { + "keyset": "Ctrl+sc_w", + "action": "select AllMap+_Not_Aircraft_Weapons+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_x", + "action": "select AllMap+_InPrevSel_Not_InHotkeyGroup+_SelectAll+" + }, + { + "keyset": "Ctrl+sc_z", + "action": "select AllMap+_InPrevSel+_ClearSelection_SelectAll+" + }, + { + "keyset": "sc_z", + "action": "buildunit_armmex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armmex" + }, + { + "keyset": "sc_z", + "action": "buildunit_armamex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armamex" + }, + { + "keyset": "sc_z", + "action": "buildunit_cormex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_cormex" + }, + { + "keyset": "sc_z", + "action": "buildunit_legmex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_legmex" + }, + { + "keyset": "sc_z", + "action": "buildunit_legmext15" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_legmext15" + }, + { + "keyset": "sc_z", + "action": "buildunit_corexp" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_corexp" + }, + { + "keyset": "sc_z", + "action": "buildunit_armmoho" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armmoho" + }, + { + "keyset": "sc_z", + "action": "buildunit_cormoho" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_cormoho" + }, + { + "keyset": "sc_z", + "action": "buildunit_legmoho" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_legmoho" + }, + { + "keyset": "sc_z", + "action": "buildunit_cormexp" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_cormexp" + }, + { + "keyset": "sc_z", + "action": "buildunit_coruwmme" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_coruwmme" + }, + { + "keyset": "sc_z", + "action": "buildunit_armuwmme" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armuwmme" + }, + { + "keyset": "sc_z", + "action": "areamex" + }, + { + "keyset": "Shift+sc_z", + "action": "areamex" + }, + { + "keyset": "Ctrl+Alt+sc_z", + "action": "areamex" + }, + { + "keyset": "sc_x", + "action": "buildunit_armsolar" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armsolar" + }, + { + "keyset": "sc_x", + "action": "buildunit_armwin" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armwin" + }, + { + "keyset": "sc_x", + "action": "buildunit_corsolar" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_corsolar" + }, + { + "keyset": "sc_x", + "action": "buildunit_corwin" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_corwin" + }, + { + "keyset": "sc_x", + "action": "buildunit_legsolar" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legsolar" + }, + { + "keyset": "sc_x", + "action": "buildunit_legwin" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legwin" + }, + { + "keyset": "sc_x", + "action": "buildunit_armadvsol" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armadvsol" + }, + { + "keyset": "sc_x", + "action": "buildunit_coradvsol" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_coradvsol" + }, + { + "keyset": "sc_x", + "action": "buildunit_legadvsol" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legadvsol" + }, + { + "keyset": "sc_x", + "action": "buildunit_armfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_armmmkr" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armmmkr" + }, + { + "keyset": "sc_x", + "action": "buildunit_corfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_corfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_cormmkr" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_cormmkr" + }, + { + "keyset": "sc_x", + "action": "buildunit_legfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_legadveconv" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legadveconv" + }, + { + "keyset": "sc_x", + "action": "buildunit_armtide" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armtide" + }, + { + "keyset": "sc_x", + "action": "buildunit_cortide" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_cortide" + }, + { + "keyset": "sc_x", + "action": "buildunit_legtide" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legtide" + }, + { + "keyset": "sc_x", + "action": "buildunit_armuwfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armuwfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_coruwfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_coruwfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_armuwmmm" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armuwmmm" + }, + { + "keyset": "sc_x", + "action": "buildunit_coruwmmm" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_coruwmmm" + }, + { + "keyset": "sc_c", + "action": "buildunit_armllt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armllt" + }, + { + "keyset": "sc_c", + "action": "buildunit_armrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_corllt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corllt" + }, + { + "keyset": "sc_c", + "action": "buildunit_leglht" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_leglht" + }, + { + "keyset": "sc_c", + "action": "buildunit_corrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_legrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_corrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corrl" + }, + { + "keyset": "sc_c", + "action": "buildunit_legrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legrl" + }, + { + "keyset": "sc_c", + "action": "buildunit_armrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armrl" + }, + { + "keyset": "sc_c", + "action": "buildunit_armpb" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armpb" + }, + { + "keyset": "sc_c", + "action": "buildunit_armflak" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armflak" + }, + { + "keyset": "sc_c", + "action": "buildunit_corvipe" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corvipe" + }, + { + "keyset": "sc_c", + "action": "buildunit_corflak" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corflak" + }, + { + "keyset": "sc_c", + "action": "buildunit_legapopupdef" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legapopupdef" + }, + { + "keyset": "sc_c", + "action": "buildunit_legflak" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legflak" + }, + { + "keyset": "sc_c", + "action": "buildunit_armgplat" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armgplat" + }, + { + "keyset": "sc_c", + "action": "buildunit_corgplat" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corgplat" + }, + { + "keyset": "sc_c", + "action": "buildunit_armtl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armtl" + }, + { + "keyset": "sc_c", + "action": "buildunit_cortl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_cortl" + }, + { + "keyset": "sc_c", + "action": "buildunit_legtl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legtl" + }, + { + "keyset": "sc_c", + "action": "buildunit_armsonar" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armsonar" + }, + { + "keyset": "sc_c", + "action": "buildunit_corsonar" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corsonar" + }, + { + "keyset": "sc_c", + "action": "buildunit_armfrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armfrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_corfrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corfrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_legfrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legfrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_armfrt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armfrt" + }, + { + "keyset": "sc_c", + "action": "buildunit_corfrt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corfrt" + }, + { + "keyset": "sc_c", + "action": "buildunit_legfrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legfrl" + }, + { + "keyset": "sc_v", + "action": "buildunit_armnanotc" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armnanotc" + }, + { + "keyset": "sc_v", + "action": "buildunit_armnanotcplat" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armnanotcplat" + }, + { + "keyset": "sc_v", + "action": "buildunit_cornanotcplat" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_cornanotcplat" + }, + { + "keyset": "sc_v", + "action": "buildunit_armlab" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armlab" + }, + { + "keyset": "sc_v", + "action": "buildunit_armvp" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armvp" + }, + { + "keyset": "sc_v", + "action": "buildunit_armap" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armap" + }, + { + "keyset": "sc_v", + "action": "buildunit_cornanotc" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_cornanotc" + }, + { + "keyset": "sc_v", + "action": "buildunit_corlab" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corlab" + }, + { + "keyset": "sc_v", + "action": "buildunit_corvp" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corvp" + }, + { + "keyset": "sc_v", + "action": "buildunit_corap" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corap" + }, + { + "keyset": "sc_v", + "action": "buildunit_legnanotc" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legnanotc" + }, + { + "keyset": "sc_v", + "action": "buildunit_leglab" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_leglab" + }, + { + "keyset": "sc_v", + "action": "buildunit_legvp" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legvp" + }, + { + "keyset": "sc_v", + "action": "buildunit_legap" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legap" + }, + { + "keyset": "sc_v", + "action": "buildunit_armsy" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armsy" + }, + { + "keyset": "sc_v", + "action": "buildunit_corsy" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corsy" + }, + { + "keyset": "sc_v", + "action": "buildunit_legsy" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legsy" + }, + { + "keyset": "numpad2", + "action": "moveback" + }, + { + "keyset": "numpad6", + "action": "moveright" + }, + { + "keyset": "numpad4", + "action": "moveleft" + }, + { + "keyset": "numpad8", + "action": "moveforward" + }, + { + "keyset": "numpad9", + "action": "moveup" + }, + { + "keyset": "numpad3", + "action": "movedown" + }, + { + "keyset": "numpad1", + "action": "movefast" + }, + { + "keyset": "numpad+", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_=", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_-", + "action": "snd_volume_decrease" + }, + { + "keyset": "numpad-", + "action": "snd_volume_decrease" + }, + { + "keyset": "Any+space", + "action": "unit_stats" + }, + { + "keyset": "Ctrl+Shift+sc_o", + "action": "cameraflip" + }, + { + "keyset": "Alt+sc_y", + "action": "settarget" + }, + { + "keyset": "Shift+Alt+sc_y", + "action": "settarget" + }, + { + "keyset": "sc_y", + "action": "settargetnoground" + }, + { + "keyset": "Shift+sc_y", + "action": "settargetnoground" + }, + { + "keyset": "Ctrl+sc_`", + "action": "group unset" + }, + { + "keyset": "Alt+sc_`", + "action": "remove_from_autogroup" + }, + { + "keyset": "Alt+sc_b", + "action": "blueprint_place" + }, + { + "keyset": "Alt+sc_c", + "action": "blueprint_create" + }, + { + "keyset": "Alt+sc_d", + "action": "blueprint_delete" + }, + { + "keyset": "Alt+sc_[", + "action": "blueprint_prev" + }, + { + "keyset": "Alt+sc_]", + "action": "blueprint_next" + }, + { + "keyset": "Alt+sc_g", + "action": "factoryqueuemode" + }, + { + "keyset": "sc_.", + "action": "cycleselected next" + }, + { + "keyset": "sc_comma", + "action": "cycleselected prev" + } + ] + }, + { + "name": "Legacy (60% Keyboard)", + "description": "ui.keybinds.presets.legacy60", + "fakeMeta": "space", + "binds": [ + { + "keyset": "esc", + "action": "select AllMap++_ClearSelection_SelectNum_0+" + }, + { + "keyset": "esc", + "action": "quitmessage" + }, + { + "keyset": "Shift+esc", + "action": "quitmenu" + }, + { + "keyset": "Ctrl+Shift+esc", + "action": "quitforce" + }, + { + "keyset": "Alt+Shift+esc", + "action": "reloadforce" + }, + { + "keyset": "Any+escape", + "action": "edit_escape" + }, + { + "keyset": "Any+pause", + "action": "pause" + }, + { + "keyset": "esc", + "action": "teamstatus_close" + }, + { + "keyset": "esc", + "action": "customgameinfo_close" + }, + { + "keyset": "esc", + "action": "buildmenu_pregame_deselect" + }, + { + "keyset": "Any+sc_z", + "action": "selectbox_same" + }, + { + "keyset": "Any+space", + "action": "selectbox_idle" + }, + { + "keyset": "Any+shift", + "action": "selectbox_append" + }, + { + "keyset": "Any+shift", + "action": "selectbox_any" + }, + { + "keyset": "Any+ctrl", + "action": "selectbox_deselect" + }, + { + "keyset": "Any+alt", + "action": "selectbox_mobile" + }, + { + "keyset": "Any+space", + "action": "selectloop" + }, + { + "keyset": "Any+ctrl", + "action": "selectloop_invert" + }, + { + "keyset": "Any+shift", + "action": "selectloop_add" + }, + { + "keyset": "Any+space", + "action": "buildsplit" + }, + { + "keyset": "Any+space", + "action": "commandinsert prepend_between" + }, + { + "keyset": "alt+sc_.", + "action": "attack_range_inc" + }, + { + "keyset": "alt+sc_comma", + "action": "attack_range_dec" + }, + { + "keyset": "Any+enter", + "action": "chat" + }, + { + "keyset": "Alt+ctrl+sc_a", + "action": "chatswitchally" + }, + { + "keyset": "Alt+ctrl+sc_s", + "action": "chatswitchspec" + }, + { + "keyset": "Any+tab", + "action": "edit_complete" + }, + { + "keyset": "Any+backspace", + "action": "edit_backspace" + }, + { + "keyset": "Any+delete", + "action": "edit_delete" + }, + { + "keyset": "Any+home", + "action": "edit_home" + }, + { + "keyset": "Alt+left", + "action": "edit_home" + }, + { + "keyset": "Any+end", + "action": "edit_end" + }, + { + "keyset": "Alt+right", + "action": "edit_end" + }, + { + "keyset": "Any+up", + "action": "edit_prev_line" + }, + { + "keyset": "Any+down", + "action": "edit_next_line" + }, + { + "keyset": "Any+left", + "action": "edit_prev_char" + }, + { + "keyset": "Any+right", + "action": "edit_next_char" + }, + { + "keyset": "Ctrl+left", + "action": "edit_prev_word" + }, + { + "keyset": "Ctrl+right", + "action": "edit_next_word" + }, + { + "keyset": "Any+enter", + "action": "edit_return" + }, + { + "keyset": "Ctrl+v", + "action": "pastetext" + }, + { + "keyset": "Any+up", + "action": "moveforward" + }, + { + "keyset": "Any+down", + "action": "moveback" + }, + { + "keyset": "Any+right", + "action": "moveright" + }, + { + "keyset": "Any+left", + "action": "moveleft" + }, + { + "keyset": "Any+pageup", + "action": "moveup" + }, + { + "keyset": "Any+pagedown", + "action": "movedown" + }, + { + "keyset": "Any+alt", + "action": "movereset" + }, + { + "keyset": "Any+alt", + "action": "moverotate" + }, + { + "keyset": "Any+ctrl", + "action": "movetilt" + }, + { + "keyset": "ctrl+sc_o", + "action": "fov_dec 5" + }, + { + "keyset": "ctrl+sc_p", + "action": "fov_inc 5" + }, + { + "keyset": "sc_numpad1", + "action": "fov_dec 5" + }, + { + "keyset": "sc_numpad7", + "action": "fov_inc 5" + }, + { + "keyset": "Meta+ctrl+tab", + "action": "pip1_copy" + }, + { + "keyset": "Meta+tab", + "action": "pip1_switch" + }, + { + "keyset": "Alt+sc_t", + "action": "pip1_track" + }, + { + "keyset": "Any+alt", + "action": "toggle_allied_upgrade" + }, + { + "keyset": "1", + "action": "specteam 0" + }, + { + "keyset": "2", + "action": "specteam 1" + }, + { + "keyset": "3", + "action": "specteam 2" + }, + { + "keyset": "4", + "action": "specteam 3" + }, + { + "keyset": "5", + "action": "specteam 4" + }, + { + "keyset": "6", + "action": "specteam 5" + }, + { + "keyset": "7", + "action": "specteam 6" + }, + { + "keyset": "8", + "action": "specteam 7" + }, + { + "keyset": "9", + "action": "specteam 8" + }, + { + "keyset": "Alt+0", + "action": "add_to_autogroup 0" + }, + { + "keyset": "Alt+1", + "action": "add_to_autogroup 1" + }, + { + "keyset": "Alt+2", + "action": "add_to_autogroup 2" + }, + { + "keyset": "Alt+3", + "action": "add_to_autogroup 3" + }, + { + "keyset": "Alt+4", + "action": "add_to_autogroup 4" + }, + { + "keyset": "Alt+5", + "action": "add_to_autogroup 5" + }, + { + "keyset": "Alt+6", + "action": "add_to_autogroup 6" + }, + { + "keyset": "Alt+7", + "action": "add_to_autogroup 7" + }, + { + "keyset": "Alt+8", + "action": "add_to_autogroup 8" + }, + { + "keyset": "Alt+9", + "action": "add_to_autogroup 9" + }, + { + "keyset": "Shift+Alt+0", + "action": "load_autogroup_preset 0" + }, + { + "keyset": "Shift+Alt+1", + "action": "load_autogroup_preset 1" + }, + { + "keyset": "Shift+Alt+2", + "action": "load_autogroup_preset 2" + }, + { + "keyset": "Shift+Alt+3", + "action": "load_autogroup_preset 3" + }, + { + "keyset": "Shift+Alt+4", + "action": "load_autogroup_preset 4" + }, + { + "keyset": "Shift+Alt+5", + "action": "load_autogroup_preset 5" + }, + { + "keyset": "Shift+Alt+6", + "action": "load_autogroup_preset 6" + }, + { + "keyset": "Shift+Alt+7", + "action": "load_autogroup_preset 7" + }, + { + "keyset": "Shift+Alt+8", + "action": "load_autogroup_preset 8" + }, + { + "keyset": "Shift+Alt+9", + "action": "load_autogroup_preset 9" + }, + { + "keyset": "0,0", + "action": "group focus 0" + }, + { + "keyset": "1,1", + "action": "group focus 1" + }, + { + "keyset": "2,2", + "action": "group focus 2" + }, + { + "keyset": "3,3", + "action": "group focus 3" + }, + { + "keyset": "4,4", + "action": "group focus 4" + }, + { + "keyset": "5,5", + "action": "group focus 5" + }, + { + "keyset": "6,6", + "action": "group focus 6" + }, + { + "keyset": "7,7", + "action": "group focus 7" + }, + { + "keyset": "8,8", + "action": "group focus 8" + }, + { + "keyset": "9,9", + "action": "group focus 9" + }, + { + "keyset": "0", + "action": "group select 0" + }, + { + "keyset": "1", + "action": "group select 1" + }, + { + "keyset": "2", + "action": "group select 2" + }, + { + "keyset": "3", + "action": "group select 3" + }, + { + "keyset": "4", + "action": "group select 4" + }, + { + "keyset": "5", + "action": "group select 5" + }, + { + "keyset": "6", + "action": "group select 6" + }, + { + "keyset": "7", + "action": "group select 7" + }, + { + "keyset": "8", + "action": "group select 8" + }, + { + "keyset": "9", + "action": "group select 9" + }, + { + "keyset": "Ctrl+0", + "action": "group set 0" + }, + { + "keyset": "Ctrl+1", + "action": "group set 1" + }, + { + "keyset": "Ctrl+2", + "action": "group set 2" + }, + { + "keyset": "Ctrl+3", + "action": "group set 3" + }, + { + "keyset": "Ctrl+4", + "action": "group set 4" + }, + { + "keyset": "Ctrl+5", + "action": "group set 5" + }, + { + "keyset": "Ctrl+6", + "action": "group set 6" + }, + { + "keyset": "Ctrl+7", + "action": "group set 7" + }, + { + "keyset": "Ctrl+8", + "action": "group set 8" + }, + { + "keyset": "Ctrl+9", + "action": "group set 9" + }, + { + "keyset": "Shift+0", + "action": "group selectadd 0" + }, + { + "keyset": "Shift+1", + "action": "group selectadd 1" + }, + { + "keyset": "Shift+2", + "action": "group selectadd 2" + }, + { + "keyset": "Shift+3", + "action": "group selectadd 3" + }, + { + "keyset": "Shift+4", + "action": "group selectadd 4" + }, + { + "keyset": "Shift+5", + "action": "group selectadd 5" + }, + { + "keyset": "Shift+6", + "action": "group selectadd 6" + }, + { + "keyset": "Shift+7", + "action": "group selectadd 7" + }, + { + "keyset": "Shift+8", + "action": "group selectadd 8" + }, + { + "keyset": "Shift+9", + "action": "group selectadd 9" + }, + { + "keyset": "Ctrl+Shift+0", + "action": "group add 0" + }, + { + "keyset": "Ctrl+Shift+1", + "action": "group add 1" + }, + { + "keyset": "Ctrl+Shift+2", + "action": "group add 2" + }, + { + "keyset": "Ctrl+Shift+3", + "action": "group add 3" + }, + { + "keyset": "Ctrl+Shift+4", + "action": "group add 4" + }, + { + "keyset": "Ctrl+Shift+5", + "action": "group add 5" + }, + { + "keyset": "Ctrl+Shift+6", + "action": "group add 6" + }, + { + "keyset": "Ctrl+Shift+7", + "action": "group add 7" + }, + { + "keyset": "Ctrl+Shift+8", + "action": "group add 8" + }, + { + "keyset": "Ctrl+Shift+9", + "action": "group add 9" + }, + { + "keyset": "Ctrl+Alt+0", + "action": "group selecttoggle 0" + }, + { + "keyset": "Ctrl+Alt+1", + "action": "group selecttoggle 1" + }, + { + "keyset": "Ctrl+Alt+2", + "action": "group selecttoggle 2" + }, + { + "keyset": "Ctrl+Alt+3", + "action": "group selecttoggle 3" + }, + { + "keyset": "Ctrl+Alt+4", + "action": "group selecttoggle 4" + }, + { + "keyset": "Ctrl+Alt+5", + "action": "group selecttoggle 5" + }, + { + "keyset": "Ctrl+Alt+6", + "action": "group selecttoggle 6" + }, + { + "keyset": "Ctrl+Alt+7", + "action": "group selecttoggle 7" + }, + { + "keyset": "Ctrl+Alt+8", + "action": "group selecttoggle 8" + }, + { + "keyset": "Ctrl+Alt+9", + "action": "group selecttoggle 9" + }, + { + "keyset": "Shift+backspace", + "action": "togglecammode" + }, + { + "keyset": "Ctrl+backspace", + "action": "togglecammode" + }, + { + "keyset": "Any+tab", + "action": "toggleoverview" + }, + { + "keyset": "Alt+sc_=", + "action": "increasespeed" + }, + { + "keyset": "Alt+sc_-", + "action": "decreasespeed" + }, + { + "keyset": "Alt+numpad+", + "action": "increasespeed" + }, + { + "keyset": "Alt+numpad-", + "action": "decreasespeed" + }, + { + "keyset": "sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "Shift+sc_[", + "action": "buildfacing inc" + }, + { + "keyset": "sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Shift+sc_]", + "action": "buildfacing dec" + }, + { + "keyset": "Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Shift+Alt+sc_z", + "action": "buildspacing inc" + }, + { + "keyset": "Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "Shift+Alt+sc_x", + "action": "buildspacing dec" + }, + { + "keyset": "sc_a", + "action": "attack" + }, + { + "keyset": "Shift+sc_a", + "action": "attack" + }, + { + "keyset": "Alt+sc_a", + "action": "areaattack" + }, + { + "keyset": "Alt+Shift+sc_a", + "action": "areaattack" + }, + { + "keyset": "sc_d", + "action": "manualfire" + }, + { + "keyset": "Shift+sc_d", + "action": "manualfire" + }, + { + "keyset": "sc_d", + "action": "manuallaunch" + }, + { + "keyset": "Shift+sc_d", + "action": "manuallaunch" + }, + { + "keyset": "Ctrl+sc_d", + "action": "selfd" + }, + { + "keyset": "Ctrl+Shift+sc_d", + "action": "selfd queued" + }, + { + "keyset": "sc_e", + "action": "reclaim" + }, + { + "keyset": "Shift+sc_e", + "action": "reclaim" + }, + { + "keyset": "sc_f", + "action": "fight" + }, + { + "keyset": "Shift+sc_f", + "action": "fight" + }, + { + "keyset": "Alt+sc_f", + "action": "chain force forcestart | say !cv forcestart" + }, + { + "keyset": "sc_g", + "action": "guard" + }, + { + "keyset": "Shift+sc_g", + "action": "guard" + }, + { + "keyset": "sc_j", + "action": "canceltarget" + }, + { + "keyset": "sc_k", + "action": "cloak" + }, + { + "keyset": "Shift+sc_k", + "action": "cloak" + }, + { + "keyset": "sc_k", + "action": "wantcloak" + }, + { + "keyset": "Any+sc_k", + "action": "wantcloak" + }, + { + "keyset": "sc_l", + "action": "loadunits" + }, + { + "keyset": "Shift+sc_l", + "action": "loadunits" + }, + { + "keyset": "sc_m", + "action": "move" + }, + { + "keyset": "Shift+sc_m", + "action": "move" + }, + { + "keyset": "sc_n", + "action": "command_skip_current" + }, + { + "keyset": "Ctrl+sc_n", + "action": "command_cancel_last" + }, + { + "keyset": "sc_p", + "action": "patrol" + }, + { + "keyset": "Shift+sc_p", + "action": "patrol" + }, + { + "keyset": "sc_q,sc_q", + "action": "drawlabel" + }, + { + "keyset": "sc_q", + "action": "drawinmap" + }, + { + "keyset": "sc_r", + "action": "repair" + }, + { + "keyset": "Shift+sc_r", + "action": "repair" + }, + { + "keyset": "Ctrl+sc_r", + "action": "resurrect" + }, + { + "keyset": "sc_s", + "action": "stop" + }, + { + "keyset": "Shift+sc_s", + "action": "stop" + }, + { + "keyset": "Ctrl+sc_s", + "action": "stopproduction" + }, + { + "keyset": "sc_u", + "action": "unloadunits" + }, + { + "keyset": "Shift+sc_u", + "action": "unloadunits" + }, + { + "keyset": "sc_w", + "action": "wait" + }, + { + "keyset": "Shift+sc_w", + "action": "wait queued" + }, + { + "keyset": "sc_x", + "action": "onoff" + }, + { + "keyset": "Shift+sc_x", + "action": "onoff" + }, + { + "keyset": "Any+sc_l", + "action": "togglelos" + }, + { + "keyset": "Ctrl+sc_t", + "action": "trackmode" + }, + { + "keyset": "Any+sc_t", + "action": "track" + }, + { + "keyset": "Any+ctrl", + "action": "moveslow" + }, + { + "keyset": "Any+shift", + "action": "movefast" + }, + { + "keyset": "Ctrl+meta+1", + "action": "viewfps" + }, + { + "keyset": "Ctrl+meta+2", + "action": "viewta" + }, + { + "keyset": "Ctrl+meta+3", + "action": "viewspring" + }, + { + "keyset": "Ctrl+meta+4", + "action": "viewrot" + }, + { + "keyset": "Ctrl+meta+5", + "action": "viewfree" + }, + { + "keyset": "meta+1", + "action": "showelevation" + }, + { + "keyset": "meta+2", + "action": "showpathtraversability" + }, + { + "keyset": "meta+3", + "action": "lastmsgpos" + }, + { + "keyset": "meta+4", + "action": "showmetalmap" + }, + { + "keyset": "meta+5", + "action": "hideinterface" + }, + { + "keyset": "meta+6", + "action": "mutesound" + }, + { + "keyset": "meta+7", + "action": "dynamicsky" + }, + { + "keyset": "meta+8", + "action": "screenshot png" + }, + { + "keyset": "Ctrl+Shift+f8", + "action": "savegame" + }, + { + "keyset": "Ctrl+sc_a", + "action": "select AllMap++_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_b", + "action": "select AllMap+_Builder_Idle+_ClearSelection_SelectOne+" + }, + { + "keyset": "Ctrl+sc_c", + "action": "selectcomm focus" + }, + { + "keyset": "Ctrl+sc_v", + "action": "select AllMap+_Not_Builder_InPrevSel_Not_InHotkeyGroup+_SelectAll+" + }, + { + "keyset": "Ctrl+sc_w", + "action": "select AllMap+_Not_Aircraft_Weapons+_ClearSelection_SelectAll+" + }, + { + "keyset": "Ctrl+sc_x", + "action": "select AllMap+_InPrevSel_Not_InHotkeyGroup+_SelectAll+" + }, + { + "keyset": "Ctrl+sc_z", + "action": "select AllMap+_InPrevSel+_ClearSelection_SelectAll+" + }, + { + "keyset": "sc_z", + "action": "buildunit_armmex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armmex" + }, + { + "keyset": "sc_z", + "action": "buildunit_armamex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armamex" + }, + { + "keyset": "sc_z", + "action": "buildunit_cormex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_cormex" + }, + { + "keyset": "sc_z", + "action": "buildunit_legmex" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_legmex" + }, + { + "keyset": "sc_z", + "action": "buildunit_legmext15" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_legmext15" + }, + { + "keyset": "sc_z", + "action": "buildunit_corexp" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_corexp" + }, + { + "keyset": "sc_z", + "action": "buildunit_armmoho" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armmoho" + }, + { + "keyset": "sc_z", + "action": "buildunit_cormoho" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_cormoho" + }, + { + "keyset": "sc_z", + "action": "buildunit_legmoho" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_legmoho" + }, + { + "keyset": "sc_z", + "action": "buildunit_cormexp" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_cormexp" + }, + { + "keyset": "sc_z", + "action": "buildunit_coruwmme" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_coruwmme" + }, + { + "keyset": "sc_z", + "action": "buildunit_armuwmme" + }, + { + "keyset": "Shift+sc_z", + "action": "buildunit_armuwmme" + }, + { + "keyset": "sc_z", + "action": "areamex" + }, + { + "keyset": "Shift+sc_z", + "action": "areamex" + }, + { + "keyset": "Ctrl+Alt+sc_z", + "action": "areamex" + }, + { + "keyset": "sc_x", + "action": "buildunit_armsolar" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armsolar" + }, + { + "keyset": "sc_x", + "action": "buildunit_armwin" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armwin" + }, + { + "keyset": "sc_x", + "action": "buildunit_corsolar" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_corsolar" + }, + { + "keyset": "sc_x", + "action": "buildunit_corwin" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_corwin" + }, + { + "keyset": "sc_x", + "action": "buildunit_legsolar" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legsolar" + }, + { + "keyset": "sc_x", + "action": "buildunit_legwin" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legwin" + }, + { + "keyset": "sc_x", + "action": "buildunit_armadvsol" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armadvsol" + }, + { + "keyset": "sc_x", + "action": "buildunit_coradvsol" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_coradvsol" + }, + { + "keyset": "sc_x", + "action": "buildunit_legadvsol" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legadvsol" + }, + { + "keyset": "sc_x", + "action": "buildunit_armfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_armmmkr" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armmmkr" + }, + { + "keyset": "sc_x", + "action": "buildunit_corfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_corfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_cormmkr" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_cormmkr" + }, + { + "keyset": "sc_x", + "action": "buildunit_legfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_legadveconv" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legadveconv" + }, + { + "keyset": "sc_x", + "action": "buildunit_armtide" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armtide" + }, + { + "keyset": "sc_x", + "action": "buildunit_cortide" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_cortide" + }, + { + "keyset": "sc_x", + "action": "buildunit_legtide" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_legtide" + }, + { + "keyset": "sc_x", + "action": "buildunit_armuwfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armuwfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_coruwfus" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_coruwfus" + }, + { + "keyset": "sc_x", + "action": "buildunit_armuwmmm" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_armuwmmm" + }, + { + "keyset": "sc_x", + "action": "buildunit_coruwmmm" + }, + { + "keyset": "Shift+sc_x", + "action": "buildunit_coruwmmm" + }, + { + "keyset": "sc_c", + "action": "buildunit_armllt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armllt" + }, + { + "keyset": "sc_c", + "action": "buildunit_armrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_corllt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corllt" + }, + { + "keyset": "sc_c", + "action": "buildunit_leglht" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_leglht" + }, + { + "keyset": "sc_c", + "action": "buildunit_corrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_legrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_corrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corrl" + }, + { + "keyset": "sc_c", + "action": "buildunit_legrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legrl" + }, + { + "keyset": "sc_c", + "action": "buildunit_armrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armrl" + }, + { + "keyset": "sc_c", + "action": "buildunit_armpb" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armpb" + }, + { + "keyset": "sc_c", + "action": "buildunit_armflak" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armflak" + }, + { + "keyset": "sc_c", + "action": "buildunit_corvipe" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corvipe" + }, + { + "keyset": "sc_c", + "action": "buildunit_corflak" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corflak" + }, + { + "keyset": "sc_c", + "action": "buildunit_legapopupdef" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legapopupdef" + }, + { + "keyset": "sc_c", + "action": "buildunit_legflak" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legflak" + }, + { + "keyset": "sc_c", + "action": "buildunit_armgplat" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armgplat" + }, + { + "keyset": "sc_c", + "action": "buildunit_corgplat" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corgplat" + }, + { + "keyset": "sc_c", + "action": "buildunit_armtl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armtl" + }, + { + "keyset": "sc_c", + "action": "buildunit_cortl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_cortl" + }, + { + "keyset": "sc_c", + "action": "buildunit_legtl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legtl" + }, + { + "keyset": "sc_c", + "action": "buildunit_armsonar" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armsonar" + }, + { + "keyset": "sc_c", + "action": "buildunit_corsonar" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corsonar" + }, + { + "keyset": "sc_c", + "action": "buildunit_armfrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armfrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_corfrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corfrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_legfrad" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legfrad" + }, + { + "keyset": "sc_c", + "action": "buildunit_armfrt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_armfrt" + }, + { + "keyset": "sc_c", + "action": "buildunit_corfrt" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_corfrt" + }, + { + "keyset": "sc_c", + "action": "buildunit_legfrl" + }, + { + "keyset": "Shift+sc_c", + "action": "buildunit_legfrl" + }, + { + "keyset": "sc_v", + "action": "buildunit_armnanotc" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armnanotc" + }, + { + "keyset": "sc_v", + "action": "buildunit_armnanotcplat" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armnanotcplat" + }, + { + "keyset": "sc_v", + "action": "buildunit_cornanotcplat" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_cornanotcplat" + }, + { + "keyset": "sc_v", + "action": "buildunit_armlab" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armlab" + }, + { + "keyset": "sc_v", + "action": "buildunit_armvp" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armvp" + }, + { + "keyset": "sc_v", + "action": "buildunit_armap" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armap" + }, + { + "keyset": "sc_v", + "action": "buildunit_cornanotc" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_cornanotc" + }, + { + "keyset": "sc_v", + "action": "buildunit_corlab" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corlab" + }, + { + "keyset": "sc_v", + "action": "buildunit_corvp" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corvp" + }, + { + "keyset": "sc_v", + "action": "buildunit_corap" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corap" + }, + { + "keyset": "sc_v", + "action": "buildunit_legnanotc" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legnanotc" + }, + { + "keyset": "sc_v", + "action": "buildunit_leglab" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_leglab" + }, + { + "keyset": "sc_v", + "action": "buildunit_legvp" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legvp" + }, + { + "keyset": "sc_v", + "action": "buildunit_legap" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legap" + }, + { + "keyset": "sc_v", + "action": "buildunit_armsy" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_armsy" + }, + { + "keyset": "sc_v", + "action": "buildunit_corsy" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_corsy" + }, + { + "keyset": "sc_v", + "action": "buildunit_legsy" + }, + { + "keyset": "Shift+sc_v", + "action": "buildunit_legsy" + }, + { + "keyset": "numpad2", + "action": "moveback" + }, + { + "keyset": "numpad6", + "action": "moveright" + }, + { + "keyset": "numpad4", + "action": "moveleft" + }, + { + "keyset": "numpad8", + "action": "moveforward" + }, + { + "keyset": "numpad9", + "action": "moveup" + }, + { + "keyset": "numpad3", + "action": "movedown" + }, + { + "keyset": "numpad1", + "action": "movefast" + }, + { + "keyset": "numpad+", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_=", + "action": "snd_volume_increase" + }, + { + "keyset": "sc_-", + "action": "snd_volume_decrease" + }, + { + "keyset": "numpad-", + "action": "snd_volume_decrease" + }, + { + "keyset": "Any+space", + "action": "unit_stats" + }, + { + "keyset": "Ctrl+Shift+sc_o", + "action": "cameraflip" + }, + { + "keyset": "Alt+sc_y", + "action": "settarget" + }, + { + "keyset": "Shift+Alt+sc_y", + "action": "settarget" + }, + { + "keyset": "sc_y", + "action": "settargetnoground" + }, + { + "keyset": "Shift+sc_y", + "action": "settargetnoground" + }, + { + "keyset": "Ctrl+meta+sc_q", + "action": "group unset" + }, + { + "keyset": "Alt+sc_q", + "action": "remove_from_autogroup" + }, + { + "keyset": "Alt+sc_b", + "action": "blueprint_place" + }, + { + "keyset": "Alt+sc_c", + "action": "blueprint_create" + }, + { + "keyset": "Alt+sc_d", + "action": "blueprint_delete" + }, + { + "keyset": "Alt+sc_[", + "action": "blueprint_prev" + }, + { + "keyset": "Alt+sc_]", + "action": "blueprint_next" + }, + { + "keyset": "f11", + "action": "luaui selector" + }, + { + "keyset": "sc_.", + "action": "cycleselected next" + }, + { + "keyset": "sc_comma", + "action": "cycleselected prev" + } + ] + } + ] +} diff --git a/common/configs/keybind_defaults.schema.json b/common/configs/keybind_defaults.schema.json new file mode 100644 index 00000000000..03ec57840c5 --- /dev/null +++ b/common/configs/keybind_defaults.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/beyond-all-reason/Beyond-All-Reason/master/common/configs/keybind_defaults.schema.json", + "title": "Shipped keybind profiles", + "description": "The read-only keybind profiles the game ships. Each profile is a whole keymap, never a delta.", + "type": "object", + "required": ["version", "profiles"], + "additionalProperties": false, + "definitions": { + "profile": { + "type": "object", + "required": ["name", "binds"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Display name, and the id a surface stores as the active selection. Unique across both lists." + }, + "description": { + "type": "string", + "minLength": 1, + "description": "i18n key for a sentence or two saying what the profile is for, shown where a surface lets the player pick one. A surface without that translation shows the key's own text." + }, + "fakeMeta": { + "type": "string", + "description": "Key to treat as the Meta modifier. A keycode, never a scancode, which the engine refuses here. Leave it out to get the engine's own, which is space; set \"none\" for a profile that wants no Meta modifier at all." + }, + "binds": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["keyset", "action"], + "additionalProperties": false, + "properties": { + "keyset": { + "type": "string", + "minLength": 1, + "description": "Keyset exactly as /bind expects it, including any modifier prefix and comma-separated chain." + }, + "action": { + "type": "string", + "minLength": 1, + "description": "Bind command plus its space-separated arguments." + } + } + } + } + } + } + }, + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "description": "Format version of this file." + }, + "priority": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Action prefixes, highest priority first. Two actions on one key are tried in the order they were bound, so bind files are emitted with these actions first; anything unlisted keeps the order it already had." + }, + "profiles": { + "type": "array", + "minItems": 1, + "description": "Selectable profiles, ordered; the first is what a surface falls back to when nothing valid is selected.", + "items": { "$ref": "#/definitions/profile" } + } + } +} diff --git a/common/configs/keybind_retired_includes.json b/common/configs/keybind_retired_includes.json new file mode 100644 index 00000000000..561390a3706 --- /dev/null +++ b/common/configs/keybind_retired_includes.json @@ -0,0 +1,786 @@ +{ + "luaui/configs/hotkeys/chat_and_ui_keys.txt": [ + { + "keyset": "esc", + "action": "select AllMap++_ClearSelection_SelectNum_0+" + }, + { + "keyset": "esc", + "action": "quitmessage" + }, + { + "keyset": "Shift+esc", + "action": "quitmenu" + }, + { + "keyset": "Ctrl+Shift+esc", + "action": "quitforce" + }, + { + "keyset": "Alt+Shift+esc", + "action": "reloadforce" + }, + { + "keyset": "Any+escape", + "action": "edit_escape" + }, + { + "keyset": "Any+pause", + "action": "pause" + }, + { + "keyset": "esc", + "action": "teamstatus_close" + }, + { + "keyset": "esc", + "action": "customgameinfo_close" + }, + { + "keyset": "esc", + "action": "buildmenu_pregame_deselect" + }, + { + "keyset": "Alt+backspace", + "action": "fullscreen" + }, + { + "keyset": "Any+sc_z", + "action": "selectbox_same" + }, + { + "keyset": "Any+space", + "action": "selectbox_idle" + }, + { + "keyset": "Any+shift", + "action": "selectbox_append" + }, + { + "keyset": "Any+shift", + "action": "selectbox_any" + }, + { + "keyset": "Any+ctrl", + "action": "selectbox_deselect" + }, + { + "keyset": "Any+alt", + "action": "selectbox_mobile" + }, + { + "keyset": "Any+space", + "action": "selectloop" + }, + { + "keyset": "Any+ctrl", + "action": "selectloop_invert" + }, + { + "keyset": "Any+shift", + "action": "selectloop_add" + }, + { + "keyset": "Any+space", + "action": "buildsplit" + }, + { + "keyset": "Any+space", + "action": "commandinsert prepend_between" + }, + { + "keyset": "alt+sc_.", + "action": "attack_range_inc" + }, + { + "keyset": "alt+sc_comma", + "action": "attack_range_dec" + }, + { + "keyset": "Any+enter", + "action": "chat" + }, + { + "keyset": "Alt+ctrl+sc_a", + "action": "chatswitchally" + }, + { + "keyset": "Alt+ctrl+sc_s", + "action": "chatswitchspec" + }, + { + "keyset": "Any+tab", + "action": "edit_complete" + }, + { + "keyset": "Any+backspace", + "action": "edit_backspace" + }, + { + "keyset": "Any+delete", + "action": "edit_delete" + }, + { + "keyset": "Any+home", + "action": "edit_home" + }, + { + "keyset": "Alt+left", + "action": "edit_home" + }, + { + "keyset": "Any+end", + "action": "edit_end" + }, + { + "keyset": "Alt+right", + "action": "edit_end" + }, + { + "keyset": "Any+up", + "action": "edit_prev_line" + }, + { + "keyset": "Any+down", + "action": "edit_next_line" + }, + { + "keyset": "Any+left", + "action": "edit_prev_char" + }, + { + "keyset": "Any+right", + "action": "edit_next_char" + }, + { + "keyset": "Ctrl+left", + "action": "edit_prev_word" + }, + { + "keyset": "Ctrl+right", + "action": "edit_next_word" + }, + { + "keyset": "Any+enter", + "action": "edit_return" + }, + { + "keyset": "Ctrl+v", + "action": "pastetext" + }, + { + "keyset": "Any+up", + "action": "moveforward" + }, + { + "keyset": "Any+down", + "action": "moveback" + }, + { + "keyset": "Any+right", + "action": "moveright" + }, + { + "keyset": "Any+left", + "action": "moveleft" + }, + { + "keyset": "Any+pageup", + "action": "moveup" + }, + { + "keyset": "Any+pagedown", + "action": "movedown" + }, + { + "keyset": "Any+alt", + "action": "movereset" + }, + { + "keyset": "Any+alt", + "action": "moverotate" + }, + { + "keyset": "Any+ctrl", + "action": "movetilt" + }, + { + "keyset": "ctrl+sc_o", + "action": "fov_dec 5" + }, + { + "keyset": "ctrl+sc_p", + "action": "fov_inc 5" + }, + { + "keyset": "sc_numpad1", + "action": "fov_dec 5" + }, + { + "keyset": "sc_numpad7", + "action": "fov_inc 5" + }, + { + "keyset": "Meta+ctrl+tab", + "action": "pip1_copy" + }, + { + "keyset": "Meta+tab", + "action": "pip1_switch" + }, + { + "keyset": "Alt+sc_t", + "action": "pip1_track" + }, + { + "keyset": "Any+alt", + "action": "toggle_allied_upgrade" + } + ], + "luaui/configs/hotkeys/dev_keys.txt": [ + { + "keyset": "Alt+b", + "action": "debug" + }, + { + "keyset": "Alt+v", + "action": "debugcolvol" + }, + { + "keyset": "Alt+p", + "action": "debugpath" + } + ], + "luaui/configs/hotkeys/gridmenu_keys.txt": [ + { + "keyset": "sc_z", + "action": "gridmenu_category 1" + }, + { + "keyset": "sc_x", + "action": "gridmenu_category 2" + }, + { + "keyset": "sc_c", + "action": "gridmenu_category 3" + }, + { + "keyset": "sc_v", + "action": "gridmenu_category 4" + }, + { + "keyset": "Shift+sc_z", + "action": "gridmenu_category 1" + }, + { + "keyset": "Shift+sc_x", + "action": "gridmenu_category 2" + }, + { + "keyset": "Shift+sc_c", + "action": "gridmenu_category 3" + }, + { + "keyset": "Shift+sc_v", + "action": "gridmenu_category 4" + }, + { + "keyset": "Any+sc_z", + "action": "gridmenu_key 1 1" + }, + { + "keyset": "Any+sc_x", + "action": "gridmenu_key 1 2" + }, + { + "keyset": "Any+sc_c", + "action": "gridmenu_key 1 3" + }, + { + "keyset": "Any+sc_v", + "action": "gridmenu_key 1 4" + }, + { + "keyset": "Any+sc_a", + "action": "gridmenu_key 2 1" + }, + { + "keyset": "Any+sc_s", + "action": "gridmenu_key 2 2" + }, + { + "keyset": "Any+sc_d", + "action": "gridmenu_key 2 3" + }, + { + "keyset": "Any+sc_f", + "action": "gridmenu_key 2 4" + }, + { + "keyset": "Any+sc_q", + "action": "gridmenu_key 3 1" + }, + { + "keyset": "Any+sc_w", + "action": "gridmenu_key 3 2" + }, + { + "keyset": "Any+sc_e", + "action": "gridmenu_key 3 3" + }, + { + "keyset": "Any+sc_r", + "action": "gridmenu_key 3 4" + }, + { + "keyset": "sc_b", + "action": "gridmenu_next_page" + }, + { + "keyset": "sc_.", + "action": "gridmenu_cycle_builder" + } + ], + "luaui/configs/hotkeys/num_keys.txt": [ + { + "keyset": "1", + "action": "specteam 0" + }, + { + "keyset": "2", + "action": "specteam 1" + }, + { + "keyset": "3", + "action": "specteam 2" + }, + { + "keyset": "4", + "action": "specteam 3" + }, + { + "keyset": "5", + "action": "specteam 4" + }, + { + "keyset": "6", + "action": "specteam 5" + }, + { + "keyset": "7", + "action": "specteam 6" + }, + { + "keyset": "8", + "action": "specteam 7" + }, + { + "keyset": "9", + "action": "specteam 8" + }, + { + "keyset": "Alt+0", + "action": "add_to_autogroup 0" + }, + { + "keyset": "Alt+1", + "action": "add_to_autogroup 1" + }, + { + "keyset": "Alt+2", + "action": "add_to_autogroup 2" + }, + { + "keyset": "Alt+3", + "action": "add_to_autogroup 3" + }, + { + "keyset": "Alt+4", + "action": "add_to_autogroup 4" + }, + { + "keyset": "Alt+5", + "action": "add_to_autogroup 5" + }, + { + "keyset": "Alt+6", + "action": "add_to_autogroup 6" + }, + { + "keyset": "Alt+7", + "action": "add_to_autogroup 7" + }, + { + "keyset": "Alt+8", + "action": "add_to_autogroup 8" + }, + { + "keyset": "Alt+9", + "action": "add_to_autogroup 9" + }, + { + "keyset": "Shift+Alt+0", + "action": "load_autogroup_preset 0" + }, + { + "keyset": "Shift+Alt+1", + "action": "load_autogroup_preset 1" + }, + { + "keyset": "Shift+Alt+2", + "action": "load_autogroup_preset 2" + }, + { + "keyset": "Shift+Alt+3", + "action": "load_autogroup_preset 3" + }, + { + "keyset": "Shift+Alt+4", + "action": "load_autogroup_preset 4" + }, + { + "keyset": "Shift+Alt+5", + "action": "load_autogroup_preset 5" + }, + { + "keyset": "Shift+Alt+6", + "action": "load_autogroup_preset 6" + }, + { + "keyset": "Shift+Alt+7", + "action": "load_autogroup_preset 7" + }, + { + "keyset": "Shift+Alt+8", + "action": "load_autogroup_preset 8" + }, + { + "keyset": "Shift+Alt+9", + "action": "load_autogroup_preset 9" + }, + { + "keyset": "0,0", + "action": "group focus 0" + }, + { + "keyset": "1,1", + "action": "group focus 1" + }, + { + "keyset": "2,2", + "action": "group focus 2" + }, + { + "keyset": "3,3", + "action": "group focus 3" + }, + { + "keyset": "4,4", + "action": "group focus 4" + }, + { + "keyset": "5,5", + "action": "group focus 5" + }, + { + "keyset": "6,6", + "action": "group focus 6" + }, + { + "keyset": "7,7", + "action": "group focus 7" + }, + { + "keyset": "8,8", + "action": "group focus 8" + }, + { + "keyset": "9,9", + "action": "group focus 9" + }, + { + "keyset": "0", + "action": "group select 0" + }, + { + "keyset": "1", + "action": "group select 1" + }, + { + "keyset": "2", + "action": "group select 2" + }, + { + "keyset": "3", + "action": "group select 3" + }, + { + "keyset": "4", + "action": "group select 4" + }, + { + "keyset": "5", + "action": "group select 5" + }, + { + "keyset": "6", + "action": "group select 6" + }, + { + "keyset": "7", + "action": "group select 7" + }, + { + "keyset": "8", + "action": "group select 8" + }, + { + "keyset": "9", + "action": "group select 9" + }, + { + "keyset": "Ctrl+0", + "action": "group set 0" + }, + { + "keyset": "Ctrl+1", + "action": "group set 1" + }, + { + "keyset": "Ctrl+2", + "action": "group set 2" + }, + { + "keyset": "Ctrl+3", + "action": "group set 3" + }, + { + "keyset": "Ctrl+4", + "action": "group set 4" + }, + { + "keyset": "Ctrl+5", + "action": "group set 5" + }, + { + "keyset": "Ctrl+6", + "action": "group set 6" + }, + { + "keyset": "Ctrl+7", + "action": "group set 7" + }, + { + "keyset": "Ctrl+8", + "action": "group set 8" + }, + { + "keyset": "Ctrl+9", + "action": "group set 9" + }, + { + "keyset": "Shift+0", + "action": "group selectadd 0" + }, + { + "keyset": "Shift+1", + "action": "group selectadd 1" + }, + { + "keyset": "Shift+2", + "action": "group selectadd 2" + }, + { + "keyset": "Shift+3", + "action": "group selectadd 3" + }, + { + "keyset": "Shift+4", + "action": "group selectadd 4" + }, + { + "keyset": "Shift+5", + "action": "group selectadd 5" + }, + { + "keyset": "Shift+6", + "action": "group selectadd 6" + }, + { + "keyset": "Shift+7", + "action": "group selectadd 7" + }, + { + "keyset": "Shift+8", + "action": "group selectadd 8" + }, + { + "keyset": "Shift+9", + "action": "group selectadd 9" + }, + { + "keyset": "Ctrl+Shift+0", + "action": "group add 0" + }, + { + "keyset": "Ctrl+Shift+1", + "action": "group add 1" + }, + { + "keyset": "Ctrl+Shift+2", + "action": "group add 2" + }, + { + "keyset": "Ctrl+Shift+3", + "action": "group add 3" + }, + { + "keyset": "Ctrl+Shift+4", + "action": "group add 4" + }, + { + "keyset": "Ctrl+Shift+5", + "action": "group add 5" + }, + { + "keyset": "Ctrl+Shift+6", + "action": "group add 6" + }, + { + "keyset": "Ctrl+Shift+7", + "action": "group add 7" + }, + { + "keyset": "Ctrl+Shift+8", + "action": "group add 8" + }, + { + "keyset": "Ctrl+Shift+9", + "action": "group add 9" + }, + { + "keyset": "Ctrl+Alt+0", + "action": "group selecttoggle 0" + }, + { + "keyset": "Ctrl+Alt+1", + "action": "group selecttoggle 1" + }, + { + "keyset": "Ctrl+Alt+2", + "action": "group selecttoggle 2" + }, + { + "keyset": "Ctrl+Alt+3", + "action": "group selecttoggle 3" + }, + { + "keyset": "Ctrl+Alt+4", + "action": "group selecttoggle 4" + }, + { + "keyset": "Ctrl+Alt+5", + "action": "group selecttoggle 5" + }, + { + "keyset": "Ctrl+Alt+6", + "action": "group selecttoggle 6" + }, + { + "keyset": "Ctrl+Alt+7", + "action": "group selecttoggle 7" + }, + { + "keyset": "Ctrl+Alt+8", + "action": "group selecttoggle 8" + }, + { + "keyset": "Ctrl+Alt+9", + "action": "group selecttoggle 9" + }, + { + "keyset": "meta+alt+0", + "action": "factory_preset save 0" + }, + { + "keyset": "meta+alt+1", + "action": "factory_preset save 1" + }, + { + "keyset": "meta+alt+2", + "action": "factory_preset save 2" + }, + { + "keyset": "meta+alt+3", + "action": "factory_preset save 3" + }, + { + "keyset": "meta+alt+4", + "action": "factory_preset save 4" + }, + { + "keyset": "meta+alt+5", + "action": "factory_preset save 5" + }, + { + "keyset": "meta+alt+6", + "action": "factory_preset save 6" + }, + { + "keyset": "meta+alt+7", + "action": "factory_preset save 7" + }, + { + "keyset": "meta+alt+8", + "action": "factory_preset save 8" + }, + { + "keyset": "meta+alt+9", + "action": "factory_preset save 9" + }, + { + "keyset": "meta+0", + "action": "factory_preset load 0" + }, + { + "keyset": "meta+1", + "action": "factory_preset load 1" + }, + { + "keyset": "meta+2", + "action": "factory_preset load 2" + }, + { + "keyset": "meta+3", + "action": "factory_preset load 3" + }, + { + "keyset": "meta+4", + "action": "factory_preset load 4" + }, + { + "keyset": "meta+5", + "action": "factory_preset load 5" + }, + { + "keyset": "meta+6", + "action": "factory_preset load 6" + }, + { + "keyset": "meta+7", + "action": "factory_preset load 7" + }, + { + "keyset": "meta+8", + "action": "factory_preset load 8" + }, + { + "keyset": "meta+9", + "action": "factory_preset load 9" + }, + { + "keyset": "any+sc_space", + "action": "factory_preset_show" + } + ] +} diff --git a/common/configs/keybind_retired_includes.schema.json b/common/configs/keybind_retired_includes.schema.json new file mode 100644 index 00000000000..e7740c68859 --- /dev/null +++ b/common/configs/keybind_retired_includes.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/beyond-all-reason/Beyond-All-Reason/master/common/configs/keybind_retired_includes.schema.json", + "title": "Retired keybind include files", + "description": "What each deleted luaui/configs/hotkeys fragment bound, keyed by the path a player's own bind file still names in a keyload. Read only while migrating such a file.", + "type": "object", + "additionalProperties": { + "type": "array", + "minItems": 1, + "description": "The file's bind lines, in the order it listed them.", + "items": { + "type": "object", + "required": ["keyset", "action"], + "additionalProperties": false, + "properties": { + "keyset": { + "type": "string", + "minLength": 1, + "description": "Keyset exactly as /bind expects it, including any modifier prefix and comma-separated chain." + }, + "action": { + "type": "string", + "minLength": 1, + "description": "Bind command plus its space-separated arguments." + } + } + } + } +} diff --git a/common/configs/keybinds.README.md b/common/configs/keybinds.README.md new file mode 100644 index 00000000000..7353a4c097c --- /dev/null +++ b/common/configs/keybinds.README.md @@ -0,0 +1,194 @@ +# Shared keybind contract + +These files are the cross-surface source of truth for keybind editing, so the in-game +editor, Chobby, and the new lobby can each build their own UI without duplicating the +data or the rules. They hold *data and rules only* - no rendering, no engine calls. + +## Files + +| File | What it is | Schema | +|---|---|---| +| `keybind_catalog.json` | Ordered categories of keybindable commands, with i18n label keys and bind-action ids. | `keybind_catalog.schema.json` | +| `keybind_defaults.json` | The keybind profiles the game ships, each a complete keymap. | `keybind_defaults.schema.json` | +| `keybind_retired_includes.json` | What the deleted `luaui/configs/hotkeys` fragments bound, for migrating a player's own bind file that still keyloads one. | `keybind_retired_includes.schema.json` | + +All three are validated in CI by `spec/common/keybind_catalog_spec.lua`: each file against its schema, +profile names unique across the shipped set, every purely modifier-only action marked +read-only, and every action command written in lower case. + +They are separate because a catalog entry is per action while a binding is per action *and* +profile. The four shipped profiles share only 207 of the 381 actions they bind between them, +so there is no single default keyset to hang off a catalog row - merging the two would mean +every row carrying a keyset-per-profile map, which is this file re-expressed inside the +catalog. The sets differ both ways as well: 41 bound actions have no catalog entry - 25 of +those are listed as hidden on purpose, the other 16 surface under "Other" - and 8 catalog +entries are bound in no profile. Adding a bindable +action usually means touching both - the catalog for where it appears, a profile for what +it is bound to out of the box. + +A profile is a whole keymap, never a delta - applying one replaces everything, so +there is no base layer to reason about. The shipped profiles carry their bindings +inline rather than pointing at bind files, so a consumer reads one shape whether the +profile came from this file or from the player's own. + +A player's own file can still `keyload` the bind files those profiles replaced, which is what +`keybind_retired_includes.json` is for. A preset path resolves by name to the profile that +covers it, but the fragments the presets pulled in - the chat and UI keys, the grid menu, the +number row - name no profile, so without their contents a migration would drop every binding +they held. It is a frozen record of files that no longer exist, not something to keep in step +with the profiles. + +Every shipped profile is selectable and read-only; editing one forks a copy under a name +the player chooses. + +## Catalog item kinds + +Everything the catalog lists is rebindable. An action a player cannot change - one bound to +a bare modifier, or handled by a widget that ships disabled - belongs in `hidden` or nowhere, +not in a category as a row that does nothing. + +Each category's `items` entry is one of: + +- `{ "action": "", "label": "" }` - one rebindable action. +- `{ "prefix": "" }` - claims every bound action whose id starts with the + prefix (for numbered families like `group select 0`, `group select 1`, ...). An optional + `"label"` is interpolated per matched action with the arg after the prefix as `%{n}` (or its + two whitespace-split tokens as `%{row}`/`%{col}`); an optional `"unit": true` resolves that + arg from a unit codename to its translated name. + +`action` is the bind command exactly as `/bind` expects and `GetKeyBindings` reports it +(command plus space-separated args, e.g. `select AllMap++_ClearSelection_SelectAll+`). The +command is lower case: the engine lower-cases it when parsing a bind line, so a capitalised +id matches nothing. + +A prefix entry may list `"members"`: the args the family covers, appended to the prefix to +form each action. Listing them makes those rows exist whether or not anything is bound, so +unbinding one leaves it there to bind again. Families that cannot be enumerated - `buildunit_` +is per unit - list none and are discovered from what is bound instead. + +An entry may carry `"alwaysModifier"`, naming a modifier the action always tolerates so no +surface shows it or lets the player pick it: + +- `"any"` binds with the engine's `Any+` qualifier and fires whatever is held. The engine + forces this for its own stateful commands (`drawinmap`, `move*`) regardless. +- `"shift"` has no engine equivalent, so the binding is written twice, bare and `Shift+`, + and both halves move together. Such an action holds exactly one key, not a list. + +An entry, action or prefix, may carry `"icon"`: the VFS path of a picture for the action, +drawn on its key in the editor's keyboard overview (and wherever else a surface has room for +one). Without one, an order shows the cursor it is already known by in game, and anything +else shows no picture; the field exists so actions can be given pictures as art for them is +made, without any surface changing. + +A category may carry `"layout": "grid"`, drawn as the grid menu's own 3x4 arrangement rather +than a flat list so the keys read the way they sit on screen. + +A single leading `{ "hidden": ["", ...] }` entry (not a category) lists actions +that are bound but never shown - matched by exact id, not prefix, so a future action can't be +suppressed by coincidence - so they surface neither as a row nor under "Other". + +## Ordering + +Two orderings, meaning different things. + +**Catalog order is presentation.** Where an item sits decides where it appears in an +editor and nothing else. Reorganize freely. + +**Bind order in `keybind_defaults.json` is precedence.** The array is written out as bind +lines in order, the engine stamps each with an incrementing index, and two actions on one +keyset are tried in that order - first to succeed wins. Position matters only against +other binds on the same keyset; where a bind sits in the file overall does not. + +When adding a bind to a shipped profile: + +- On a keyset nothing else uses, position is free. Put it next to related binds. +- On a keyset that already carries an action, the earlier entry gets first refusal. Place + it above only if it should win. +- Do not reorder existing binds to tidy the file. That silently changes precedence. + +A handler that declines (returns falsy) does not hold the key - the next action on that +keyset is tried. Ordering only settles contests between handlers that would both succeed, +so "the wrong thing fires" is not automatically an ordering problem. + +`priority` is the override for when the natural order is wrong: action prefixes, highest +first, applied when a profile is written out. It is a stable sort, so listing an action +moves that action and leaves everything else where it was. It ships empty, because preset +order already encodes the intended precedence - add to it only for a case you can point at. + +## The config contract (behavior each surface implements) + +Structure lives in the schemas; these are the operations, which a schema can't express. +Every surface answers the same questions from the same facts. + +The player's own profiles live in `LuaUI/Config/keybind_profiles.json`, in the same +shape as the shipped ones plus an `active` field naming the selected profile. That file +is per-install rather than shared, but its format is the contract - a surface that can +read one can read the other. + +A player's profile carries `basedOn`, the name of the profile it is compared with: +recorded when it was forked or duplicated, and otherwise (imported, or made before the +field existed, or naming a profile that no longer exists) inferred on load as the shipped +profile it differs from on the fewest actions, and written back. That is what lets a +surface say which keys the player changed and what the default was. The player can point +it at any other profile, shipped or their own, or at `"none"`, which means no comparison +and is the one value loading leaves alone rather than replacing with a guess. + +A shipped profile may carry `description`, an i18n key for a sentence saying what the +profile is for, shown wherever a surface lets the player pick one. + +A profile travels as text in the bind-file form the engine loads, headed by a +`// keybind editor profile: ` comment: that is what the in-game Export copies to +the clipboard and what Import reads back, and the same text a player would put in +`uikeys.txt` by hand. + +- **Which profile are we on?** Read `active` from the player's profile store. If it names + nothing that exists in either file, fall back to the first shipped profile. +- **Apply a profile.** Write its binds out as `bind ` lines with a leading + `fakemeta `, point the engine config string `KeybindingFile` at that file, and + reload. Reloading clears the keymap first, which is why a profile has to define every + binding it wants. It does not clear the meta key, so always write that line: leave it out + and whatever the last profile set stays. A profile naming no key wants the engine's own, + `space`; `fakemeta none` asks for no Meta modifier at all. +- **Edit a binding.** Only in the player's own profiles. Shipped profiles are read-only, + so the first edit made while one is selected forks it into a copy and edits that. +- **Create / rename / delete.** Names are the identity, so they must stay unique across + both files; disambiguate rather than overwrite. Deleting the active profile means + falling back and applying whatever is left. + +### Same rules, different plumbing + +| Operation | In-game (LuaUI) | Lobby (Chobby / web) | +|---|---|---| +| Read the profile set | `VFS.LoadFile` both JSON files | read the same two files | +| Apply a profile | write `uikeys.txt`, `Spring.SetConfigString`, `keyreload` | write `uikeys.txt` and the config value the game reads on launch | +| Persist an edit | snapshot `Spring.GetKeyBindings` back into the active profile | rewrite the profile entry directly | + +## i18n + +The catalog carries i18n *keys*, not resolved strings. The ones it names live in +`language/en/commands.json`, in three namespaces: `commands` for things that also appear on +the command card (names and tooltips), `actions` for everything else a player can bind, and +`categories` for the group titles. The loader globs every json in `language//`, so the +namespaces merge into one lookup and Transifex picks the file up from the directory filter. + +Nothing is written twice. A row whose action *is* a command points at the `commands` entry +rather than repeating the string, which is why the wording there follows the command card. +Two lookups still leave the file: the grid menu's category names stay in `ui.buildMenu.*` +where that menu owns them, and `buildunit_` rows resolve `units.names.*` out of `units.json`. + +The editor's own UI - buttons, dialogs, prompts - is not vocabulary and stays in +`interface.json` under `ui.keybinds.editor.*`. A surface that builds its own UI needs the +three namespaces above and none of that. + +## Not covered yet + +- A widget/mod action-declaration API, so widgets register their own bindable actions + (with label + category + description) into the catalog at runtime instead of only being + editable when already bound. +- Command descriptions for every action. A catalog item may carry `description`, an i18n + key for a tooltip sentence; without one the in-game editor falls back to the command + card's tooltip (`commands._tooltip`, for a row labelled `commands.`) and then + to the engine's command description (`cmd.`, `cmd.._description` for the + structured ones, `cmd.luarules.` for gadget commands). Roughly a third of the + catalog still has none of those. Widget/mod actions have no `cmd.*` entry, so their + descriptions depend on the declaration API above. diff --git a/common/feature_scatter.lua b/common/feature_scatter.lua index 3383cbf8718..17fd827dd22 100644 --- a/common/feature_scatter.lua +++ b/common/feature_scatter.lua @@ -139,6 +139,23 @@ local function insideLocal(dx, dz, radius, shape, lengthScale) return ok end +---------------------------------------------------------------- +-- Scale variation +---------------------------------------------------------------- +-- Bottom-heavy roll: skewing a uniform draw by t^1.5 lands most features near +-- scaleMin with a thinning tail toward scaleMax, which is the size distribution +-- of a natural stand -- many small trees, few large ones. +local function rollScale(rng, smin, smax) + smin = smin or 1 + smax = smax or smin + if smax <= smin then + return smin + end + local t = rng:next() + t = t * sqrt(t) + return smin + (smax - smin) * t +end + ---------------------------------------------------------------- -- Distribution: random ---------------------------------------------------------------- @@ -227,7 +244,15 @@ end -- Gaussian offset, ~25% scatter freely. A minimum separation derived from the -- selected defs' own collision radii prevents exact overlaps, so large features -- naturally space out while small ones pack tightly. -local function generateClustered(radius, shape, lengthScale, count, defNames, rng) +-- +-- When a scale range is set, scale is correlated with distance to the nearest +-- nucleus -- large features in the clump core, saplings at the fringe -- and the +-- pair-separation test uses the two candidates' scales, so small features pack +-- tighter than large ones. Both are what makes a scatter read as a grown stand +-- rather than dice rolls. +local function generateClustered(radius, shape, lengthScale, count, defNames, rng, scaleMin, scaleMax) + scaleMin = scaleMin or 1 + scaleMax = scaleMax or 1 local minSpacing = 4 for i = 1, #defNames do local def = FeatureDefNames[defNames[i]] @@ -244,7 +269,6 @@ local function generateClustered(radius, shape, lengthScale, count, defNames, rn minSpacing = radius / sqrt(count * 2) end minSpacing = max(4, minSpacing) - local minSq = minSpacing * minSpacing local numClusters = max(2, min(6, floor(sqrt(count) * 0.7 + 0.5))) local clusterCenters = {} @@ -264,13 +288,16 @@ local function generateClustered(radius, shape, lengthScale, count, defNames, rn local sigma = radius / max(1, #clusterCenters) * 1.2 local RANDOM_FRAC = 0.25 + local hasScale = scaleMax > scaleMin + local scaleRange = scaleMax - scaleMin + local positions = {} local maxAttempts = count * 15 local tries = 0 while #positions < count and tries < maxAttempts do tries = tries + 1 - local px, pz + local px, pz, ps local valid = false if rng:next() < RANDOM_FRAC then @@ -278,6 +305,7 @@ local function generateClustered(radius, shape, lengthScale, count, defNames, rn local rz = (rng:next() * 2 - 1) * radius * lengthScale if insideLocal(rx, rz, radius, shape, lengthScale) then px, pz = rx, rz + ps = hasScale and rollScale(rng, scaleMin, scaleMax) or 1 valid = true end else @@ -288,19 +316,32 @@ local function generateClustered(radius, shape, lengthScale, count, defNames, rn local ang = rng:next() * TAU px, pz = cc[1] + mag * cos(ang), cc[2] + mag * sin(ang) valid = insideLocal(px, pz, radius, shape, lengthScale) + if valid then + if hasScale then + -- Core of the clump = big, fringe = small, plus jitter so the + -- gradient does not read as concentric rings. + local closeness = 1 - min(1, mag / (sigma * 1.5)) + local t = closeness + (rng:next() - 0.5) * 0.4 + t = max(0, min(1, t)) + ps = scaleMin + scaleRange * t + else + ps = 1 + end + end end if valid then local tooClose = false for i = 1, #positions do local ddx, ddz = px - positions[i][1], pz - positions[i][2] - if ddx * ddx + ddz * ddz < minSq then + local need = hasScale and minSpacing * 0.5 * (ps + positions[i][3]) or minSpacing + if ddx * ddx + ddz * ddz < need * need then tooClose = true break end end if not tooClose then - positions[#positions + 1] = { px, pz } + positions[#positions + 1] = { px, pz, ps } end end end @@ -312,9 +353,9 @@ end -- Public: layout generation ---------------------------------------------------------------- ---Build a brush-relative layout. Deterministic for a given (seed, params). ----@param params table shape, radius, rotation, count, distribution, rotRandom, defNames, lengthScale +---@param params table shape, radius, rotation, count, distribution, rotRandom, defNames, lengthScale, scaleMin, scaleMax ---@param seed number ----@return table layout array of { dx, dz, defName, heading } +---@return table layout array of { dx, dz, defName, heading, scale } local function generateLocal(params, seed) local defNames = params.defNames or {} if #defNames == 0 then @@ -327,12 +368,20 @@ local function generateLocal(params, seed) local lengthScale = params.lengthScale or 1.0 local count = max(1, floor(params.count or 1)) local distribution = params.distribution or "random" + local scaleMin = params.scaleMin or 1 + local scaleMax = params.scaleMax or 1 + if scaleMax < scaleMin then + scaleMin, scaleMax = scaleMax, scaleMin + end + -- Entries only carry a scale when the brush actually varies it, so the wire + -- format (and save files) of a never-scaled map stay byte-identical. + local hasScale = scaleMin ~= 1 or scaleMax ~= 1 local positions if distribution == "regular" then positions = generateRegular(radius, shape, lengthScale, count, rng) elseif distribution == "clustered" then - positions = generateClustered(radius, shape, lengthScale, count, defNames, rng) + positions = generateClustered(radius, shape, lengthScale, count, defNames, rng, scaleMin, scaleMax) else positions = generateRandom(radius, shape, lengthScale, count, rng) end @@ -348,11 +397,18 @@ local function generateLocal(params, seed) local layout = {} for i = 1, #positions do local wx, wz = rotatePoint(positions[i][1], positions[i][2], angleDeg) + -- Clustered rolled its scales during generation (clump-correlated); the + -- other distributions roll here. + local s = nil + if hasScale then + s = positions[i][3] or rollScale(rng, scaleMin, scaleMax) + end layout[#layout + 1] = { dx = wx, dz = wz, defName = defNames[rng:int(1, numDefs)], heading = (baseHeading + rng:int(-spread, spread)) % 65536, + scale = s, } end @@ -368,7 +424,7 @@ end ---@param centerZ number brush centre ---@param params table smartEnabled and smartFilters ---@param extraRotDeg number|nil additional rotation for this copy of the brush ----@return table placements array of { defName, x, y, z, heading, pitch, roll } +---@return table placements array of { defName, x, y, z, heading, pitch, roll, scale } -- -- `extraRotDeg` exists for symmetry. getSymmetricPositions hands back a per-copy -- `rot` that differs from the brush's own rotation (radial copies are turned by @@ -442,6 +498,7 @@ local function resolve(layout, centerX, centerZ, params, extraRotDeg) heading = (entry.heading + headingOffset) % 65536, pitch = 0, roll = 0, + scale = entry.scale, } end end @@ -454,4 +511,5 @@ return { generateLocal = generateLocal, resolve = resolve, rotatePoint = rotatePoint, + rollScale = rollScale, } diff --git a/common/holidays.lua b/common/holidays.lua index a906ebe1ffd..bc9187ce09a 100644 --- a/common/holidays.lua +++ b/common/holidays.lua @@ -7,8 +7,6 @@ if not Spring.GetModOptions().holiday_events then end local modOptions = Spring.GetModOptions() -local currentDay = modOptions.date_day -local currentMonth = modOptions.date_month local currentYear = modOptions.date_year -- Meeus's Julian algorithm Function to calculate Easter Sunday for a given year. Magic. diff --git a/common/lib_startpoint_guesser.lua b/common/lib_startpoint_guesser.lua index e62c510516f..4844ea5ec03 100644 --- a/common/lib_startpoint_guesser.lua +++ b/common/lib_startpoint_guesser.lua @@ -14,27 +14,18 @@ local mathMax = math.max local mathClamp = math.clamp local spGetGroundHeight = Spring.GetGroundHeight -local ParseBoxes = VFS.Include("luarules/gadgets/include/startbox_utilities.lua") +local StartboxLib = VFS.Include("luarules/gadgets/include/startbox_utilities.lua") local PolygonLib = VFS.Include("common/lib_polygon.lua") -local startboxConfig, startboxConfigExplicit, startboxConfigParsed - -- The bounds handed to these functions are the polygon's bounding box, so a box that is not -- a rectangle needs the shape itself to decide what is inside it. local function GetStartboxEntry(allyID) - if not startboxConfigParsed then - startboxConfigParsed = true - local ok, config, _, explicit = pcall(ParseBoxes) - if ok then - startboxConfig, startboxConfigExplicit = config, explicit - end - end - - if not (startboxConfigExplicit and startboxConfig) then + local config, _, explicit = StartboxLib.GetConfig() + if not (explicit and config) then return nil end - return startboxConfig[allyID] + return config[allyID] end local function IsInStartbox(allyID, x, z, xmin, zmin, xmax, zmax) diff --git a/common/luaUtilities/base64.lua b/common/luaUtilities/base64.lua index ebcc3f10b2e..63f499996ea 100644 --- a/common/luaUtilities/base64.lua +++ b/common/luaUtilities/base64.lua @@ -39,8 +39,7 @@ local base64chars = { [55] = '3', [56] = '4', [57] = '5', [58] = '6', [59] = '7', [60] = '8', [61] = '9', [62] = '-', [63] = '_' } --- function encode --- encodes input string to base64. +-- encodes input string to base64url with = padding. local function base64Encode(data) local bytes = {} local result = {} @@ -61,6 +60,8 @@ local function base64Encode(data) end -- decoding table +-- The url alphabet ('-' and '_') is what base64Encode above emits and what the modoptions transport specifies. +-- '+' and '/' are accepted as aliases, making this decode both base64 and base64url. local base64bytes = { ['A'] = 0, ['B'] = 1, ['C'] = 2, ['D'] = 3, ['E'] = 4, ['F'] = 5, ['G'] = 6, ['H'] = 7, ['I'] = 8, ['J'] = 9, ['K'] = 10, ['L'] = 11, ['M'] = 12, ['N'] = 13, ['O'] = 14, ['P'] = 15, ['Q'] = 16, ['R'] = 17, ['S'] = 18, @@ -69,12 +70,39 @@ local base64bytes = { ['l'] = 37, ['m'] = 38, ['n'] = 39, ['o'] = 40, ['p'] = 41, ['q'] = 42, ['r'] = 43, ['s'] = 44, ['t'] = 45, ['u'] = 46, ['v'] = 47, ['w'] = 48, ['x'] = 49, ['y'] = 50, ['z'] = 51, ['0'] = 52, ['1'] = 53, ['2'] = 54, ['3'] = 55, ['4'] = 56, ['5'] = 57, ['6'] = 58, ['7'] = 59, ['8'] = 60, ['9'] = 61, ['-'] = 62, ['_'] = 63, + ['+'] = 62, ['/'] = 63, ['='] = nil, } --- function decode --- decode base64 input to string +-- Anything outside the alphabet, padding aside. +local UNEXPECTED_CHARACTER_PATTERN = "[^%w=_+/-]" + +-- Decoding is best-effort for compatibility, but warns about invalid chars +local function warnUnexpectedCharacters(data) + local seen = {} + local characters = {} + + for character in data:gmatch(UNEXPECTED_CHARACTER_PATTERN) do + if not seen[character] then + seen[character] = true + characters[#characters + 1] = string.format("%q", character) + end + end + + Spring.Log("base64", LOG.WARNING, string.format( + "Decoding input with %d unexpected character(s) (%s); they are dropped, so the result is truncated.", + #characters, + table.concat(characters, ", ") + )) +end + +-- decode base64 or base64url input to string local function base64Decode(data) + + if data:find(UNEXPECTED_CHARACTER_PATTERN) then + warnUnexpectedCharacters(data) + end + local chars = {} local result = {} local resultCount = 0 diff --git a/common/luaUtilities/modoption_payload.lua b/common/luaUtilities/modoption_payload.lua new file mode 100644 index 00000000000..5ea119ef4aa --- /dev/null +++ b/common/luaUtilities/modoption_payload.lua @@ -0,0 +1,44 @@ +-- modoptions decoder. +-- Expected format: base64url(zlib(json)), but zlib is optional + +local base64 = VFS.Include("common/luaUtilities/base64.lua") + +local function decodeJson(text) + if not text or text == "" then + return nil + end + + local ok, parsed = pcall(Json.decode, text) + if not ok or type(parsed) ~= "table" then + return nil + end + + return parsed +end + +local function Decode(raw) + if type(raw) ~= "string" or #raw == 0 then + return nil + end + + local okDecode, decoded = pcall(base64.Decode, raw) + if not okDecode or not decoded or decoded == "" then + return nil + end + + -- VFS.ZlibDecompress raises on non-zlib or empty input rather than returning nil. + local okZlib, decompressed = pcall(VFS.ZlibDecompress, decoded) + if okZlib and decompressed then + local parsed = decodeJson(decompressed) + if parsed then + return parsed + end + end + + -- Uncompressed payload: the base64 already yielded the json. + return decodeJson(decoded) +end + +return { + Decode = Decode, +} diff --git a/common/overlap_lines.lua b/common/overlap_lines.lua index a01bce1dc5b..e179044d4b4 100644 --- a/common/overlap_lines.lua +++ b/common/overlap_lines.lua @@ -135,10 +135,6 @@ local function findLineIntersection(p1, p2, p3, p4) return nil end -local function getSide(p, lineP1, lineP2) - return (lineP2.x - lineP1.x) * (p.z - lineP1.z) - (lineP2.z - lineP1.z) * (p.x - lineP1.x) -end - local function getSideXZ(px, pz, lineP1, lineP2) return (lineP2.x - lineP1.x) * (pz - lineP1.z) - (lineP2.z - lineP1.z) * (px - lineP1.x) end diff --git a/common/springUtilities/safeluaparser.lua b/common/springUtilities/safeluaparser.lua index a827ef38753..d281e89bef0 100644 --- a/common/springUtilities/safeluaparser.lua +++ b/common/springUtilities/safeluaparser.lua @@ -147,7 +147,6 @@ local function _safeLuaTableParserInternal(text) end -- Count the number of '=' characters between the brackets - local start = pos pos = pos + 1 local equalCount = 0 @@ -375,7 +374,6 @@ local function _safeLuaTableParserInternal(text) -- Find the matching closing parenthesis for the function call local parenDepth = 1 - local callStart = pos pos = pos + 1 local inString = false local stringChar = nil diff --git a/common/springUtilities/teamFunctions.lua b/common/springUtilities/teamFunctions.lua index b7276255e4d..7ef45678d1d 100644 --- a/common/springUtilities/teamFunctions.lua +++ b/common/springUtilities/teamFunctions.lua @@ -216,19 +216,19 @@ return { return getSettings().isHoliday end, }, - ---@return integer? scavTeamID Team ID for the scavenger team. + ---@return TeamID? scavTeamID GetScavTeamID = function() return getSettings().scavTeamID end, - ---@return integer? scavAllyTeamID Team ID for the scavenger ally team. + ---@return AllyTeamID? scavAllyTeamID GetScavAllyTeamID = function() return getSettings().scavAllyTeamID end, - ---@return integer? raptorTeamID Team ID for the raptor team. + ---@return TeamID? raptorTeamID GetRaptorTeamID = function() return getSettings().raptorTeamID end, - ---@return integer? raptorAllyTeamID Team ID for the raptor ally team. + ---@return AllyTeamID? raptorAllyTeamID GetRaptorAllyTeamID = function() return getSettings().raptorAllyTeamID end, diff --git a/common/tablefunctions.lua b/common/tablefunctions.lua index 2a5aaf95044..8fe05084c97 100644 --- a/common/tablefunctions.lua +++ b/common/tablefunctions.lua @@ -79,15 +79,22 @@ if not table.sortStable then ---This method preserves elements' original order when possible, unlike `table.sort`. ---@generic T ---@param tbl T[] - ---@param compare fun(a: T, b: T) : boolean|nil where true := less than, false := greater than, nil := equal to + ---@param compare? fun(a: T, b: T) : boolean|nil where true := less than, false := greater than, nil := equal to table.sortStable = function(tbl, compare) if not compare then compare = compareDefault end - local index = table.getKeyOf -- local speedup + + local originalIndex = {} + for index = 1, #tbl do + originalIndex[tbl[index]] = index + end table.sort(tbl, function(a, b) local comparison = compare(a, b) - return comparison or (comparison == nil and index(tbl, a) < index(tbl, b)) + if comparison ~= nil then + return comparison + end + return originalIndex[a] < originalIndex[b] end) end end diff --git a/doc/TerraformBrush-changelog.md b/doc/TerraformBrush-changelog.md index fe19e31b62d..6ab98ee75d7 100644 --- a/doc/TerraformBrush-changelog.md +++ b/doc/TerraformBrush-changelog.md @@ -4,6 +4,114 @@ Release history for the Terraform Brush map-editing suite. Version numbers follow the improvements-branch scheme (`tf-brush-improvements-N` up to 1.10, `tf-improvements-N` from 1.11): branch `N` corresponds to release `1.N`. Only versions merged into the upstream Beyond All Reason repository are listed as releases. Intermediate development branches that were folded into a later release are noted separately. +## 1.14 - 2026-09-04 + +### New + +- The Open Project browser gained a search box (matches the name, the folder path or the NxN size), RECENT / NAME / SIZE sort chips and a folder tree. Projects may live in subfolders of MapProjects/ up to four levels deep, and a project name may contain "/" to save into one, so a git clone of a maps repository placed inside MapProjects/ lists as it is on disk and pulls straight into the browser. The date column reads as an age ("3 h ago", "yesterday"); RECENT orders by the last open or save through the editor, not only the last save; and a project saved this session, or freshly cloned and opened once, lists even while the engine's folder snapshot cannot see it yet. + +- Sun & Shadows gained PRESETS (requested by PtaQ and MrBob): six sun-only times of day (Dawn to Overcast: direction, intensity, sun colours and shadow densities, nothing else), the harvested map moods the New Map wizard offers, and your own saved files. Save writes the whole live environment under a name to Terraform Brush/Environments/, so a look carries to every map and session; a SUN ONLY / FULL ENVIRONMENT switch decides what a click applies. The sun direction also has AZIMUTH and ELEVATION sliders next to the vector rows. +- `/tf_sunlog` logs every sun write from any widget with a traceback, for finding out what reset a sun. +- SURFACE and LAYERS gained an INFLUENCE section (requested by PtaQ and MrBob): an altitude band and a slope band, each with a feather, that scale a stroke instead of cutting it the way the FILTERS do, so a texture does more of its thing in the lowlands or on the flats and fades out beyond them. SURFACE remembers a profile per texture (it follows the texture across slots and biome swaps, projects keep it in surface.lua, and Copy to all stamps it onto every slot); LAYERS keeps one per channel. Erasing is never scaled, and the Ctrl sneak peek shows the band so what you see is what lands. + +- SURFACE > FILL AND SEED gained an AUTOMATIC DEPOSIT block (requested by PtaQ and MrBob, for Teizer's craters): a SURFACE variant slot the shader claims on its own where wind-blown sand would gather, on the lee side of slopes relative to a wind direction and in the pockets the intermediary already reads. It only fills ground no stroke claimed, so paint wins; cliffs stay clean; and it is off until a slot is chosen. It is an authoring control, so it sits with the other fill tools rather than in the tileset config; the values still save with the tileset knobs. +- SURFACE > GRADING gained SELECTED SLOT tints: a per-texture albedo tint for the armed variant, remembered by texture like FLIP, saved with the project in tileset.lua, and independent of the TOPS group tint that moves every top together. (The Teizer tileset briefly borrowed two grass tops and tinted a desert top green as an oasis stand-in; both were walked back the same day, the oasis is an asset-side job.) +- The Tileset window's METAL SPOTS glow light gained the LIGHTS tool's point-light controls (requested by PtaQ): INTENSITY, RADIUS and HEIGHT sliders, the colour swatch palette with a preview bar and RED / GREEN / BLUE sliders. The block grays out while the glow is off. The settings are tileset knobs, so they save with the project, ride in presets and come back on a section RESET; picking a style reseeds the colour, intensity and radius from the style, and the on/off state now persists too. +- METAL SPOTS gained a BAND WIDTH slider (requested by PtaQ): how far the silhouette band, the rubble skirt that Band layer and Band tone shape, reaches around a spot. 1 is the previous look; below it the skirt tightens, above it the band spreads to roughly four times the old reach. It saves with the tileset knobs and a style pick or section RESET restores it. +- The Tileset window gained a WORLDSPACE TINT (WIP) section (requested by PtaQ; the GRADIENT STOPS by MrBob): world-height tinting as one system with a wide look range, from the old three-colour grade to a layered canyon. One shared AXIS feeds every lever: Auto rides the map's height reference, so a flat canvas stays untinted, and Manual pins it to elmos and can be tilted along a direction and wobbled. GRADE is the old three stops as colour chips with a movable split. STRATA lays repeating tint beds along the axis: one to eight beds, their period, phase, hardness (a soft crossfade to a knife edge), wobble and per-bed jitter, with BASE / INTER / CLIFF / PLAT chips choosing which layers take them and a colour chip per bed. GRADIENT STOPS is MrBob's array of two to eight hue / saturation / value entries spread evenly over the height, in Multiply or Colorize mode. RAMP samples a painted gradient image along the axis (a PNG from Terraform Brush/Ramps/, picked from a file list with Rescan folder and Clear) in Multiply or Tint mode, with a strength and a repeat count. SATURATION scales colour from base to top, TIDE RINGS darkens rings above the waterline, and SNOW LINE whitens the tops above a height with a band, a slope limit and its own colour. Every colour chip (grade stops, beds, gradient stops, snow) selects what one shared COLOUR editor edits: the swatch palette, a preview bar, and Red / Green / Blue plus Hue / Sat / Value sliders that work for every chip whichever way it is stored. Each sub-group has its own RESET and the header RESET takes them all; the chips follow presets, project loads and console changes. The ramp file is saved with the project next to the biome. Needs tileset shader 0.27; with the shipped defaults a map renders exactly as it did. It is marked WIP: it will get more work in a later release. + +- SHAPE gained a FOLLOW STROKE chip (requested by MrBob and PtaQ): the brush shape turns to the direction you are dragging, so a square, hexagon or triangle leaves a ribbon with its flat sides along the path instead of a chain of same-angle stamps. The angle is smoothed over the last few dabs, so mouse jitter cannot spin the shape, and quantised to 2 degrees, the step the brush's own falloff cache keys on. The protractor snaps it to the spoke grid when both are on, the brush ring shows the angle that will land, and your own rotation comes back when the stroke ends. The chip is offered for the sculpt modes only (raise, lower, level, smooth, smudge): the other tools sharing the SHAPE row stamp rather than stroke. +- A CLAY SCULPT preset ships MrBob's sculpting setup from the campaign terrain tutorial: clay on, intensity at the floor, the sharpest falloff, square with FOLLOW STROKE. It is the single brush he makes a whole map with. +- Every DISPLAY row gained a PASSABILITY chip: it tints ground steeper than a move class in the engine's impassable purple, so cliff height can be judged while sculpting without selecting a unit and pressing F6. Clicking cycles BOT, VEH, HOVER, AMPH and off; the slopes come off the real movedefs, so the band matches what F6 draws. Needs the tileset shader. +- Every DISPLAY row also gained an IMAGE chip (requested by PtaQ): lay a reference image over the whole map, a real coastline to sculpt against or a transparent sheet of guide lines, and paint over it. Images are read from Terraform Brush/Overlays/ in the install folder (PNG, JPG, TGA, BMP or DDS); alpha is kept, so only the lines of a transparent sheet show. The gear next to the chip (or a right click on it) opens the IMAGE OVERLAY window with the file list and the placement controls: OPACITY, OFFSET X / Y and SCALE sliders that preview live on the ground while you drag, STRETCH or KEEP ASPECT, FLIP H / V and a RESET; a left click on the chip toggles the overlay once an image is loaded. The image is projected onto the ground through the map's depth, so it hugs every slope at any map size for one fullscreen pass, and units and features draw over it. The pick and placement survive a reload. `/tfimage ` picks a file from the console, `/tfimage` alone toggles it, `/tfimage off` unloads it. +- SURFACE gained SCATTER: position, size and strength jitter per stamp, on top of SPACING. With spacing near one and a half brush widths, one drag lays the hand-placed dot field the texturing pass wants instead of a solid band. The DOT preset now carries a generous falloff and the scatter, matching MrBob's texturing brush. +- The SURFACE palette captions each texture with its role (TINT, NORMAL and so on). A tileset manifest can name a role per texture; without one the tutorial's numbering convention is used, where 002 is the tint accent and 003 the sculpted-normal style layer. +- The header gained FOCUS MODE, an eye button next to the pause button (requested by PtaQ): it hides the game interface the way F5 does but leaves the editor alive. The panel, the brush preview, the grid and water overlays and skybox switching all keep working, so a map can be looked at, sculpted and screenshotted without the HUD. Skybox picks used to wait for the HUD to come back, because they were applied from the one draw call-in the interface hide skips; they now apply from DrawScreenPost, as do the New Map reload's environment preset, fog-off and forcestart, the heightmap import and export jobs, and the project Save / Open driver, none of which would have run under a hidden interface. Closing the panel, quitting the editor, a `/luaui reload` or F5 all end focus mode and hand the interface back; hiding the panel with its hotkey does not, so focus plus a hidden panel is the clean-screenshot setup. A map capture started in focus mode still comes out clean and leaves the interface hidden afterwards. +- A New Map and an opened project bring the Terraformer up by themselves (requested by PtaQ): the brush arms in RAISE the moment the canvas is playable, at the New Map's forcestart and at the end of a project's load phases, unless a tool is already up. + +### Improvements + +- Sculpt drags cost a fraction of what they did (reported by huskyvoiceyui and Moose on big maps). The gadget now applies every dab of a tick against a working copy of the cells it touches and commits once per tick, so the engine's terrain recalculation (mip heightmaps, normals, slopes, pathing, LOS, the normal and shading textures, mesh patch updates) runs once per tick per symmetry copy instead of once per dab (up to 48 times), the undo stack gets one entry per tick instead of one per dab (a stroke's undo is that much cheaper too), and the per-cell engine reads drop to one per tick. Non-clay results are unchanged to floating-point noise. +- The falloff-stamp cache no longer keys the circle and ring on rotation (FOLLOW STROKE with the default circle was rebuilding an identical stamp every 2 degrees of tangent), holds stamps by a cell budget instead of a fixed four, and steps very large shaped stamps at a coarser angle so a FOLLOW drag at big radius cannot rebuild a 100k-cell stamp per dab. +- The ground mesh refresh is now armed from the engine's own heightmap-update event instead of ten frames per brush tick, which had the whole visible mesh re-tessellating every frame for the length of a drag (the bigger the map, the more it cost). The panel mirrors terraform state at a quarter rate while the brush is down on the world, and its per-frame slider writes go through the dirty-check helper. +- Settings > General gained a persisted Performance mode toggle (requested by PtaQ for the mapmaking artists): wider dab spacing where the falloff can take it (a quarter radius for soft curves and clay, a fifth up to curve 2.0, unchanged above), 32 dabs per tick instead of 48, 6-degree FOLLOW STROKE steps, and the panel readouts strided all the time. Its description names the two free levers, pausing the game and Focus mode. +- Sculpt strokes follow the path the mouse actually drew. The brush sampled the cursor 20 times a second and bridged a straight line between samples, so a fast scribble arrived with its corners cut and its dabs delivered in visible bursts. Every mouse move is now recorded and the tick walks that polyline, dropping dabs at the brush's own spacing. +- Clay strokes deposit per distance travelled, not per tick. A tick's intensity used to be divided by the number of dabs it contained, so the faster you moved the less clay landed, to the point where a fast pass left almost nothing. A whole tick of dabs is now sent as one message and the gadget derives every clay plane from the heightmap as it was before the tick, so speed no longer removes the stroke and overlapping dabs still cannot compound past one brush step. It also cuts up to 48 network messages a tick down to one. +- SURFACE brush size steps by a tenth of the current size per scroll notch instead of a flat 8 elmos, so a small brush can be tuned (reported by MrBob). +- The New Map wizard's ENVIRONMENT pick opens on Default again, and Default now means the canonical editor sun (requested by PtaQ). It used to mean "leave the engine lighting alone", which is placeholder lighting - ground ambient and diffuse both a flat 0.5 against about 0.99 on a real daylight map - so the wizard had been pointed at a harvested mood to work around it, and every new map inherited that map's water, fog and sky with it. Default now applies the sun and nothing else; the harvested moods are still in the list. +- SURFACE altitude bounds gained SAMPLE buttons like the raise tool's HEIGHT CAP (requested by PtaQ): the FILTERS Alt min / Alt max rows in both modes and the INFLUENCE Alt min / Alt max rows arm the height sampler, the next click on the terrain (or on a colormap contour) writes that height into the bound, pushes the other bound along if the band would invert, and switches the filter or band on. +- Sun & Shadows PRESETS trimmed to three (requested by PtaQ): Canonical, Dusk and Overcast. Canonical is PtaQ's saved editor sun and is also the New Map wizard's Clear Daylight sun. +- Project folders and names may contain spaces (requested by PtaQ): a cloned maps repository with spaces in its folder names now lists in Open Project, and Save As accepts them; a name still cannot start or end with a space. +- Open Project rows are set larger and the window is wider, so project names read at a glance (requested by PtaQ and MrBob). +- The sun sliders keep the applied intensity on every nudge (the engine defaults a missing intensity to 1.0), re-assert the ground and unit shadow densities separately instead of flattening them to the ground value, and restamp each other so the vector rows and the azimuth/elevation rows never disagree. + +### Fixes + +- Clay strokes no longer leave concentric rings (reported by huskyvoiceyui and Moose). The clay plane is measured on the surface the stroke started on (the pre-stroke heights, averaged over the dab centre and four taps half a radius out), so every dab of a stroke agrees on one plane and the stroke lays exactly one layer of INTENSITY x 8 elmos. The old plane re-measured the live centre height, which from the second tick on was the disc the previous tick had just raised, so every tick stacked another disc one layer up and the disc edges showed as rings (a 40-tick slow drag at intensity 10 piled 2700 elmos). The stacking survives as Settings > Stroke > Clay build-up for anyone who wants a held brush to keep piling. +- The SURFACE Ctrl sneak peek works at every zoom again (reported by PtaQ). It only exists in the shader's live stack, and last release's far cache and clipmap take over the ground as soon as the camera pulls back, so the preview quietly stopped past a close zoom. A pixel under the brush now takes the live path whatever the distance, and the cache-only shader program stands down while a peek is up. +- DISPLAY chip rows wrap onto a second line instead of squeezing every label onto two lines when a tool has five chips (reported by PtaQ). +- Text fields that could not be typed into now can (reported by Moose): the Open Project filter, the Light Library filter and its preset name box, the grass blade and colour-mod texture paths, and the Lights orientation pitch/yaw boxes. A field only receives the keyboard if it starts SDL text input when focused, and these five were added without it, so the game ate every keystroke. Every text field in the panel now goes through one helper instead of each copying the same eight lines. +- The sun config survives a project open and a preset (requested by PtaQ and MrBob). Three paths could still lose it: the ENV panel's RESET buttons returned to the lighting the engine held when the panel first opened, which on a canvas is the flat blank-map default, and are now re-anchored after every project or preset apply; a preset without an intensity (the map moods have none) forced it back to 1.0 and now keeps the session's; and a skybox fade in flight restored the sun colours it had captured over a config applied mid-fade, and is now retargeted at the new colours. +- Editor sessions no longer spawn commanders (requested by PtaQ and MrBob). New Map and Open Project start scripts carry an `editor_sandbox` flag; the initial-spawn gadget skips the commander for those sessions and the game-end gadgets stand down on the same flag. A project load runs in pregame, so it used to wipe the units before any commander existed and then start the game, which spawned one per team at a guessed spot; New Map spawned one the moment the session started. Editor canvases now also leave pregame on their own a few frames after boot, so terrain raised above the canvas base is clickable without a manual start. + +## 1.13 - 2026-09-02 + +### New + +- The Tileset window gained a PERFORMANCE section: HIGH, MEDIUM and LOW quality presets that drop draw-time features of the tileset shader, and the levers underneath them as sliders (foothills, stagger, far anti-tiling, cliff-tap slack, the far cache with its start and band, "Cliffs cached past" for the distance at which steep faces join the cache, and the clipmap toggle). A preset applies on top of the sliders, so HIGH is where each lever can be judged on its own. +- The BIOME LIBRARY tiles are built at runtime from the tileset shader's biome manifests (one Lua file per biome in the shader's `tilesets/` folder) instead of six hardcoded RML tiles, so a biome added on disk shows up in the picker without a UI edit. Thumbnails are GL overdraws like the EXTRA LAYER material tiles (a shipped preview drawn whole, or the base albedo as a centered crop), and each biome's skybox pick comes from its manifest instead of a name-match table in the UI. + +### Improvements + +- AUTORAMP swipe: holding the button and dragging re-fires the ramp along the path, so a whole cliff line restyles in one stroke instead of one click per segment. +- SURFACE's per-tile FLIP buttons became one FLIP chip in the NOW PAINTING strip that flips the normal map of the selected slot, BASE included. Three buttons per tile left PICK, FLIP and X too cramped to hit; the chip dims when the selected slot has no texture. +- The SURFACE texture picker asks the painter for its coverage readback only while a picker is open. The readback is a GPU sync, and its only reader is the picker's "already carries paint" warning. + +### Fixes + +- LAYERS paint, clone-tool pastes and a project's splat swap now tell the tileset shader which region changed, so painted layers no longer vanish when zooming out. The shader serves distant ground from a baked copy that sampled the splat texture at bake time; strokes widen a dirty rectangle that is flushed to the far cache and clipmap during the drag and once more when it ends, and whole-texture changes (undo, redo, load) request a full refill. +- The picture-in-picture widget no longer floods the log with GL errors on Mesa drivers: a uniform call was handed every return value of a multi-value function instead of only the ones the uniform takes. +- The FILE dropdown stays on top of the texture thumbnails (reported by Moose). Tile previews (SURFACE palette, EXTRA LAYER, BIOME LIBRARY, skybox and splat channel grids) are GL overdraws painted after the UI renders, so an open menu was drawn under them; every pass now skips tiles the menu covers. +- The header's passthrough (pause) button works with the SURFACE panel (reported by Moose). The toggle saved, deactivated and restored every tool except the surface painter, so pausing with SURFACE armed left its brush owning the mouse and clicks never reached unit selection. Both submodes now stand down on pause and re-arm on unpause, LAYERS included. + +## 1.12 - 2026-08-22 + +### New + +- The Dimensions window's HEIGHT BOUNDS became HEIGHT RANGE, with two modes. RESCALE remaps the whole relief onto a new min/max, so lowering the max compresses the terrain instead of shearing the mountain tops off; CLAMP keeps the old cut behavior for shaving a runaway peak. Both act on the whole map, are undoable like any brush stroke, and the sliders seed from the live extremes with CURRENT and RESET (the map's own range) refills. +- WATER LEVEL is now a slider with a WYSIWYG shoreline preview: dragging draws the resulting coastline in the world at that height, APPLY slides the terrain so the water lands exactly on the previewed line, and RESET returns the map to its own level. The Water window carries a mirrored FLUID LEVEL track, kept in lockstep. +- The brush cursor no longer dies at the map border. Every editor brush (terraform, splat, diffuse, grass, metal, features) follows the mouse past the edge through a shared resolver and fades out with distance, so terrain and paint right against the border are comfortable to work. The feature placer drops off-map placements per symmetry copy instead of clamping them, which used to pile features up along the edge line. +- SURFACE grew from two paintable variant slots to seven: slots 1-3 in the first mask, 4-7 in a second one. The DETAIL SLOT 3 metal-suite toggle this branch briefly carried is gone again; slot 3 is a regular slot and the metal spots always keep their material. +- DISPLAY, INSTRUMENTS and FILTERS are now canonical sections shared by the SURFACE and LAYERS submodes. The soft submode gains smart filters (avoid water, avoid cliffs, alt min/max), LAYERS gets a Layer Map overlay chip, and grid snap, protractor, measure, symmetry and the height colormap all work with the SURFACE brush. +- Sneak Peek: while it is on, holding Ctrl renders the selected layer inside the brush ring as if the stroke had landed, in both submodes, so where a texture's fixed features fall can be judged before committing. It re-arms on every entry into the tool. +- The Tileset window's EXTRA LAYER (slot 4) has its own section with a mode switch, and its texture choice is a tile grid with real albedo thumbnails instead of a prev/next name stepper. The first tile restores the biome's own pick, so the material follows biome swaps again. +- Feature Placer scale variation groundwork: Scale Min / Max sliders roll a per-feature scale at placement time, realized by snapping to pre-baked model variants since the engine exposes no feature-scale API. No variant sets ship yet, so the sliders are inert for now; the fir variants and the tree clump work are shelved on a separate branch. With clustered distribution the roll correlates size with distance to the cluster core. +- Map projects round-trip the full Tileset configuration through a new `tileset.lua` section: biome, metal-spot style, glow lights, the EXTRA LAYER material and every tuning knob survive save and open. All SURFACE slots persist too, with a second mask saved whenever slots 4-7 carry paint. +- Map projects record the skybox picked at runtime in the ENVIRONMENT panel instead of the one the canvas booted with, env configs carry the skybox path, and a project reopens with its sky even if the skybox panel was never opened that session. +- SMUDGE: a third MODIFY submode that drags terrain along the stroke, GIMP's smudge for the heightfield. A height grab is carried with the cursor and folded into the ground as it moves, so relief smears along the drag and tapers off; intensity sets how long the tail survives. L cycles SMOOTH, LEVEL and SMUDGE. +- AUTORAMP: a third RAMP type. Click an existing cliff and it is rebuilt at a chosen angle with wavy lips, ridged erosion gullies and a scree fan at the base. Cliff start anchors the face (Extend keeps the top lip, Subtract keeps the bottom one, Average pivots on the mid line), and a WYSIWYG hover preview shows the exact resulting terrain as a translucent fill/cut mesh before the click - the preview and the apply run the same seeded math. R toggles RAMP and AUTORAMP. +- The Tileset window gains a WATER section: walkable depth, shallows tint, clarity, curve, hue, saturation and power, and a deep-floor glow, driving the tileset shader's terrain-based water shading. + +### Improvements + +- The smooth brush computes a true dense box mean (summed-area table) instead of a sparse 9-tap blur. The sparse taps were blind to ripples whose wavelength matched their spacing, so grid-aligned stripes survived every smoothing pass while everything else flattened. +- The SURFACE slot rail is thumbnail-first tiles: the texture takes the tile, PICK opens the library for that slot, X clears it, and clicking a tile only arms the brush. Selecting used to also open the library, which threw the whole catalog on screen every time the brush changed. +- The SURFACE texture picker gained a large hover preview big enough to judge a material by; the coverage meter is retired, since an artist reads the ground rather than a histogram. +- The SURFACE brush modes (DOT, WASH, FILL, ERASE) are proper icon buttons matching the terrain modes. +- The Dimensions height extremes readouts poll while the window is open; the refresh button is gone. +- The metal brush's Metal Value slider stays usable in remove submode, since the erase rate scales with it. +- The MODIFY MODE row got real icons, including a hand-drawn SMUDGE glyph, at the same visual weight as the tool set. Panel icons are authored with their brightness in the RGB channels now, since RmlUi clamps mid-to-high alpha to fully opaque and alpha-authored softness rendered as solid white. + +### Fixes + +- Skybox and texture thumbnails no longer hang over the world after the panel closes. The GL overdraw passes kept rendering against stale layout boxes; they now bail when the panel is disengaged or hidden. +- The metal brush's remove submode always erases, regardless of which mouse button started the drag. +- An environment config saved while the engine reported no sun position no longer blacks out the map it is later applied to: a degenerate sun direction is neither serialized nor applied. +- The ENV sun sliders reseed after a project load applies an environment, instead of writing their stale attach-time values back through the engine on the next nudge. +- Fast brush drags no longer leave gaps between stamps: the stroke interpolator used to widen its stamp spacing past the brush radius on quick flicks (visible as evenly spaced terrain ribs with SMUDGE), and now lags the cursor at proper overlap instead, catching up over the following ticks. +- Slider restamps no longer fight the thumb mid-drag (the erode repose marble used to stick while the track still worked). +- The Feature Placer no longer crashes the engine on model-less feature defs such as the geo vent crack. + ## 1.11 - 2026-08-14 ### New diff --git a/doc/TerraformBrush.md b/doc/TerraformBrush.md index 0ab967db088..43f03c003a2 100644 --- a/doc/TerraformBrush.md +++ b/doc/TerraformBrush.md @@ -165,7 +165,11 @@ When the **Height Colormap** overlay is active, each cap row shows a **SAMPLE** ### Clay Mode -"Flat buildup" — creates plateau-like terrain with a flat top at the brush's target height rather than the standard dome falloff. Sent as a flag (`0`/`1`) in the terraform message. Toggle with `X`. +"Flat buildup" — creates plateau-like terrain with a flat top at the brush's target height rather than the standard dome falloff. Toggle with `X`. + +Each dab targets a **plane** at the stroke's reference height plus `INTENSITY × 8` elmos (raise) or minus it (lower); cells on the wrong side of the plane blend toward it by `falloff × opacity × intensity` per dab and never cross it. The reference is the **pre-stroke surface**: the heights every cell had when the stroke started, averaged over the dab centre and four taps half a radius out. A stroke therefore lays exactly one layer over the ground it started on, however slowly you drag or however much the dabs overlap, and the layer's edge follows the falloff. Measuring the plane on the live centre height instead stacked a new disc every tick, and the disc edges came out as concentric rings; that behaviour survives as **Settings > Stroke > Clay build-up** for anyone who wants a held brush to keep piling layers. + +Sent as the clay flag in the terraform messages: `0` off, `1` clay, `2` clay with build-up. Clay mode applies to **all terrain modes** (raise, lower, level, smooth, ramp, restore, noise) and **all shapes** (circle, square, triangle, hexagon, octagon, ring). In ramp mode the flattened profile applies along the full ramp length. @@ -225,10 +229,48 @@ The panel exposes a **Tools row** of icon buttons. Each tool has its own sub-pan ### Feature Placer -Distribution mode (random/regular/clustered) · Size/rotation/count/cadence sliders · Undo/redo · Save/load/clear +Distribution mode (random/regular/clustered) · Size/rotation/count/cadence sliders · Scale variation · Undo/redo · Save/load/clear **Files:** `luaui/Widgets/cmd_feature_placer.lua` · `luaui/RmlWidgets/gui_feature_placer/` +#### Scale Variation + +Scale Min / Scale Max sliders (0.1-3.0x) roll a per-feature scale at placement +time. The roll is realised by snapping to the nearest **pre-baked model +variant** of the chosen def and placing that variant instead: a def opts in by +shipping sibling defs tagged `customParams.scale_base` (the def they vary) and +`scale_factor` (their size), with the model, collision cylinder, wood value and +mass all baked at that size. Variant defs are hidden from the asset library -- +the placer reaches them only through snapping. + +**No variant sets ship yet**, so the sliders are currently inert: a def with no +variants ignores the roll and places at its normal size, and the ghost preview +shows exactly that. The fir tree variants that drove this feature are shelved on +the `feature-densification` branch along with the tree clump work, together +with the offline script that bakes them. + +- Rolls are bottom-heavy (many small, few large), matching a natural stand. +- With **Clustered** distribution, scale correlates with distance to the cluster + nucleus: big features in the core, small ones at the fringe, and the minimum + spacing scales per pair so small features pack tighter. +- Point mode rolls a scale per placement too. +- Variants are ordinary defs, so save/load, undo/redo, gizmo, and map projects + need no special handling. + +Why baked variants rather than scaling at runtime: the engine has no +feature-scale API. `Spring.Set{Unit,Feature}PieceMatrix` looks like one, but +`LocalModelPiece::SetPieceSpaceMatrix` is only +`return blockScriptAnims = mat.IsRotOrRotTranMatrix();` -- it validates the +matrix, sets a flag, and **discards the geometry entirely** (no member stores +it; a piece's transform comes solely from `CalcPieceSpaceTransform(pos, rot, +scale)`, and `SetPosition`/`SetRotation`/`SetScaling` are reachable only from +unit animation scripts). Features therefore cannot be scaled -- or have their +pieces posed at all -- from Lua. The gadget still understands a per-entry scale +token on the wire (4/5/7/8-token forms) and applies collision/radius/mid-aim +scaling, but only when the engine reports the matrix accepted, so on current +engines that path is a clean no-op and it lights up automatically if a real API +ever lands. + #### WYSIWYG Preview The brush draws the features it is about to place, as translucent instanced @@ -635,6 +677,23 @@ Mode buttons (raise/lower/level/smooth/ramp/restore/noise) · Shape buttons · P | Dust effects | off | CEG particle bursts + rumble sounds on each op (DJ Mode) | | Velocity intensity | off | Scale brush intensity by mouse drag speed | +### Performance Mode + +**Settings > General > Performance mode** (persisted in `ui_prefs.lua`). For big maps and slower machines; the tools stay the same, sculpting just samples more economically: + +| Lever | Default | Performance mode | +|-------|---------|------------------| +| Dab spacing along the stroke | 15 % of radius | 24 % for soft curves (≤ 1.0) and clay, 20 % up to curve 2.0, 15 % above | +| Dabs per 20 Hz tick (cap) | 48 | 32 | +| FOLLOW STROKE angle step | 2° | 6° (a third of the stamp builds on shaped brushes) | +| Panel terraform mirror | every frame (every 4th frame while dragging) | every 4th frame; frame rate again while the mouse is over the panel | + +The spacing rule is falloff-aware: a soft dome sums smoothly at a quarter radius and a clay stroke converges on one plane whatever the spacing, while hard-edged curves keep the full density so they do not band. + +Two free levers regardless of the toggle: pausing the game while sculpting spares the pathfinder's terrain updates, and Focus mode (the eye icon in the header) drops the rest of the HUD. + +Always on, no toggle needed: the gadget commits a tick's dabs in one heightmap write and one undo entry (see Undo / Redo System), the falloff-stamp cache is rotation-invariant for circles and rings and budgeted by cells, and the ground mesh refresh is armed by the engine's heightmap-update event rather than per brush tick. + ### Presets Built-in presets (non-deletable) and unlimited user presets. Stored in `LuaUI/Config/TerraformPresets/*.lua`. @@ -679,6 +738,7 @@ All terrain edits go through `SendLuaRulesMsg()` to the server-side gadget. | Message | Format | |---------|--------| | `$terraform_brush$` | `dir x z radius shape rot curve capMin capMax intensity lengthScale clay dust opacity instant flattenHeight [ringInnerRatio]` | +| `$terraform_stroke$` | `dir radius shape curve capMin capMax intensity lengthScale clay dust opacity instant flattenHeight ringInnerRatio nDabs x1 z1 rot1 [x2 z2 rot2 ...]` — one per tick per symmetry copy, every dab of the tick; applied as one batch (one heightmap commit, one undo entry) | | `$terraform_ramp$` | `startX startZ startY endX endZ endY radius clay dust` | | `$terraform_ramp_spline$` | `radius pointCount [x1 z1 x2 z2 ...] clay dust` | | `$terraform_restore$` | `x z radius shape rot curve intensity lengthScale` | @@ -686,15 +746,15 @@ All terrain edits go through `SendLuaRulesMsg()` to the server-side gadget. | `$terraform_import$` | `columnX height1 height2 ...` | | `$terraform_undo$` | (no args) | | `$terraform_redo$` | (no args) | -| `$terraform_merge_end$` | (no args) — sent by widget on mouse release to finalize the drag-stroke undo entry | -| `$terraform_stroke_end$` | (no args) — marks the end of a distinct stroke for diagnostics | +| `$terraform_merge_end$` | (no args) — sent by the widget after every brush tick; closes the tick's undo entry | +| `$terraform_stroke_end$` | (no args) — sent on mouse release; advances the stroke id (`$terraform_undo_stroke$` pops all entries of the latest id) and drops the pre-stroke heights the clay plane measures against | **Feature placer messages** (`luarules/gadgets/cmd_feature_placer.lua`). Every mutating branch is gated on `Spring.IsCheatingEnabled()`. | Message | Format | |---------|--------| -| `$feature_place_list$` | `strokeId` then `name x z heading [pitch roll y]` per entry, joined by `\|`, 40 per message | +| `$feature_place_list$` | `strokeId` then `name x z heading [pitch roll y] [scale]` per entry, joined by `\|`, 40 per message. Token count disambiguates: 4 plain, 5 scale, 7 tilt, 8 tilt+scale | | `$feature_transform$` | `strokeId` then `fid x y z pitch yaw roll` per entry, joined by `\|` | | `$feature_remove_ids$` | `fid` per entry, joined by `\|` | | `$feature_remove$` | `x z radius shape rot` | @@ -703,7 +763,8 @@ mutating branch is gated on `Spring.IsCheatingEnabled()`. | `$feature_undo$` / `$feature_redo$` / `$feature_clearall$` | (no args) | The optional `pitch roll y` tail is only sent for features the gizmo tilted or -lifted. `strokeId` collapses one user action into one undo entry even when it is +lifted, and the optional `scale` token only for features whose scale roll came +out different from 1. `strokeId` collapses one user action into one undo entry even when it is split across several messages -- a 500-feature stamp is 13 batches, a gizmo drag over a large selection several more -- the same way the terraform brush merges a paint stroke. Only one stroke is open at a time, and the entry is pushed lazily @@ -754,7 +815,7 @@ gizmo-transformed anyway. | 8–9 | `capMin capMax` | float or empty | Height cap bounds | | 10 | `intensity` | float | 0.1–100 | | 11 | `lengthScale` | float | 0.2–5.0 | -| 12 | `clay` | 0/1 | Clay mode | +| 12 | `clay` | 0/1/2 | Clay mode (`2` = with per-tick build-up) | | 13 | `dust` | 0/1 | Dust/DJ mode | | 14 | `opacity` | float | 0.01–1.0 | | 15 | `instant` | 0/1 | Stamp mode | @@ -819,25 +880,23 @@ After each terraform op, `tessellationDirtyFrames` is set to 10. Counter decreme History is maintained as a **server-side stack** in the gadget. All terrain modifications snapshot the previous state before applying. -#### Stroke Merge (Drag → Single Undo Entry) +#### Stroke Entries (One Per Tick) -Each brush stroke fires many `$terraform_brush$` messages per second while the mouse is held. Rather than creating hundreds of separate undo entries, all changes during a single drag are merged into **one entry**: +Each brush tick sends one `$terraform_stroke$` message per symmetry copy carrying every dab of that tick. The gadget applies the dabs in order against a working copy of the cells they touch (read from the engine once, on first touch) and commits **once per message**: one `SetHeightMapFunc` (so one engine terrain recalculation) and **one undo entry** built straight from the pre-tick heights of the cells it wrote. Dab k still sees dab k-1's writes, so the result is what sequential commits produced, at a fraction of the engine work. -- On each push during an active drag, new vertices are added to the current snapshot — duplicates (same x/z already snapshotted) are skipped via a numeric hash set, so re-visiting a cell doesn't grow the snapshot. -- When the mouse is released the widget sends **`$terraform_merge_end$`**, which finalizes the snapshot and closes the merge window. -- Undo/redo each restore the entire drag stroke in a single step. +Entries of one drag share a stroke id: `$terraform_merge_end$` closes the tick, `$terraform_stroke_end$` (mouse release) advances the id, and `$terraform_undo_stroke$` pops every entry with the latest id in one step. Cross-tick merging is deliberately not done (it produced striped leftovers on undo). -Ramp and spline operations always produce a new independent entry (no merge). +Ramp and spline operations always produce a new independent entry. #### Storage Format -Snapshots are stored as **flat arrays** `{x, z, h, x, z, h, ...}` instead of sub-tables `{{x,z,h},...}`. This eliminates the tens-of-thousands of per-vertex sub-table allocations that caused `SetHeightMapFunc` heavy operations to spike GC. +Snapshots are stored as a **bbox grid**: a mask and a height grid over the entry's bounding box (`minX`, `minZ`, `w`, `h`, `ss`). Cells still at their map-original height store a mask bit only (`2`) and no height; edited cells store `1` plus the pre-edit height. Brush ticks build the grid directly from their working copy; the ramp, noise, erode and fill ops convert a flat `{x, z, h, ...}` buffer, which itself replaced per-vertex sub-tables that used to spike GC. #### Vertex Budget (Anti-OOM) | Constant | Value | Meaning | |----------|-------|---------| -| `MAX_UNDO` | 2000 | Maximum entries in undo or redo stack | +| `MAX_UNDO` | 10000 | Maximum entries in undo or redo stack | | `MAX_SNAPSHOT_VERTICES` | 8 000 000 | ~192 MB — total vertex budget across all stacked snapshots | When `totalVertexCount` exceeds the budget, the **oldest** undo entries are evicted until under budget. If still over, the oldest redo entries are also evicted. This prevents OOM crashes with very large-radius restore/noise operations on wide maps. @@ -950,7 +1009,7 @@ Temporal dimension: **record and playback** brush strokes for dynamic, time-vary | # | Item | Notes | |---|------|-------| | 2 | **Feature placement preview** | WYSIWYG ghosts: the exact features about to be placed, at their exact positions and orientations, drawn as instanced translucent models under the cursor. Remove mode tints what the brush would destroy. See [Feature Placer → WYSIWYG Preview](#wysiwyg-preview). | -| 3 | **Feature gizmo tool** | Click / shift-click / box-drag to select placed features; 3D gizmo with X/Y/Z translate arrows, pitch/yaw/roll rings and a free-move centre handle. Groups transform rigidly about their centroid. Scale is not implemented because the engine exposes no feature-scale API. See [Feature Placer → Selection & Gizmo](#selection--gizmo). | +| 3 | **Feature gizmo tool** | Click / shift-click / box-drag to select placed features; 3D gizmo with X/Y/Z translate arrows, pitch/yaw/roll rings and a free-move centre handle. Groups transform rigidly about their centroid; per-feature visual scale is rolled at placement time (see [Feature Placer → Scale Variation](#scale-variation)). See [Feature Placer → Selection & Gizmo](#selection--gizmo). | | 4 | **Symmetry tool** | Full implementation. Mirror X/Y modes with axis angle rotation; N-way radial mode (2–16 copies); draggable origin gizmo; Flipped mode (mirror + invert heights); one-shot Mirror Terrain button. See [Instruments → Symmetry / Mirror Tool](#symmetry--mirror-tool). | | 5 | **Velocity-sensitive intensity** | Toggle in Overlays section; scales brush strength by mouse drag speed. See [Velocity-Sensitive Intensity](#velocity-sensitive-intensity). | | 7 | **Partial restore slider** | Slider in restore mode; 0–100% blend target sent to gadget. See [Restore](#restore). | diff --git a/gamedata/alldefs_post.lua b/gamedata/alldefs_post.lua index 8f214ce91e9..b47ef0df204 100644 --- a/gamedata/alldefs_post.lua +++ b/gamedata/alldefs_post.lua @@ -205,6 +205,19 @@ end -- MODULE FUNCTIONS ------------------------- +local function spawnedAirUnit(carriedUnits) + if not carriedUnits then + return + end + for carriedName in string.gmatch(carriedUnits, "%S+") do + -- Scav units still have the base unit here but check against both sets anyway. + local carriedDef = UnitDefs[carriedName] or UnitDefs[(string.gsub(carriedName, "_scav$", ""))] ---@as table + if carriedDef and carriedDef.canfly then + return carriedDef + end + end +end + local function unitDef_Post(name, uDef) local isScav = string.sub(name, -5, -1) == "_scav" local basename = isScav and string.sub(name, 1, -6) or name @@ -224,12 +237,19 @@ local function unitDef_Post(name, uDef) -- Event Model Replacements: ----------------------------------------------------------------------------- - if isAprilFools and holidayModels.AprilFools[basename] then - uDef.objectname = holidayModels.AprilFools[basename] - elseif isHalloween and holidayModels.Halloween[basename] then - uDef.objectname = holidayModels.Halloween[basename] - elseif isXmas and holidayModels.Xmas[basename] then - uDef.objectname = holidayModels.Xmas[basename] + local holidayModel + if isAprilFools then + holidayModel = holidayModels.AprilFools[basename] + elseif isHalloween then + holidayModel = holidayModels.Halloween[basename] + elseif isXmas then + holidayModel = holidayModels.Xmas[basename] + end + if holidayModel then + uDef.objectname = holidayModel.model + if holidayModel.hats then + customparams.holidayhatcount = holidayModel.hats + end end ---------------------------------------------------------------------------------------------------------- @@ -266,6 +286,16 @@ local function unitDef_Post(name, uDef) customparams.subfolder = "none" end + -- israptor/iscritter are set explicitly in the unit def files; the name prefixes stay + -- load-bearing elsewhere (createScavengerUnitDefs in unitdefs_post.lua), so warn loudly + -- when a def follows the naming convention but is missing its flag + if string.sub(name, 1, 6) == "raptor" and not customparams.israptor then + Spring.Log("AllDefs", LOG.WARNING, name .. " is named like a raptor but lacks customparams.israptor") + end + if string.sub(name, 1, 8) == "critter_" and not customparams.iscritter then + Spring.Log("AllDefs", LOG.WARNING, name .. " is named like a critter but lacks customparams.iscritter") + end + if modOptions.unit_restrictions_notech15 then if tonumber(customparams.techlevel) == 1.5 then customparams.modoption_blocked = true @@ -294,8 +324,30 @@ local function unitDef_Post(name, uDef) customparams.modoption_blocked = true elseif uDef.canfly then customparams.modoption_blocked = true - elseif customparams.restrictions_inclusion and string.find(customparams.restrictions_inclusion, "_noair_") then --used to remove factories and drone carriers with no other purpose (ex. leghive but not rampart) + elseif customparams.restrictions_inclusion and string.find(customparams.restrictions_inclusion, "_noair_") then --used to remove factories with no other purpose (ex. legap) customparams.modoption_blocked = true + else + local strippedDrones = false + for weaponName, weaponDef in pairs(weapondefs) do + local carriedUnit = weaponDef.customparams and weaponDef.customparams.carried_unit + local carriedDef = spawnedAirUnit(carriedUnit) + if carriedDef then + weapondefs[weaponName] = nil + strippedDrones = true + -- Make a minimal effort toward cost adjustments: + local count = weaponDef.customparams.startingdronecount + if count and tonumber(count) then + uDef.metalcost = (uDef.metalcost or 0) - count * (carriedDef.metalcost or 0) + uDef.energycost = (uDef.energycost or 0) - count * (carriedDef.energycost or 0) + end + uDef.metalcost = (uDef.metalcost or 0) * 0.95 + uDef.energycost = (uDef.energycost or 0) * 0.95 + end + end + -- Keep drone spawners that have other weapons: + if strippedDrones and not next(weapondefs) then + customparams.modoption_blocked = true + end end end @@ -844,8 +896,8 @@ local function unitDef_Post(name, uDef) customparams.smart_weapon_cmddesc = "default" end - weapondefs[weapons[ priorityWeapon].def:lower()].customparams.smart_priority = true - weapondefs[weapons[ backupWeapon].def:lower()].customparams.smart_backup = true + weapondefs[weapons[priorityWeapon].def:lower()].customparams.smart_priority = true + weapondefs[weapons[backupWeapon].def:lower()].customparams.smart_backup = true weapondefs[weapons[trajectoryWeapon].def:lower()].customparams.smart_trajectory_checker = true else customparams.weapons_smart_select = nil diff --git a/gamedata/scavengers/unitdef_post.lua b/gamedata/scavengers/unitdef_post.lua index 1ac7fad4b53..70e3d25a663 100644 --- a/gamedata/scavengers/unitdef_post.lua +++ b/gamedata/scavengers/unitdef_post.lua @@ -258,9 +258,11 @@ local function scavUnitDef_Post(name, uDef) -- Extra Units ---------------------------------------------------------------------------------------------------------------------------------- -- Armada T1 Land Constructors + --[[ if name == "armca_scav" or name == "armck_scav" or name == "armcv_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Armada T1 Sea Constructors if name == "armcs_scav" or name == "armcsa_scav" then @@ -320,9 +322,11 @@ local function scavUnitDef_Post(name, uDef) end -- Cortex T1 Land Constructors + --[[ if name == "corca_scav" or name == "corck_scav" or name == "corcv_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Cortex T1 Sea Constructors if name == "corcs_scav" or name == "corcsa_scav" then @@ -332,9 +336,11 @@ local function scavUnitDef_Post(name, uDef) end -- Cortex T1 Bots Factory + --[[ if name == "corlab_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Cortex T2 Land Constructors if name == "coraca_scav" or name == "corack_scav" or name == "coracv_scav" then @@ -369,9 +375,11 @@ local function scavUnitDef_Post(name, uDef) end -- Cortex T2 Aircraft Plant + --[[ if name == "coraap_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Cortex T2 Shipyard if name == "corasy_scav" then @@ -383,19 +391,25 @@ local function scavUnitDef_Post(name, uDef) end -- Cortex T3 Gantry + --[[ if name == "corgant_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Cortex T3 Underwater Gantry + --[[ if name == "corgantuw_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Legion T1 Land Constructors + --[[ if name == "legca_scav" or name == "legck_scav" or name == "legcv_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Legion T2 Land Constructors if name == "legaca_scav" or name == "legack_scav" or name == "legacv_scav" then @@ -422,24 +436,32 @@ local function scavUnitDef_Post(name, uDef) -- Scavengers Units ------------------------------------------------------------------------------------------------------------------------ -- Armada T1 Land Constructors + --[[ if name == "armca_scav" or name == "armck_scav" or name == "armcv_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Armada T1 Sea Constructors + --[[ if name == "armcs_scav" or name == "armcsa_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Armada T1 Vehicle Factory + --[[ if name == "armvp_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Armada T1 Aircraft Plant + --[[ if name == "armap_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Armada T2 Constructors if name == "armaca_scav" or name == "armack_scav" or name == "armacv_scav" then @@ -500,14 +522,18 @@ local function scavUnitDef_Post(name, uDef) end -- Cortex T2 Sea Constructors + --[[ if name == "coracsub_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Cortex T2 Bots Factory + --[[ if name == "coralab_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Cortex T2 Vehicle Factory if name == "coravp_scav" then @@ -550,9 +576,11 @@ local function scavUnitDef_Post(name, uDef) end -- Legion T1 Land Constructors + --[[ if name == "legca_scav" or name == "legck_scav" or name == "legcv_scav" then local numBuildoptions = #uDef.buildoptions end + ]] -- Legion T2 Land Constructors if name == "legaca_scav" or name == "legack_scav" or name == "legacv_scav" then diff --git a/gamedata/unitdefs_post.lua b/gamedata/unitdefs_post.lua index 4cfd8a27cc1..519321f16e5 100644 --- a/gamedata/unitdefs_post.lua +++ b/gamedata/unitdefs_post.lua @@ -228,6 +228,54 @@ local function createScavengerUnitDefs() end end +-- A tweak that fails is only reported here, in the defs environment, which has no way to +-- reach LuaUI except through the defs it produces. Each failure is stashed on the +-- commander defs - the ones present in every game - so the game info panel can say a tweak +-- was not applied rather than listing it as a setting that took effect. +local tweakFailures = {} +local tweakErrorCarriers = { "armcom", "corcom", "legcom" } + +local function recordTweakFailure(name, message) + -- Tab between the option and its message, newline between records: a Lua error message + -- carries neither, so the panel can split them apart again. + tweakFailures[#tweakFailures + 1] = name .. "\t" .. (string.gsub(tostring(message), "%s+", " ")) +end + +local function publishTweakFailures() + if #tweakFailures == 0 then + return + end + + local text = table.concat(tweakFailures, "\n") + for _, name in ipairs(tweakErrorCarriers) do + local unitDef = UnitDefs[name] + if unitDef then + unitDef.customparams = unitDef.customparams or {} + unitDef.customparams.tweak_errors = text + end + end +end + +-- What a tweakunits overwrote, so the game info panel can say what a value used to be +-- rather than only what it is now. This is the one case where the before is knowable +-- cheaply: the tweak is a table, so the paths it sets are the paths to read first. +-- +-- It rides on the unit's own customparams because that is where per-unit data belongs and +-- because the defs are the only thing this environment can hand to LuaUI at all. +local function recordOverwritten(unitDef, tweak, path, out) + for key, value in pairs(tweak) do + local here = path == "" and tostring(key) or (path .. "." .. tostring(key)) + local current = unitDef and unitDef[key] + if type(value) == "table" then + -- A path that opens a sub-table is not a value anyone set; its leaves are. + recordOverwritten(type(current) == "table" and current or nil, value, here, out) + elseif type(current) ~= "table" then + -- Empty means there was nothing there before, which the panel reads as new. + out[#out + 1] = here .. "\t" .. (current == nil and "" or tostring(current)) + end + end +end + local function preProcessTweakOptions() local modOptions = {} if BAR.GetModOptionsCopy then @@ -249,10 +297,12 @@ local function preProcessTweakOptions() end table.sort(tweaks, function(a, b) - if a.type == "defs" and b.type == "units" then - return true - elseif a.type == "units" and b.type == "defs" then + -- Ensure that tweakunits are processed before tweakdefs + -- This allows fine-tuning of tweaks using extended capabilities of tweakdefs + if a.type == 'defs' and b.type == 'units' then return false + elseif a.type == 'units' and b.type == 'defs' then + return true end return a.index < b.index end) @@ -268,6 +318,7 @@ local function preProcessTweakOptions() local postfunc, err = loadstring(postsFuncStr) if err then Spring.Echo("Error parsing modoption", name, "from string", postsFuncStr, "Error: " .. err) + recordTweakFailure(name, err) else Spring.Echo("Loading " .. name .. " modoption") Spring.Echo(postsFuncStr) @@ -277,11 +328,13 @@ local function preProcessTweakOptions() shouldNormalizeUnitDefs = true -- tweakdefs can add or denormalize units else Spring.Echo("Error executing tweakdef", name, postsFuncStr, "Error :" .. result) + recordTweakFailure(name, result) end end end else Spring.Echo("Error parsing and decoding tweakdef", name, modOptions[name], "Error :" .. postsFuncStr) + recordTweakFailure(name, postsFuncStr) end else local success, tweakunits = pcall(BAR.Utilities.CustomKeyToUsefulTable, modOptions[name]) @@ -291,13 +344,24 @@ local function preProcessTweakOptions() for unitName, ud in pairs(UnitDefs) do if tweakunits[unitName] then Spring.Echo("Loading tweakunits for " .. unitName) - table.mergeInPlace(ud, system.lowerkeys(tweakunits[unitName]), true) + local lowered = system.lowerkeys(tweakunits[unitName]) + local overwritten = {} + recordOverwritten(ud, lowered, "", overwritten) + table.mergeInPlace(ud, lowered, true) normalizeUnitDef(ud) -- tweakunits can set required tables to nil + if #overwritten > 0 then + -- Appended, not replaced: a later slot can set a path an earlier one + -- already did, and the first record is the one that predates them all. + local was = ud.customparams.tweaked_from + ud.customparams.tweaked_from = (was and was .. "\n" or "") + .. table.concat(overwritten, "\n") + end end end end else Spring.Echo("Failed to parse modoption", name, "with value", modOptions[name]) + recordTweakFailure(name, tweakunits) end end end @@ -352,3 +416,4 @@ postProcessAllUnitDefs() postProcessRegularUnitDefs() postProcessScavengerUnitDefs() exportYardmaps() +publishTweakFailures() diff --git a/language/de/commands.json b/language/de/commands.json new file mode 100644 index 00000000000..c3ad409b6bf --- /dev/null +++ b/language/de/commands.json @@ -0,0 +1,132 @@ +{ + "commands": { + "move": "Bewegen", + "move_tooltip": "Bewege eine Einheit zu einer Position oder folge anderen Einheiten", + "stop": "Stoppen", + "stop_tooltip": "Breche die aktuell ausgeführte Aktion der Einheit ab", + "attack": "Angreifen", + "attack_tooltip": "Greife eine Einheit oder Bodenposition an", + "areaattack": "Flächenangriff", + "areaattack_tooltip": "Greife alles in einem Umkreis an (Klicken und Ziehen)", + "manualfire": "D-Gun", + "manualfire_tooltip": "Feuere die mächtige Desintegrations-Kanone des Kommandanten", + "manuallaunch": "Abschuss", + "manuallaunch_tooltip": "Schieße eine Rakete auf ein Ziel", + "patrol": "Patrouillieren", + "patrol_tooltip": "Einen oder mehrere Patrouillen-Wegpunkte ablaufen", + "fight": "Kämpfen", + "fight_tooltip": "Befehle Einheiten anzugreifen, während sie sich zu einer Position bewegen", + "resurrect": "Wiederbeleben", + "resurrect_tooltip": "Belebe Wracks wieder, damit sie erneut zu Einheiten werden (Klicke und ziehe für Bereichsauswahl)", + "guard": "Beschützen", + "guard_tooltip": "Eine andere Einheit vor feindlichen Einheiten schützen, die sie angreifen", + "wait": "Warten", + "wait_tooltip": "Eine Einheit/Fabrik beim Abarbeiten der Befehlswarteschlange anhalten", + "repair": "Reparieren", + "repair_tooltip": "Eine beschädigte Einheit reparieren", + "reclaim": "Zerlegen", + "reclaim_tooltip": "Metall/Energie aus Wracks oder Besonderheiten (Bäume/Steine) gewinnen", + "restore": "Wiederherstellen", + "restore_tooltip": "Einen Teil der Karte zur Originalhöhe wiederherstellen", + "capture": "Einnehmen", + "capture_tooltip": "Konvertiert Einheiten, die einem Feind (oder Verbündeten) gehören", + "settarget": "Ziel festlegen", + "settarget_tooltip": "Setzt das Prioritätsziel (Ziel wird priorisiert, wenn es in Reichweite ist)", + "canceltarget": "Ziel löschen", + "canceltarget_tooltip": "Löscht das Prioritätsziel", + "areamex": "Gebiets-Metallextraktion", + "areamex_tooltip": "Klicke und ziehe einen Bereich, in dem automatisch auf allen verfügbaren Metallquellen Extraktoren gebaut werden sollen", + "loadunits": "Einheiten Aufladen", + "loadunits_tooltip": "Lädt eine oder mehrere Einheiten aus dem Bereich in den Transporter auf", + "unloadunits": "Einheiten Absetzen", + "unloadunits_tooltip": "Setzt eine oder mehrere Einheiten aus dem Bereich aus dem Transporter ab", + "stockpile": "Vorrat %{stockpileStatus}", + "stockpile_tooltip": "[ Anzahl im Vorrat ] / [ Angestrebte Anzahl ]", + "stopproduction": "Warteschlange leeren", + "stopproduction_tooltip": "Leert die Warteschlange für alle Einheiten in den ausgewählten Fabriken", + "morph": "Verbessern", + "morph_tooltip": "Auf die nächste Technologiestufe aufwerten (zweiter Klick bricht ab)", + "Fire at will": "Feuer frei", + "Hold fire": "Feuer einstellen", + "Return fire": "Zurückschießen", + "firestate_tooltip": "Setzt die Bedingungen, unter denen Einheiten auf Feinde schießen sollen (ohne expliziten Angriffsbefehl)", + "Hold pos": "Stellung halten", + "Maneuver": "Manövrieren", + "Roam": "Frei bewegen", + "movestate_tooltip": "Setzt die Beschränkung dafür, wie weit sich eine Einheit bewegen darf, um Gegner anzugreifen", + "Repeat on": "Wiederholen an", + "Repeat off": "Wiederholen aus", + "repeat_tooltip": "Wiederholt die Befehlswarteschlange", + "Low Prio": "Niedrige Priorität", + "High Prio": "Hohe Priorität", + "priority_tooltip": "Bestimmt die Ressourcenzuteilung dieses Arbeiters, wenn Mangel herrscht", + "Decloaked": "Sichtbar", + "Cloaked": "Getarnt", + "wantcloak_tooltip": "Sichtbarkeitszustand", + " On ": "An", + " Off ": "Aus", + "onoff_tooltip": "Aktivitätszustand: Schaltet eine Einheit an/aus", + " Fly ": "Fliegen", + "Land": "Landen", + "idlemode_tooltip": "Bestimmt, was Flugzeuge tun, wenn sie untätig sind", + "apLandAt_tooltip": "Bestimmt, was Flugzeuge tun, wenn sie die Fabrik verlassen", + "Low traj": "Direktes Feuer", + "High traj": "Indirektes Feuer", + "trajectory_tooltip": "Setzt den Feuermodus im Artilleriezustand (Flache/Steile Flugbahn)", + "hound_weapon_plasma": "Schwere Plasmakanonea", + "hound_weapon_gauss": "Gauß-Kanone", + "hound_weapon_toggle_tooltip": "Wechselt zwischen Gauss-Kanone und schwerer Plasmakanone", + "customOnOff": { + "lowTrajectory": "Direktes Feuer", + "highTrajectory": "Indirektes Feuer", + "trajectory_tooltip": "Schaltet den Artillerie-Abschusswinkel zwischen flacher und steiler Flugbahn um" + } + }, + "categories": { + "selection": "Einheiten auswählen", + "orders": "Auswahlbefehle", + "queues": "Befehle einreihen", + "camera": "Kamerabewegung", + "drawing": "Zeichnen", + "sound": "Sound" + }, + "actions": { + "massSelect": { + "all": "Alle Einheiten auswählen", + "builders": "Alle Bauarbeiter auswählen", + "sameType": "Alle Einheiten des selben Typs wie die Ausgewählte auswählen", + "removeAutoGroup": "Einheitstyp aus Autogruppe entfernen" + }, + "orders": { + "cloak": "Tarnen", + "selfDestruct": "Selbstzerstörung" + }, + "queues": { + "prepend": "Befehl am Anfang der Warteschlange einfügen" + }, + "buildOrders": { + "rotate": "Gebäudeausrichtung ändern" + }, + "issueBuildOrders": { + "spacingUp": "Bauabstand erhöhen", + "spacingDown": "Bauabstand verringern" + }, + "camera": { + "flip": "Kamera umdrehen" + }, + "cameraModes": { + "mapmarks": "Durch Kartenmarkierungen springen", + "heightmap": "Höhenkarte anzeigen", + "traversability": "Passierbarkeit anzeigen (für ausgewählte Einheit)", + "resourceSpots": "Metallkarte anzeigen", + "interface": "Benutzeroberfläche verstecken" + }, + "console": { + "erase": "Alle Zeichnungen und Marker wegradieren", + "pause": "Pause" + }, + "sound": { + "mute": "Stummschaltung umschalten" + } + } +} diff --git a/language/de/interface.json b/language/de/interface.json index 983bc874843..775397a7d1f 100644 --- a/language/de/interface.json +++ b/language/de/interface.json @@ -139,90 +139,7 @@ "disabled": "%{textColor}%{unit}%{warnColor} (deaktiviert)" }, "orderMenu": { - "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}", - "move": "Bewegen", - "move_tooltip": "Bewege eine Einheit zu einer Position oder folge anderen Einheiten", - "stop": "Stoppen", - "stop_tooltip": "Breche die aktuell ausgeführte Aktion der Einheit ab", - "attack": "Angreifen", - "attack_tooltip": "Greife eine Einheit oder Bodenposition an", - "areaattack": "Flächenangriff", - "areaattack_tooltip": "Greife alles in einem Umkreis an (Klicken und Ziehen)", - "manualfire": "D-Gun", - "manualfire_tooltip": "Feuere die mächtige Desintegrations-Kanone des Kommandanten", - "manuallaunch": "Abschuss", - "manuallaunch_tooltip": "Schieße eine Rakete auf ein Ziel", - "patrol": "Patrouillieren", - "patrol_tooltip": "Einen oder mehrere Patrouillen-Wegpunkte ablaufen", - "fight": "Kämpfen", - "fight_tooltip": "Befehle Einheiten anzugreifen, während sie sich zu einer Position bewegen", - "resurrect": "Wiederbeleben", - "resurrect_tooltip": "Belebe Wracks wieder, damit sie erneut zu Einheiten werden (Klicke und ziehe für Bereichsauswahl)", - "guard": "Beschützen", - "guard_tooltip": "Eine andere Einheit vor feindlichen Einheiten schützen, die sie angreifen", - "wait": "Warten", - "wait_tooltip": "Eine Einheit/Fabrik beim Abarbeiten der Befehlswarteschlange anhalten", - "repair": "Reparieren", - "repair_tooltip": "Eine beschädigte Einheit reparieren", - "reclaim": "Zerlegen", - "reclaim_tooltip": "Metall/Energie aus Wracks oder Besonderheiten (Bäume/Steine) gewinnen", - "restore": "Wiederherstellen", - "restore_tooltip": "Einen Teil der Karte zur Originalhöhe wiederherstellen", - "capture": "Einnehmen", - "capture_tooltip": "Konvertiert Einheiten, die einem Feind (oder Verbündeten) gehören", - "settarget": "Ziel festlegen", - "settarget_tooltip": "Setzt das Prioritätsziel (Ziel wird priorisiert, wenn es in Reichweite ist)", - "canceltarget": "Ziel löschen", - "canceltarget_tooltip": "Löscht das Prioritätsziel", - "areamex": "Gebiets-Metallextraktion", - "areamex_tooltip": "Klicke und ziehe einen Bereich, in dem automatisch auf allen verfügbaren Metallquellen Extraktoren gebaut werden sollen", - "loadunits": "Einheiten Aufladen", - "loadunits_tooltip": "Lädt eine oder mehrere Einheiten aus dem Bereich in den Transporter auf", - "unloadunits": "Einheiten Absetzen", - "unloadunits_tooltip": "Setzt eine oder mehrere Einheiten aus dem Bereich aus dem Transporter ab", - "stockpile": "Vorrat %{stockpileStatus}", - "stockpile_tooltip": "[ Anzahl im Vorrat ] / [ Angestrebte Anzahl ]", - "stopproduction": "Warteschlange leeren", - "stopproduction_tooltip": "Leert die Warteschlange für alle Einheiten in den ausgewählten Fabriken", - "morph": "Verbessern", - "morph_tooltip": "Auf die nächste Technologiestufe aufwerten (zweiter Klick bricht ab)", - - "Fire at will": "Feuer frei", - "Hold fire": "Feuer einstellen", - "Return fire": "Zurückschießen", - "firestate_tooltip": "Setzt die Bedingungen, unter denen Einheiten auf Feinde schießen sollen (ohne expliziten Angriffsbefehl)", - "Hold pos": "Stellung halten", - "Maneuver": "Manövrieren", - "Roam": "Frei bewegen", - "movestate_tooltip": "Setzt die Beschränkung dafür, wie weit sich eine Einheit bewegen darf, um Gegner anzugreifen", - "Repeat on": "Wiederholen an", - "Repeat off": "Wiederholen aus", - "repeat_tooltip": "Wiederholt die Befehlswarteschlange", - "Low Prio": "Niedrige Priorität", - "High Prio": "Hohe Priorität", - "priority_tooltip": "Bestimmt die Ressourcenzuteilung dieses Arbeiters, wenn Mangel herrscht", - "Decloaked": "Sichtbar", - "Cloaked": "Getarnt", - "wantcloak_tooltip": "Sichtbarkeitszustand", - " On ": "An", - " Off ": "Aus", - "onoff_tooltip": "Aktivitätszustand: Schaltet eine Einheit an/aus", - " Fly ": "Fliegen", - "Land": "Landen", - "idlemode_tooltip": "Bestimmt, was Flugzeuge tun, wenn sie untätig sind", - "apLandAt_tooltip": "Bestimmt, was Flugzeuge tun, wenn sie die Fabrik verlassen", - "Low traj": "Direktes Feuer", - "High traj": "Indirektes Feuer", - "trajectory_tooltip": "Setzt den Feuermodus im Artilleriezustand (Flache/Steile Flugbahn)", - "hound_weapon_plasma": "Schwere Plasmakanonea", - "hound_weapon_gauss": "Gauß-Kanone", - "hound_weapon_toggle_tooltip": "Wechselt zwischen Gauss-Kanone und schwerer Plasmakanone", - - "customOnOff": { - "lowTrajectory": "Direktes Feuer", - "highTrajectory": "Indirektes Feuer", - "trajectory_tooltip": "Schaltet den Artillerie-Abschusswinkel zwischen flacher und steiler Flugbahn um" - } + "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}" }, "idleBuilders": { "name": "Untätige Arbeiter", @@ -245,7 +162,7 @@ "keybinds": { "title": "Tastenbelegungen", "disclaimer": "Diese Tastenbelegungen sind standardmäßig gesetzt. Wenn du Hotkey-Widgets entfernst/ersetzt oder deine eigenen UIKeys verwendest, funktionieren sie möglicherweise nicht mehr!", - "howtochangekeybinds":"Zum Ändern: in Einstellungen/Steuerung Tastenbelegungen auf \"Benutzerdefiniert\" setzen, um die Datei BAR/data/uikeys.txt zu erstellen.\nBearbeite diese Datei und wähle \"Benutzerdefiniert\" wieder aus, um erneut zu laden.", + "howtochangekeybinds": "Zum Ändern: in Einstellungen/Steuerung Tastenbelegungen auf \"Benutzerdefiniert\" setzen, um die Datei BAR/data/uikeys.txt zu erstellen.\nBearbeite diese Datei und wähle \"Benutzerdefiniert\" wieder aus, um erneut zu laden.", "chat": { "title": "Chat", "send": "Chatnachricht senden", @@ -264,7 +181,6 @@ "share": "Einheiten/Ressourcen teilen" }, "camera": { - "title": "Kamerabewegung", "zoomKey": "Mausrad", "zoom": "Kamera zoomen", "panKey": "Pfeiltasten / Maus am Bildschirmrand", @@ -272,8 +188,7 @@ "tiltKey": "STRG + Mausrad", "tilt": "Kamerawinkel ändern", "dragKey": "Mittlere Maustaste (+ Ziehen)", - "drag": "Kamera ziehen", - "flip": "Kamera umdrehen" + "drag": "Kamera ziehen" }, "cameraModes": { "title": "Kameramodi", @@ -282,21 +197,13 @@ "fullscreenKey": "ALT + Backspace", "fullscreen": "Vollbild umschalten", "overview": "Überblickende Kamera umschalten", - "los": "Sichtlinienansicht umschalten", - "heightmap": "Höhenkarte anzeigen", - "traversability": "Passierbarkeit anzeigen (für ausgewählte Einheit)", - "mapmarks": "Durch Kartenmarkierungen springen", - "resourceSpots": "Metallkarte anzeigen", - "interface": "Benutzeroberfläche verstecken" + "los": "Sichtlinienansicht umschalten" }, "sound": { - "title": "Sound", "volumeKey": "-/+", - "volume": "Lautstärke ändern", - "mute": "Stummschaltung umschalten" + "volume": "Lautstärke ändern" }, "selection": { - "title": "Einheiten auswählen", "unitsKey": "Linke Maustaste (+ Ziehen)", "units": "Einheiten aus- oder abwählen" }, @@ -308,23 +215,8 @@ "formationOrder": "Formationsbefehl an Einheit(en) geben" }, "orders": { - "title": "Auswahlbefehle", "defaultKey": "(nichts)", - "default": "Standardbefehl (meist Bewegen)", - "move": "Bewegen", - "attack": "Angreifen", - "stop": "Stoppen (löscht Befehlswarteschlange)", - "repair": "Reparieren", - "reclaim": "Zerlegen", - "resurrect": "Wiederbeleben", - "patrol": "Patroullieren", - "fight": "Kämpfen", - "setTarget": "Prioritätsziel setzen", - "cancelTarget": "Prioritätsziel löschen", - "wait": "Warten (Aktuellen Befehl pausieren)", - "cloak": "Tarnen", - "dGun": "Manuelles Feuern (D-Gun)", - "selfDestruct": "Selbstzerstörung" + "default": "Standardbefehl (meist Bewegen)" }, "issueOrders": { "title": "Ausgewählte Befehle geben", @@ -336,11 +228,9 @@ "formation": "Formationsbefehl an Einheit(en) geben" }, "queues": { - "title": "Befehle einreihen", "append": "Befehl am Ende der Warteschlange anfügen", "appendKey": "Umschalttaste + (irgendein Befehl)", - "prependKey": "Leertaste + (irgendein Befehl)", - "prepend": "Befehl am Anfang der Warteschlange einfügen" + "prependKey": "Leertaste + (irgendein Befehl)" }, "buildOrders": { "title": "Bauaufträge auswählen", @@ -354,8 +244,7 @@ "intel": "Durch Radar/Verteidigung/etc. schalten", "factoriesKey": "V", "factories": "Durch Fabriken schalten", - "rotateKey": "ü und +", - "rotate": "Gebäudeausrichtung ändern" + "rotateKey": "ü und +" }, "issueBuildOrders": { "title": "Bauaufträge erteilen", @@ -368,29 +257,22 @@ "gridKey": "Umschalttaste + ALT + (Bauauftrag)", "grid": "Als Rechteck bauen", "spacingUpKey": "ALT + Y", - "spacingUp": "Bauabstand erhöhen", - "spacingDownKey": "ALT + X", - "spacingDown": "Bauabstand verringern" + "spacingDownKey": "ALT + X" }, "massSelect": { "title": "Gruppenauswahl", "allKey": "STRG + A", - "all": "Alle Einheiten auswählen", "buildersKey": "STRG + B", - "builders": "Alle Bauarbeiter auswählen", "createGroupKey": "STRG + (Nummer)", "createGroup": "Einheiten zu Gruppe hinzufügen (Nummer=1,2,...)", "createAutoGroupKey": "ALT + (Nummer)", "createAutoGroup": "Einheitstyp zu Autogruppe hinzufügen (Nummer=1,2,...)", "removeAutoGroupKey": "ALT + ^", - "removeAutoGroup": "Einheitstyp aus Autogruppe entfernen", "groupKey": "(Nummer)", "group": "Alle der Gruppe (Nummer) zugewiesenen Einheiten auswählen", - "sameTypeKey": "STRG + Y", - "sameType": "Alle Einheiten des selben Typs wie die Ausgewählte auswählen" + "sameTypeKey": "STRG + Y" }, "drawing": { - "title": "Zeichnen", "mapmarkKey": "^ + Doppelklick", "mapmark": "Kartenmarkierung platzieren", "drawKey": "^ + Linke Maustaste ziehen", @@ -401,9 +283,7 @@ "console": { "title": "Konsolenbefehle", "eraseKey": "/clearmapmarks", - "erase": "Alle Zeichnungen und Marker wegradieren", - "pauseKey": "/pause", - "pause": "Pause" + "pauseKey": "/pause" } }, "chat": { @@ -471,7 +351,7 @@ "heightTitle": "Höhe", "heightmap": "[%{keyset}] Zeigt jede Höhenstufe in einer anderen Farbe", "pathingTitle": "Passierbarkeit", - "pathing":"[%{keyset}] Zeigt an, wohin die ausgewählte Einheit sich bewegen kann, Grün: okay, Rot: problematisch, Lila: unerreichbar", + "pathing": "[%{keyset}] Zeigt an, wohin die ausgewählte Einheit sich bewegen kann, Grün: okay, Rot: problematisch, Lila: unerreichbar", "resourcesTitle": "Ressourcen", "resources": "[%{keyset}] Hebt Metalladern in Grün und geothermische Quellen in Gelb hervor. Besetzte Metalladern werden in Rot angezeigt." }, @@ -594,7 +474,7 @@ }, "moveAttackNotify": { "underAttack": "%{unit} wird angegriffen!", - "cantMove":"%{unit}: Kann Ziel nicht erreichen!" + "cantMove": "%{unit}: Kann Ziel nicht erreichen!" }, "unitShare": { "received": "Du hast %{count}neue Einheit(en)* erhalten", diff --git a/language/en/commands.json b/language/en/commands.json new file mode 100644 index 00000000000..af53c877464 --- /dev/null +++ b/language/en/commands.json @@ -0,0 +1,277 @@ +{ + "commands": { + "move": "Move", + "move_tooltip": "Move a unit towards a position or follow other units", + "stop": "Stop", + "stop_tooltip": "Cancel the unit's current actions", + "attack": "Attack", + "attack_tooltip": "Attack a unit or ground position", + "areaattack": "Area Attack", + "areaattack_tooltip": "Area attack everything within a circle (click-drag)", + "manualfire": "D-Gun", + "manualfire_tooltip": "Fire the powerful commander Disintegrator-gun", + "manuallaunch": "Launch", + "manuallaunch_tooltip": "Launch a missile at a target", + "patrol": "Patrol", + "patrol_tooltip": "Patrol along one or more waypoints", + "fight": "Fight", + "fight_tooltip": "Order units to take action while moving to a position", + "resurrect": "Resurrect", + "resurrect_tooltip": "Revive wrecks to become units again (click-drag for area)", + "guard": "Guard", + "guard_tooltip": "Guard another unit against enemy units attacking it", + "factoryguard": "Factory Guard", + "factoryguard_tooltip": "Builders produced by this factory will automatically guard it", + "wait": "Wait", + "wait_tooltip": "Pause a unit/factory on processing command/build queues", + "repair": "Repair", + "repair_tooltip": "Repair a damaged unit", + "reclaim": "Reclaim", + "reclaim_tooltip": "Suck metal/energy from wrecks or features (trees/stones)", + "restore": "Restore", + "restore_tooltip": "Restore an area of the map to its original height", + "capture": "Capture", + "capture_tooltip": "Convert units that belong to the enemy (or ally)", + "settarget": "Set Target", + "settarget_tooltip": "Set a prioritized target (prioritizes targeting when target in range)", + "canceltarget": "Clear Target", + "canceltarget_tooltip": "Removes the priority target", + "areamex": "Area Mex", + "areamex_tooltip": "Click-drag an area to auto queue metal extractors for all available metal spots", + "loadunits": "Load units", + "loadunits_tooltip": "Load unit or multiple units within an area in the transport", + "unloadunits": "Unload units", + "unloadunits_tooltip": "Unload unit or multiple units within an area in the transport", + "stockpile": "Stockpile %{stockpileStatus}", + "stockpile_tooltip": "[ stockpiled number ] / [ target stockpile number ]", + "stopproduction": "Clear Queue", + "stopproduction_tooltip": "Clear build queue and quotas for all units on selected factories", + "morph": "Upgrade", + "morph_tooltip": "Upgrade to next Tech-level (second click to cancel)", + "Spawning Disabled": "Spawning disabled", + "Spawning Enabled": "Spawning enabled", + "sellunit": "Sell Unit", + "sellunit_tooltip": "Toggle currently selected units for sale, allies will be able to buy them", + "For Sale": "For Sale", + "Not For Sale": "Not For Sale", + "Fire at will": "Fire at will", + "Hold fire": "Hold fire", + "Return fire": "Return fire", + "Defend": "Defend", + "Fire at all": "Fire At All", + "firestate_hold_fire_descr": "Don't acquire targets without orders", + "firestate_return_fire_descr": "Return fire when attacked", + "firestate_defend_descr": "Fire upon enemies when they individually become a threat", + "firestate_fire_at_will_descr": "Fire upon enemies within range", + "firestate_fire_at_all_descr": "Fire upon anything belonging to the enemy within range", + "firestate_tooltip": "Set under what conditions a unit should start firing at enemies (without explicit attack order).", + "Hold pos": "Hold pos", + "Maneuver": "Maneuver", + "Roam": "Roam", + "movestate_tooltip": "Set how far out of its way a unit should move to attack enemies", + "bomber_targeting": "Targeting", + "bomber_targeting_tooltip": "Hold Fire: Only attack with an explicit order. Fire at will: Idle bombers will seek targets within range.", + "Repeat on": "Repeat On", + "Repeat off": "Repeat Off", + "repeat_tooltip": "Repeat unit command queue", + "Low Prio": "Low Priority", + "High Prio": "High Priority", + "priority_tooltip": "Assigns resources to use for this builder when not having enough for all", + "Decloaked": "Visible", + "Cloaked": "Cloaked", + "wantcloak_tooltip": "Visibility state", + " On ": "On", + " Off ": "Off", + "onoff_tooltip": "Active state: turn a unit on/off", + " Fly ": "Fly", + "Land": "Land", + "idlemode_tooltip": "Sets what aircraft do when idle", + "aplandat_tooltip": "Sets what aircraft do when leaving air factory", + "csspawning_tooltip": "Sets the spawning state of the carrier", + "Low traj": "High Traj", + "High traj": "High Traj", + "trajectory_toggle_tooltip": "Switch artillery firing angle between low, high and automatic trajectory", + "trajectory_low": "Low Trajectory", + "trajectory_high": "High Trajectory", + "trajectory_auto": "Auto Trajectory", + "hound_weapon_plasma": "Heavy Plasma", + "hound_weapon_gauss": "Gauss Cannon", + "hound_weapon_toggle_tooltip": "Sets weapon to a slow plasma projectile with a large aoe, or a fast projectile gauss weapon", + "blueprint_place": "Place Blueprint", + "blueprint_place_tooltip": "Place a saved blueprint", + "blueprint_create": "Save Blueprint", + "blueprint_create_tooltip": "Save the selected units into a new blueprint, in the order they were selected", + "factoryqueuemode_normal": "Queue Mode", + "factoryqueuemode_quota": "Quota Mode", + "factoryqueuemode_tooltip": "Queue: Build each queued unit once\nQuota: Maintain a minimum quota of each unit on the battlefield", + "customOnOff": { + "lowTrajectory": "Low Trajectory", + "highTrajectory": "High Trajectory", + "trajectory_tooltip": "Switch artillery firing angle between low and high trajectory", + "gauss_tooltip": "Switches between Gauss Cannon and Heavy Plasma Cannon" + }, + "quicksharetotarget": "Share Unit", + "quicksharetotarget_tooltip": "Share unit to target player." + }, + "actions": { + "selection": { + "commAppend": "Add commander to selection", + "commFocus": "Select and go to commander", + "boxIdle": "Box select: idle units only", + "boxSame": "Box select: matching unit types", + "loopActivate": "Loop select: activate" + }, + "massSelect": { + "all": "Select all units", + "builders": "Select all constructors", + "sameType": "Select all units of same type as selected", + "sameTypeVisible": "Select same type as current, on screen", + "damaged": "Select damaged units", + "half": "Select half of current selection", + "idleTransports": "Select all idle transports", + "waitingVisible": "Select waiting units, on screen", + "deselectAll": "Deselect all", + "removeAutoGroup": "Remove unit type from autogroup" + }, + "orders": { + "cloak": "Cloak", + "waitQueued": "Wait (queued)", + "selfDestruct": "Self-destruct", + "selfDestructQueued": "Self-destruct (queued)", + "setTargetNoGround": "Set unit target", + "gatherWait": "Gather wait" + }, + "queues": { + "prepend": "Add order to start of order queue", + "skipCurrent": "Skip current command", + "cancelLast": "Cancel last command" + }, + "unitStates": { + "onoffToggle": "Toggle unit on/off", + "onoffOff": "Turn unit off", + "onoffOn": "Turn unit on", + "repeatOff": "Repeat off", + "repeatOn": "Repeat on", + "trajectory": "Trajectory %{n}", + "fireHold": "Fire state: Hold fire", + "fireReturn": "Fire state: Return fire", + "fireAtWill": "Fire state: Fire at will", + "moveHold": "Move state: Hold position", + "moveManeuver": "Move state: Maneuver", + "moveRoam": "Move state: Roam" + }, + "controlGroups": { + "select": "Select control group %{n}", + "focus": "Select control group %{n} and focus camera", + "assign": "Assign selection to control group %{n}", + "add": "Add selection to control group %{n}", + "selectAdd": "Add control group %{n} to selection", + "selectToggle": "Toggle control group %{n} in selection", + "clear": "Remove selection from control groups", + "addAuto": "Add unit type to auto-group %{n}", + "removeOne": "Remove closest unit from its group", + "preset": "Load auto-group preset %{n}" + }, + "buildHotkeys": { + "unit": "Build %{n}", + "split": "Split build orders between builders" + }, + "buildOrders": { + "rotate": "Rotate building placement", + "rotateBack": "Rotate building placement (reverse)" + }, + "issueBuildOrders": { + "spacingUp": "Increase build spacing", + "spacingDown": "Decrease build spacing" + }, + "factory": { + "queueMode": "Toggle factory repeat mode", + "loadPreset": "Load factory preset %{n}", + "savePreset": "Save factory preset %{n}", + "showPresets": "Show factory presets", + "togglePresets": "Toggle factory presets" + }, + "gridMenu": { + "buildKey": "Row %{row}, column %{col}", + "nextPage": "Next page", + "cycleBuilder": "Cycle builder" + }, + "blueprints": { + "delete": "Delete blueprint", + "next": "Next blueprint", + "prev": "Previous blueprint" + }, + "camera": { + "flip": "Flip camera", + "moveForward": "Move camera forward", + "moveBack": "Move camera back", + "moveLeft": "Move camera left", + "moveRight": "Move camera right", + "moveUp": "Move camera up", + "moveDown": "Move camera down", + "moveFast": "Move camera faster", + "setAnchor": "Set camera anchor %{n}", + "jumpAnchor": "Jump to camera anchor %{n}", + "pipSwitch": "Swap camera with picture-in-picture", + "pipCopy": "Copy camera to picture-in-picture", + "pipTrack": "Track selection in picture-in-picture", + "fovIncrease": "Increase field of view", + "fovDecrease": "Decrease field of view", + "cycleSelectedNext": "Cycle camera to next selected unit", + "cycleSelectedPrev": "Cycle camera to previous selected unit" + }, + "cameraModes": { + "mapmarks": "Cycle through map marks", + "heightmap": "Show height map", + "traversability": "Show passability (for selected unit)", + "resourceSpots": "Show metal map", + "interface": "Hide GUI" + }, + "interfaceDisplay": { + "settings": "Settings", + "unitStats": "Unit stats", + "saveGame": "Save game", + "screenshot": "Take screenshot", + "firingRange": "Display firing range", + "widgetSelector": "Widget selector" + }, + "gameControl": { + "voteForcestart": "Vote forcestart", + "increaseSpeed": "Increase game speed", + "decreaseSpeed": "Decrease game speed" + }, + "drawing": { + "drawInMap": "Draw", + "drawLabel": "Place label" + }, + "console": { + "erase": "Erase all drawings and markers", + "pause": "Pause" + }, + "sound": { + "mute": "Toggle mute", + "volumeUp": "Increase volume", + "volumeDown": "Decrease volume" + }, + "spectating": { + "spectate": "Spectate team %{n}" + } + }, + "categories": { + "selection": "Selecting units", + "orders": "Orders", + "queues": "Order modifiers", + "unitStates": "Unit states", + "controlGroups": "Control groups", + "buildHotkeys": "Build", + "gridMenu": "Grid menu", + "blueprints": "Blueprints", + "camera": "Camera", + "mapViews": "Map views", + "interfaceDisplay": "Interface", + "drawing": "Drawing", + "sound": "Sound", + "gameControl": "Game", + "other": "Other" + } +} diff --git a/language/en/interface.json b/language/en/interface.json index 9566f92f24b..ed5887fdf5e 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -42,7 +42,7 @@ "commanderCount": "Commander Counter", "commanderCountTooltip": "Displays the number of ally and enemy commanders", "tidalspeed": "Tidal Speed", - "tidalspeedTooltip": "Tidal (water) speed on this map (always constant)", + "tidalspeedTooltip": "Tidal (water) speed on this map (always constant)", "windspeed": "Wind Speed", "windspeedTooltip": "(small numbers are minimum and maximum)\nAverage wind speed on this map is %{avgWindValue}\nCurrent wind risk is %{riskWindValue} (percentage of time wind speed will be below 6)\n%{warnColor}It is better to build Solar Collectors when average\n%{warnColor}wind speed is below 5 (Armada) or 6 (Cortex)", "wind": { @@ -61,7 +61,7 @@ "shareEnergyTooltip": "Share excess energy above this point with your team", "pullTooltip": "The desired demand for %{resource} (per second) provided that there is sufficient supply and storage.\nBuilders on low-priority will only use leftover resources and thus their demand isn't counted here.", "incomeTooltip": "%{resource} income (per second)", - "expenseTooltip": "%{resource} spending (per second)", + "expenseTooltip": "%{resource} spending (per second)", "storageTooltip": "%{resource} storage", "conversionTooltipTitle": "Energy Conversion Slider", "conversionTooltip": "Excess energy above this point\nwill power your Energy Converters" @@ -189,6 +189,7 @@ "tooltipctrl": "CTRL-click to continuously clear" }, "buildMenu": { + "back": "Back", "category_econ": "Economy", "category_combat": "Combat", "category_utility": "Utility", @@ -206,120 +207,7 @@ "areamex_tooltip": "Tip: Select this extractor twice to use area mex command" }, "orderMenu": { - "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}", - "move": "Move", - "move_tooltip": "Move a unit towards a position or follow other units", - "stop": "Stop", - "stop_tooltip": "Cancel the units current actions", - "attack": "Attack", - "attack_tooltip": "Attack a unit or ground position", - "areaattack": "Area Attack", - "areaattack_tooltip": "Area attack everything within a circle (click-drag)", - "manualfire": "D-Gun", - "manualfire_tooltip": "Fire the powerful commander Disintegrator-gun", - "manuallaunch": "Launch", - "manuallaunch_tooltip": "Launch a missile at a target", - "patrol": "Patrol", - "patrol_tooltip": "Patrol along one or more waypoints", - "fight": "Fight", - "fight_tooltip": "Order units to take action while moving to a position", - "resurrect": "Resurrect", - "resurrect_tooltip": "Revive wrecks to become units again (click-drag for area)", - "guard": "Guard", - "guard_tooltip": "Guard another unit against enemy units attacking it", - "factoryguard": "Factory Guard", - "factoryguard_tooltip": "Builders produced by this factory will automatically guard it", - "wait": "Wait", - "wait_tooltip": "Pause a unit/factory on processing command/build queues", - "repair": "Repair", - "repair_tooltip": "Repair a damaged unit", - "reclaim": "Reclaim", - "reclaim_tooltip": "Suck metal/energy from wrecks or features (trees/stones)", - "restore": "Restore", - "restore_tooltip": "Restore an area of the map to its original height", - "capture": "Capture", - "capture_tooltip": "Convert units that belong to the enemy (or ally)", - "settarget": "Set Target", - "settarget_tooltip": "Set a prioritized target (prioritizes targeting when target in range) ", - "canceltarget": "Clear Target", - "canceltarget_tooltip": "Removes the priority target", - "areamex": "Area Mex", - "areamex_tooltip": "Click-drag an area to auto queue metal extractors for all available metal spots", - "loadunits": "Load units", - "loadunits_tooltip": "Load unit or multiple units within an area in the transport", - "unloadunits": "Unload units", - "unloadunits_tooltip": "Unload unit or multiple units within an area in the transport", - "stockpile": "Stockpile %{stockpileStatus}", - "stockpile_tooltip": "[ stockpiled number ] / [ target stockpile number ]", - "stopproduction": "Clear Queue", - "stopproduction_tooltip": "Clear build queue and quotas for all units on selected factories", - "morph": "Upgrade", - "morph_tooltip": "Upgrade to next Tech-level (second click to cancel)", - "Spawning Disabled": "Spawning disabled", - "Spawning Enabled": "Spawning enabled", - "sellunit": "Sell Unit", - "sellunit_tooltip": "Toggle currently selected units for sale, allies will be able to buy them", - "For Sale": "For Sale", - "Not For Sale": "Not For Sale", - - "Fire at will": "Fire at will", - "Hold fire": "Hold fire", - "Return fire": "Return fire", - "Defend": "Defend", - "Fire at all": "Fire At All", - "firestate_hold_fire_descr": "Don't acquire targets without orders", - "firestate_return_fire_descr": "Return fire when attacked", - "firestate_defend_descr": "Fire upon enemies when they individually become a threat", - "firestate_fire_at_will_descr": "Fire upon enemies within range", - "firestate_fire_at_all_descr": "Fire upon anything belonging to the enemy within range", - "firestate_tooltip": "Set under what conditions a unit should start firing at enemies (without explicit attack order).", - "Hold pos": "Hold pos", - "Maneuver": "Maneuver", - "Roam": "Roam", - "movestate_tooltip": "Set how far out of its way a unit should move to attack enemies", - "bomber_targeting": "Targeting", - "bomber_targeting_tooltip": "Hold Fire: Only attack with an explicit order. Fire at will: Idle bombers will seek targets within range.", - "Repeat on": "Repeat On", - "Repeat off": "Repeat Off", - "repeat_tooltip": "Repeat unit command queue", - "Low Prio": "Low Priority", - "High Prio": "High Priority", - "priority_tooltip": "Assigns resources to use for this builder when not having enough for all", - "Decloaked": "Visible", - "Cloaked": "Cloaked", - "wantcloak_tooltip": "Visibility state", - " On ": "On", - " Off ": "Off", - "onoff_tooltip": "Active state: turn a unit on/off", - " Fly ": "Fly", - "Land": "Land", - "idlemode_tooltip": "Sets what aircraft do when idle", - "aplandat_tooltip": "Sets what aircraft do when leaving air factory", - "csspawning_tooltip": "Sets the spawning state of the carrier", - "Low traj": "High Traj", - "High traj": "High Traj", - "trajectory_toggle_tooltip": "Switch artillery firing angle between low, high and automatic trajectory", - "trajectory_low": "Low Trajectory", - "trajectory_high": "High Trajectory", - "trajectory_auto": "Auto Trajectory", - "hound_weapon_plasma": "Heavy Plasma", - "hound_weapon_gauss": "Gauss Cannon", - "hound_weapon_toggle_tooltip": "Sets weapon to a slow plasma projectile with a large aoe, or a fast projectile gauss weapon", - "blueprint_place": "Place Blueprint", - "blueprint_place_tooltip": "Place a saved blueprint", - "blueprint_create": "Save Blueprint", - "blueprint_create_tooltip": "Save the selected units into a new blueprint, in the order they were selected", - "factoryqueuemode_normal": "Queue Mode", - "factoryqueuemode_quota": "Quota Mode", - "factoryqueuemode_tooltip": "Queue: Build each queued unit once\nQuota: Maintain a minimum quota of each unit on the battlefield", - "customOnOff": { - "lowTrajectory": "Low Trajectory", - "highTrajectory": "High Trajectory", - "trajectory_tooltip": "Switch artillery firing angle between low and high trajectory", - "gauss_tooltip": "Switches between Gauss Cannon and Heavy Plasma Cannon" - }, - "quicksharetotarget": "Share Unit", - "quicksharetotarget_tooltip": "Share unit to target player." + "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}" }, "idleBuilders": { "name": "Idle Builders", @@ -350,158 +238,94 @@ }, "keybinds": { "title": "Keybinds", - "disclaimer": "These keybinds are set by default. If you remove/replace hotkey widgets, or use your own uikeys, they might stop working!", - "howtochangekeybinds":"To change them: in Settings/Control tab, set Keybindings to Custom to create the BAR/data/uikeys.txt file.\nEdit this file and type /keyreload in chat to reload them.", - "chat": { - "title": "Chat", - "send": "Send chat message", - "alliesKey": "alt + enter", - "allies": "Send chat message to allies", - "spectatorsKey": "shift + enter", - "spectators": "Send chat message to spectators", - "ignoreKey": "ctrl + left click on name", - "ignore": "Ignore player" - }, - "menus": { - "title": "Menus", - "settings": "Settings", - "share": "Share units / resources" - }, - "camera": { - "title": "Camera movement", - "zoomKey": "scrollwheel", - "zoom": "Zoom camera", - "panKey": "arrow keys / mouse at screen edge", - "pan": "Move camera", - "tiltKey": "ctrl + scrollwheel", - "tilt": "Change camera angle", - "dragKey": "middle click (+ drag)", - "drag": "Drag camera", - "flip": "Flip camera" - }, - "cameraModes": { - "title": "Camera modes", - "changeKey": "ctrl + f1,2,3,4,5", - "change": "Change camera type", - "fullscreenKey": "alt + backspace", - "fullscreen": "Toggle fullscreen", - "overview": "Toggle overview camera", - "los": "Toggle LOS view", - "heightmap": "Show height map", - "traversability": "Show passability (for selected unit)", - "mapmarks": "Cycle through map marks", - "resourceSpots": "Show metal map", - "interface": "Hide GUI" - }, - "sound": { - "title": "Sound", - "volumeKey": "-/+", - "volume": "Change volume", - "mute": "Toggle mute" - }, - "selection": { - "title": "Selecting units", - "unitsKey": "left mouse (+ drag)", - "units": "Select or deselect units" - }, - "issueContextOrders": { - "title": "Giving orders", - "orderKey": "right mouse (single click)", - "order": "Give order to unit(s)", - "formationOrderKey": "right mouse (drag)", - "formationOrder": "Give formation order to unit(s)" - }, - "orders": { - "title": "Selecting orders", - "defaultKey": "(none)", - "default": "default order (usually move)", - "move": "move", - "attack": "attack", - "stop": "stop (clears order queue)", - "repair": "repair", - "reclaim": "reclaim", - "resurrect": "resurrect", - "patrol": "patrol", - "fight": "fight", - "setTarget": "set priority target", - "cancelTarget": "cancel priority target", - "wait": "wait (pause current command)", - "cloak": "cloak", - "dGun": "manual fire (dgun)", - "selfDestruct": "self-destruct" - }, - "issueOrders": { - "title": "Giving selected orders", - "orderKey": "left mouse (single click)", - "order": "Give order to unit(s)", - "revert": "Revert to default order", - "revertKey": "right mouse (single click)", - "formationKey": "right mouse + drag", - "formation": "Give formation order to unit(s)" - }, - "queues": { - "title": "Queueing orders", - "append": "Add order to end of order queue", - "appendKey": "shift + (some order)", - "prepend": "Add order to start of order queue" + "editor": { + "allCategories": "All", + "search": "Search actions or keys...", + "boundTo": "Bound to %{keys}", + "boundToAny": "Also fires on this key with any modifier held", + "preset": "Preset", + "defaultTag": "Default", + "pressKey": "Press a key or mouse button...", + "newProfile": "New Preset", + "duplicate": "Duplicate", + "duplicateTooltip": "Make a copy of this preset that you can change.", + "edit": "Edit", + "editTooltip": "Rename or delete this preset.", + "editLockedTooltip": "Default presets cannot be renamed or deleted. Duplicate this one to get a copy you can change.", + "delete": "Delete", + "save": "Save", + "saveAsNew": "Save as New", + "noticeDefault": "Default presets cannot be changed. Your edits will be saved as a new preset.", + "noticeDefaultUnsaved": "Unsaved changes (Ctrl+Z undoes). Saving creates a new preset and leaves the default as it is.", + "noticeUnsaved": "Unsaved changes (Ctrl+Z undoes the last edit).", + "changed": "Changed", + "changedCount": "Changed (%{n})", + "changedUnknown": "Changed (?)", + "changedNoneTooltip": "No preset is being compared with. Pick one in this section to list the actions whose keys differ from it.", + "compareWith": "Compare with", + "compareNone": "None", + "compareNoneHint": "Pick a preset to compare with. The actions whose keys differ from it are listed here, each with the key it has there.", + "changedNothing": "No keys differ from %{name}.", + "changedTooltip": "Every action whose keys differ from %{name}. Restore default keybinds by clicking the button containing the defualt keybind on the right.", + "conflict": "%{keys} is also bound to: %{actions}", + "conflictFirst": "%{action} (tried first)", + "conflictOrder": "Actions sharing a key are tried in the order they were bound; the first to take the key wins.", + "conflictShipped": "The game ships them on one key; the one bound first is tried first.", + "conflictCapture": "Also bound to: %{actions}", + "defaultIn": "Default in %{name}: %{keys}", + "defaultNone": "Not bound in %{name}", + "revertHint": "Click to put the default keybind back.", + "revertNone": "none", + "revertChip": "default: %{keys}", + "presetDefault": "A default preset. It cannot be changed; editing it saves a new preset of your own.", + "presetBasedOn": "Your preset, based on %{name}.", + "presetOwn": "Your preset.", + "export": "Export", + "exportTooltip": "Copy this preset to the clipboard as text, to share or keep.", + "exportDone": "\"%{name}\" is on the clipboard. Paste it anywhere to share or keep it; Import reads it back in.", + "import": "Import", + "importTooltip": "Read a preset from text on the clipboard and add it as a new preset.", + "keyboard": "Keyboard", + "keyboardTooltip": "Show this preset's keybinds laid out on a keyboard. Click Shift, Ctrl, Alt or Meta on it to see what the keys do with that held; click again for the list.", + "importTitle": "Import preset", + "importEmpty": "The clipboard is empty. Copy a preset's text first, then try again.", + "importSummary": "%{n} keybinds found", + "importErrors": "%{n} lines could not be read and will be dropped", + "importNone": "No keybinds found in the clipboard text", + "ok": "OK", + "reset": "Discard Changes", + "resetConfirm": "Discard your unsaved keybind changes?", + "saveTitle": "Save as new preset", + "discard": "Discard", + "unsavedTitle": "Unsaved changes", + "unsavedMessage": "Your keybind changes have not been saved yet.", + "duplicateTitle": "Duplicate preset", + "editTitle": "Edit preset", + "deleteConfirm": "Delete \"%{name}\"? This cannot be undone.", + "applyFailedTitle": "Could not apply preset", + "applyFailedMessage": "\"%{name}\" could not be written to disk, so your keybinds are unchanged. Check that the game folder is writable.", + "accept": "Accept", + "cancel": "Cancel" }, - "buildOrders": { - "title": "Selecting build orders", - "selectTileKey": "(mouse)", - "selectTile": "Select from units build-menu", - "metalKey": "z", - "metal": "Cycle through mexes", - "energyKey": "x", - "energy": "Cycle through energy production", - "intelKey": "c", - "intel": "Cycle through radar/defence/etc", - "factoriesKey": "v", - "factories": "Cycle through factories", - "rotate": "Change facing of buildings" + "keyboard": { + "layerBase": "No modifier held", + "layer": "%{mods} held", + "hint": "Click Shift, Ctrl, Alt or Meta to see what the keys do with it held, or hold it. Click a key to list its actions.", + "notShown": "%{n} keybinds sit on keys neither keyboard draws.", + "numpad": "Numpad", + "numpadTooltip": "Show the arrow keys, the navigation keys and the number pad. Click again for the main keys.", + "unbound": "Nothing bound", + "anyModifier": "with any modifier", + "paired": "with or without Shift", + "clickKey": "Click to list everything on this key.", + "clickModifier": "Click to show what the keys do with %{mod} held.", + "clickModifierOff": "Click to stop showing the keys with %{mod} held." }, - "issueBuildOrders": { - "title": "Giving build orders", - "orderKey": "left mouse", - "order": "Give build order", - "deselectKey": "right mouse", - "deselect": "De-select build order", - "lineKey": "shift + (build order)", - "line": "Build in a line", - "gridKey": "shift + alt + (build order)", - "grid": "Build in a square", - "spacingUp": "Increase build spacing", - "spacingDown": "Decrease build spacing" - }, - "massSelect": { - "title": "Group selection", - "all": "Select all units", - "builders": "Select all constructors", - "createGroupKey": "ctrl + (num)", - "createGroup": "Add units to group (num=1,2,..)", - "createAutoGroupKey": "alt + (num)", - "createAutoGroup": "Add unit type to autogroup (num=1,2,..)", - "removeAutoGroupKey": "alt + `", - "removeAutoGroup": "Remove unit type from autogroup", - "groupKey": "(num)", - "group": "Select all units assigned to group (num)", - "sameType": "Select all units of same type as selected", - "damaged": "Select damaged units" - }, - "drawing": { - "title": "Drawing", - "mapmarkKey": "` + dbl click", - "mapmark": "Place map mark", - "drawKey": "` + drag left mouse", - "draw": "Draw on map", - "eraseKey": "` + drag right mouse", - "erase": "Erase drawings and markers" - }, - "console": { - "title": "Console commands", - "eraseKey": "/clearmapmarks", - "erase": "Erase all drawings and markers", - "pauseKey": "/pause", - "pause": "Pause" + "presets": { + "grid": "Builds through the grid menu: QWER, ASDF and ZXCV pick a slot in the build grid, so the same key builds the same slot for every builder. Orders sit on the keys around them.", + "grid60": "The Grid preset for keyboards without a function row: map views, camera anchors and the other F-key actions move to Meta + number, and what sat on ` moves to Meta + Q.", + "legacy": "The classic layout: a key per building for build hotkeys, orders on their original keys, and the build menu rather than the grid.", + "legacy60": "The Legacy preset for keyboards without a function row: map views, camera views and the other F-key actions move to Meta + number." } }, "blueprint": { @@ -548,9 +372,53 @@ } }, "teamStats": { + "title": "Statistics", "player": "Player", "dead": "%{player} (dead)", "gone": "%{player} (gone)", + "team": "Team %{number}", + "members": "%{count} players", + "memberOne": "1 player", + "notYet": "Not available yet", + "perMinuteSuffix": " / min", + "perMinuteNote": "Shown per minute the team was in the game.", + "timeInGame": "Time in game: %{time}", + "diedAt": "Died at %{time}", + "ofTeam": "%{share} of the team's %{value}", + "ofTotal": "(%{value} of %{total})", + "unitCount": "(%{count} units)", + "unitOne": "(1 unit)", + "milestones": "Milestones", + "milestone": { + "factory": "First factory", + "tech2": "Tech 2", + "tech3": "Tech 3", + "nuke": "First nuke", + "antinuke": "First anti-nuke", + "lrpc": "First long-range cannon", + "commanderLost": "Commander lost", + "teamDied": "Out of the game" + }, + "group": { + "all": "All", + "live": "Live", + "economy": "Economy", + "combat": "Combat", + "units": "Units", + "composition": "Composition", + "activity": "Activity", + "graphs": "Graphs" + }, + "switch": { + "groupByTeam": "Group by team", + "groupByTeamDesc": "Players under their team, with the team's totals above them. Off, every player is listed in one sorted list.", + "shareOfTeam": "Share of team", + "shareOfTeamDesc": "Every amount as the share of the team's total it makes up, so it shows who carried. Ratios and levels stay as they are.", + "perMinute": "Per minute", + "perMinuteDesc": "Totals divided by the minutes each team was in the game, so a short game or an early exit compares fairly.", + "bars": "Bars", + "barsDesc": "A bar behind every number, scaled to the largest in its column." + }, "damage": "Damage", "damageDealt": "Dealt", "damageReceived": "Received", @@ -559,14 +427,122 @@ "unitsProduced": "Produced", "unitsKilled": "Killed", "unitsDied": "Lost", - "actionsPerMinute1": "Actions", - "actionsPerMinute2": "Per Minute", + "killEfficiency": "Efficiency", + "unitsCaptured": "Captured", + "unitsStolen": "Stolen", + "unitsReceived": "Received", + "unitsSent": "Sent", + "unitsActive": "Active", + "activity": "Activity", "aggression": "Aggression", - "aggressionLevel": "Level", + "actionsPerMinute": "Actions per minute", "metal": "Metal", "energy": "Energy", "resourceProduced": "Produced", - "resourceExcess": "Excess" + "resourceUsed": "Used", + "resourceExcess": "Excess", + "resourceSent": "Sent", + "resourceReceived": "Received", + "resourceStored": "Stored", + "shortReceived": "Recv", + "shortEfficiency": "Eff.", + "shortBuilt": "Built", + "shortProduced": "Prod.", + "shortAggression": "Aggr.", + "shortActionsPerMinute": "APM", + "resourceIncome": "Income", + "resourceExpense": "Expense", + "resourceLevel": "Level", + "industry": "Industry", + "conversion": "Conversion in use", + "shortConversion": "Conv.", + "buildPower": "Build power", + "shortBuildPower": "BP", + "buildPowerUse": "Build power in use", + "shortBuildPowerUse": "In use", + "value": "Unit value", + "unitValue": "Unit value", + "shortValue": "Value", + "valueArmy": "Army", + "shortArmy": "Army", + "valueAir": "Air", + "shortAir": "Air", + "valueSea": "Sea", + "shortSea": "Sea", + "valueDefense": "Defense", + "shortDefense": "Def.", + "valueStrategic": "Strategic", + "shortStrategic": "Strat.", + "valueFactories": "Factories", + "shortFactories": "Labs", + "valueBuilders": "Builders", + "shortBuilders": "Cons", + "valueEconomy": "Economy", + "shortEconomy": "Eco", + "valueUtility": "Utility", + "shortUtility": "Util", + "traded": "Value", + "killedValue": "Value destroyed", + "lostValue": "Value lost", + "valueEfficiency": "Value efficiency", + "teamKillValue": "Friendly fire", + "shortTeamKill": "Allied", + "commanders": "Commanders", + "comKills": "Commanders killed", + "comLost": "Commanders lost", + "desc": { + "damageDealt": "Damage dealt to enemy units.", + "damageReceived": "Damage taken from enemy units.", + "damageEfficiency": "Damage dealt as a share of damage received.", + "unitsProduced": "Units built.", + "unitsKilled": "Enemy units destroyed.", + "unitsDied": "Units lost.", + "killEfficiency": "Units destroyed as a share of units lost.", + "unitsCaptured": "Enemy units captured.", + "unitsStolen": "Units the enemy captured.", + "unitsReceived": "Units received from allies.", + "unitsSent": "Units given to allies.", + "unitsActive": "Units alive right now: built, received and captured, minus lost, given away and stolen.", + "metalProduced": "Metal produced.", + "metalUsed": "Metal spent.", + "metalExcess": "Metal wasted because storage was full.", + "metalSent": "Metal shared to allies.", + "metalReceived": "Metal received from allies.", + "metalStored": "Metal in storage right now: produced and received, minus spent, shared and wasted.", + "energyProduced": "Energy produced.", + "energyUsed": "Energy spent.", + "energyExcess": "Energy wasted because storage was full.", + "energySent": "Energy shared to allies.", + "energyReceived": "Energy received from allies.", + "energyStored": "Energy in storage right now: produced and received, minus spent, shared and wasted.", + "aggressionLevel": "How much damage was dealt for the resources gathered, on a logarithmic scale: 10 × log10 of damage dealt over metal plus a sixtieth of energy, produced and received. Higher is more aggressive.", + "actionsPerMinute": "Commands given per minute of game time.", + "metalIncome": "Metal coming in per second right now.", + "metalExpense": "Metal going out per second right now.", + "metalLevel": "How full the metal storage is right now.", + "energyIncome": "Energy coming in per second right now.", + "energyExpense": "Energy going out per second right now.", + "energyLevel": "How full the energy storage is right now.", + "conversion": "The share of the team's energy conversion capacity converting right now.", + "buildPower": "Build power of every builder, nano turret and factory the team has.", + "buildPowerUse": "The share of the team's build power at work right now: building, repairing, reclaiming or resurrecting.", + "unitValue": "What every unit the team has cost, in metal with energy at a sixtieth.", + "valueArmy": "Value of the mobile ground army: every armed ground and hover unit.", + "valueAir": "Value of every aircraft that is not a builder.", + "valueSea": "Value of every armed ship and submarine.", + "valueDefense": "Value of every static defense: turrets, anti-air, mines and shields with weapons.", + "valueStrategic": "Value of nukes, anti-nukes and long-range cannons.", + "valueFactories": "Value of every factory.", + "valueBuilders": "Value of every constructor, nano turret and commander.", + "valueEconomy": "Value of every energy and metal maker.", + "valueUtility": "Value of everything else: radars, jammers, storage, transports and scouts.", + "killedValue": "What the enemy units this team destroyed had cost, in metal with energy at a sixtieth. A unit under construction counts for what was put into it.", + "lostValue": "What the units this team lost had cost, the same way.", + "valueEfficiency": "Value destroyed as a share of value lost.", + "teamKillValue": "What the allied units this team destroyed had cost.", + "comKills": "Enemy commanders destroyed.", + "comLost": "Commanders lost." + } }, "reclaimInfo": { "metal": "Metal: %{metal}", @@ -609,12 +585,12 @@ "heightTitle": "Height", "heightmap": "[%{keyset}] Displays a different color for every height level", "pathingTitle": "Traversability", - "pathing":"[%{keyset}] Shows where the selected unit can move, Green: okay, Red: problematic, Purple: can't move", + "pathing": "[%{keyset}] Shows where the selected unit can move, Green: okay, Red: problematic, Purple: can't move", "resourcesTitle": "Resources", "resources": "[%{keyset}] Highlights metal spots in green and geothermal vents in yellow.\n Occupied metal spots are shown in red." }, "pauseScreen": { - "paused": "GAME PAUSED" + "paused": "GAME PAUSED" }, "voting": { "no": "NO", @@ -627,26 +603,40 @@ }, "gameInfo": { "title": "Game info", + "all": "All", + "search": "Search...", + "changedOnly": "Changed only", + "game": "Game", + "version": "Version", + "mutator": "Mutator", "engine": "Engine", "engineVersionError": "engine version error", - "decodefailed": "decode failed", + "decodefailed": "could not be decoded", + "tweakFailed": "this tweak was NOT applied", + "was": "was", + "wasNew": "new", + "copyHint": "Drag to select these lines, Ctrl+A for all of them, Ctrl+C to copy.", + "map": "Map", + "mapInfo": "Map info", + "mapSettings": "Map settings", "size": "Size", "gravity": "Gravity", "hardness": "Hardness", "tidalStrength": "Tidal speed", "windStrength": "Wind speed", "waterDamage": "Water damage", - "raptorOptions": "Raptor options", - "adjustedSettings": "Adjusted settings", - "settings": "Settings", + "startPositions": "Start positions", + "startPosFixed": "Fixed", + "startPosRandom": "Random", + "startPosChoose": "Chosen in game", "range": "Range", "default": "Default", "reclaimableMetal": "Reclaimable Metal", "reclaimableEnergy": "Reclaimable Energy" }, "cmdlimiter": { - "forceresignwarning": "You queued too much buildings a few time, continue this and you will get forcefully resigned.", - "forceresign": "You queued too much buildings too many times, you have been force resigned!" + "forceresignwarning": "You queued too many buildings a few times, continue and you will get forcefully resigned.", + "forceresign": "You queued too many buildings too many times, you have been forcibly resigned!" }, "raptors": { "firstWave1": "The Raptors have arrived!", @@ -654,14 +644,14 @@ "airWave1": "Flying Raptors, incoming!", "airWave2": "%{unitCount} Raptors!", "queenIsAngry1": { - "one" : "The Queen is here!", - "other" : "The Queens are here!" + "one": "The Queen is here!", + "other": "The Queens are here!" }, "queenIsAngry2": "Prepare yourself for the final battle!", "queenResistant": "The Queen is becoming resistant to %{unit} attacks!", - "resistanceUnits":{ - "one" : "The Queen is becoming resistant to:", - "other" : "The Queens are becoming resistant to:" + "resistanceUnits": { + "one": "The Queen is becoming resistant to:", + "other": "The Queens are becoming resistant to:" }, "wave1": "Wave %{waveNumber}", "wave2": "%{unitCount} Raptors!", @@ -674,25 +664,25 @@ "queenAngerAggression": "Player Aggression: +%{value}%%/s", "queenAngerEco": "Economy: +%{value}%%/s", "queenETA": { - "one" : "Queen arrives in: %{time}.", - "other" : "%{count} queens arrive in: %{time}" + "one": "Queen arrives in: %{time}.", + "other": "%{count} queens arrive in: %{time}" }, "queenHealth": { - "one" : "Queen Health: %{health}%%", - "other" : "Queen Healths: %{health}%%" + "one": "Queen Health: %{health}%%", + "other": "Queen Healths: %{health}%%" }, "queensKilled": "Queens Killed: %{nKilled}/%{nTotal}", "queenResistantToList": { - "one" : "Queen is resistant to:", - "other" : "Queens are resistant to:" + "one": "Queen is resistant to:", + "other": "Queens are resistant to:" }, "queenStaggerActive": { - "one" : "Queen is Staggered!", - "other" : "Queens are Staggered!" + "one": "Queen is Staggered!", + "other": "Queens are Staggered!" }, "queenStaggerPercentage": { - "one" : "Queen Stagger: %{value}%%", - "other" : "Queens Stagger: %{value}%%" + "one": "Queen Stagger: %{value}%%", + "other": "Queens Stagger: %{value}%%" }, "gracePeriod": "Grace Period: %{time}", "burrowCount": "Burrows: %{count}", @@ -716,14 +706,14 @@ "firstWave1": "The Scavengers have arrived!", "firstWave2": "Get ready to defend yourself!", "bossIsAngry1": { - "one" : "The Boss is here!", - "other" : "The Bosses are here!" + "one": "The Boss is here!", + "other": "The Bosses are here!" }, "bossIsAngry2": "Prepare yourself for the final battle!", "bossResistant": "The Boss is becoming resistant to %{unit} attacks!", "resistanceUnits": { - "one" : "The Boss is becoming resistant to:", - "other" : "The Bosses are becoming resistant to:" + "one": "The Boss is becoming resistant to:", + "other": "The Bosses are becoming resistant to:" }, "wave1": "Wave %{waveNumber}", "wave2": "%{unitCount} Scavs!", @@ -736,25 +726,25 @@ "bossAngerAggression": "Player Aggression: +%{value}%%/s", "bossAngerEco": "Economy: +%{value}%%/s", "bossETA": { - "one" : "Boss arrives in: %{time}.", - "other" : "%{count} bosses arrive in: %{time}" + "one": "Boss arrives in: %{time}.", + "other": "%{count} bosses arrive in: %{time}" }, "bossHealth": { - "one" : "Boss Health: %{health}%%", - "other" : "Boss Healths: %{health}%%" + "one": "Boss Health: %{health}%%", + "other": "Boss Healths: %{health}%%" }, "bossesKilled": "Bosses Killed: %{nKilled}/%{nTotal}", "bossResistantToList": { - "one" : "Boss is resistant to:", - "other" : "Bosses are resistant to:" + "one": "Boss is resistant to:", + "other": "Bosses are resistant to:" }, "bossStaggerActive": { "one" : "Boss is Staggered!", - "other" : "Bosss are Staggered!" + "other" : "Bosses are Staggered!" }, "bossStaggerPercentage": { "one" : "Boss Stagger: %{value}%%", - "other" : "Bosss Stagger: %{value}%%" + "other" : "Bosses Stagger: %{value}%%" }, "gracePeriod": "Grace Period: %{time}", "burrowCount": "Burrows: %{count}", @@ -836,7 +826,7 @@ }, "draftOrderMod": { "teamPlacement": "Team Placement", - "placeYourCom": "Place your commmander", + "placeYourCom": "Place your commander", "waitingFor": "Waiting for %{name}", "waitingForTurn": "Waiting for turn", "waitingForPlayers": "Waiting for players", @@ -845,7 +835,7 @@ "modeRandom": "Random draft order start position mode activated. Players await their turn to place", "modeCaptain": "Captain draft order start position mode activated. Players await their turn to place", "modeSkill": "Skill-based draft order start position mode activated. Players await their turn to place", - "modeFair": "Fair start position mode activated. Please wait until players joins the game before placing" + "modeFair": "Fair start position mode activated. Please wait until players join the game before placing" }, "substitutePlayers": { "offer": "Offer to play", @@ -880,7 +870,7 @@ }, "moveAttackNotify": { "underAttack": "%{unit} is being attacked!", - "cantMove":"%{unit}: Can't reach destination!" + "cantMove": "%{unit}: Can't reach destination!" }, "unitShare": { "shared": "shared %{units} to %{name}", @@ -944,7 +934,98 @@ "button_factoryresetluaui": "Factory Reset LuaUI", "file": "File", "author": "Author", - "islocal": "local" + "islocal": "local", + "isrml": "rml", + "iserror": "error", + "category": { + "changed": "Changed", + "local": "Your own", + "rml": "RmlUi", + "all": "All", + "favorite": "Favorites", + "interface": "Interface", + "commands": "Commands", + "units": "Units", + "camera": "Camera", + "graphics": "Graphics", + "sound": "Sound", + "map": "Map", + "minimap": "Minimap", + "api": "API", + "debug": "Debug", + "other": "Other" + }, + "search": "Search...", + "localonly": "Local only", + "factorydefaults": "Factory defaults", + "cancel": "Cancel", + "confirm": "Confirm", + "factorydefaultswarn": "This throws away every interface setting you have: which widgets are on, their positions, and anything you have configured in them. LuaUI reloads immediately. It cannot be undone.", + "unloadallwarn": "Switches off every widget in the list at once. Your settings are kept, and you can switch them back on one at a time.", + "disallowuserwarn": "Stops loading widgets from your own LuaUI folder, leaving only the ones the game ships. LuaUI reloads immediately.", + "allowuserwarn": "Loads widgets from your own LuaUI folder again alongside the ones the game ships. LuaUI reloads immediately.", + "resetwarn": "Puts every widget back to the set the game enables by default. Widgets you added stay on disk. LuaUI reloads immediately.", + "state": { + "on": "Running", + "pending": "Enabled, but not running", + "off": "Off" + }, + "enabledonly": "Enabled only", + "errorsonly": "Errors only", + "byorder": "By load order", + "total": "total", + "defaulton": "default enabled", + "defaultoff": "default disabled", + "profiler": "Cost", + "byload": "By cost", + "alldesc": "Every widget the game knows about, whether it is running or not.", + "favoritedesc": "The widgets you have starred. Click the star ahead of any widget's name to put it here, and click it again to take it out. Nothing else about the widget changes.", + "staradd": "Add to favorites", + "starremove": "Remove from favorites", + "stardesc": "Collects this widget under Favorites at the top of the column, so it is one click away whatever else the list is showing. It is a bookmark and nothing more: it does not switch the widget on, and it is remembered between games.", + "changeddesc": "Every widget switched to something other than what it ships as - on when it ships off, or off when it ships on. What this game has been customised into, and exactly what Factory defaults would undo.", + "prefixdesc": "Widgets whose file begins with %{prefix}.", + "otherdesc": "Widgets whose file begins with something this panel has no category for.", + "countsdesc": "The count reads how many are running out of how many there are.", + "localdesc": "The widgets in your own LuaUI folder rather than the ones the game ships. They carry a local tag on the row too.", + "rmldesc": "The widgets that build their interface with RmlUi, laid out from markup and style sheets, rather than drawing it themselves. Found by what their source uses rather than where the file sits, so your own count too. They carry an rml tag on the row as well.", + "localonlydesc": "Show only the widgets in your own LuaUI folder, leaving out the ones the game ships.", + "enabledonlydesc": "Show only the widgets the config says to load - running or not - so what is off stays out of the way.", + "errorsonlydesc": "Show only the widgets that have raised an error this session - the ones tagged error - whether an error stopped them or they are running again.", + "byorderdesc": "Order the list the way the widgets load, which is the order their call-ins run in. Anything not running has no place in that order and follows at the end.", + "profilerdesc": "Show what each widget costs: processor time as a share of the frame, and memory allocated per second. Measuring it means timing every call-in of every widget, so this is only paid for while it is switched on.", + "byloaddesc": "Order the list by what each widget costs, heaviest first. The order stands still while the cursor is over the list, so nothing slides out from under a click, and catches up when the cursor leaves.", + "reloaddesc": "Loads every widget again from disk, keeping what is switched on. The quickest way to pick up a widget you have just edited.", + "loadsetdesc": "Switches on every widget in the chosen set and switches off everything else, so the list ends up exactly as the set describes it.", + "order": "Load order", + "cleardata": "Reset", + "showdata": "Show data", + "showdatadesc": "Everything this widget has saved, the way it is stored. Click to read all of it.", + "errorsloading": "while loading", + "errorsmore": "more", + "depsuses": "Uses", + "depsusedby": "Used by", + "depsoff": "off", + "depsrunningone": "running widget", + "depsrunning": "running widgets", + "depsunchecked": "never check it is there", + "depswarn": "%{count} running widgets use what %{name} provides without checking it is there, so switching it off will most likely break them:", + "depswarnone": "A running widget uses what %{name} provides without checking it is there, so switching it off will most likely break it:", + "depscause": "%{key} comes from %{provider}, which is off", + "close": "Close", + "cleardatatitle": "Clear saved settings", + "cleardatawarn": "Throws away everything %{name} has saved - its options, its window position, whatever it remembers - and it starts again from its defaults. Nothing else in the list is touched.", + "cleardatarestartwarn": "Throws away everything %{name} has saved - its options, its window position, whatever it remembers. It is running, so it is switched off and on again to start from its defaults. Nothing else in the list is touched.", + "layer": "Layer", + "sets": "Widget sets", + "noset": "No set", + "saveset": "Save", + "deleteset": "Delete", + "savesettitle": "Save widget set", + "savesetwarn": "Remembers which widgets are switched on right now under this name. Saving over a name you already have replaces it.", + "deletesettitle": "Delete widget set", + "deletesetwarn": "Forgets this set. The widgets it switched on stay as they are.", + "loadset": "Load" }, "unitstats": { "prog": "Prog", @@ -1079,15 +1160,15 @@ "metalIncome_title": "Metal Income", "metalIncome_tooltip": "Metal income per second", "energyConversionMetalIncome_title": "Metal Conversion", - "energyConversionMetalIncome_tooltip": "Metal income from energy conversion" , + "energyConversionMetalIncome_tooltip": "Metal income from energy conversion", "energyIncome_title": "Energy Income", "energyIncome_tooltip": "Energy income per second", "buildPower_title": "Build Power", "buildPower_tooltip": "Build power at disposal", "metalProduced_title": "Metal Produced", - "metalProduced_tooltip": "Total metal produced" , + "metalProduced_tooltip": "Total metal produced", "energyProduced_title": "Energy Produced", - "energyProduced_tooltip": "Total energy produced" , + "energyProduced_tooltip": "Total energy produced", "metalExcess_title": "Metal Excess", "metalExcess_tooltip": "Total metal excess", "energyExcess_title": "Energy Excess", @@ -1179,7 +1260,7 @@ "vsync_fraction": "frame limiter", "vsync_fraction_descr": "Render only a fractional number of frames", "limitoffscreenfps": "Limit FPS when offscreen", - "limitoffscreenfps_descr": "Reduces fps when offscreen (by setting vsync to a high number)\n(for borderless window and fullscreen need engine not have focus)\nMakes your pc more responsive/cooler when you do stuff outside the game\nCamera movement will break idle mode", + "limitoffscreenfps_descr": "Reduces fps when offscreen (by setting vsync to a high number)\n(for borderless window and fullscreen need engine not have focus)\nMakes your PC more responsive/cooler when you do stuff outside the game\nCamera movement will break idle mode", "limitidlefps": "also limit when idle", "limitidlefps_descr": "Reduces fps when idle for a minute (by setting vsync to a high number)\nCamera movement will break idle mode", "msaa": "Anti Aliasing", @@ -1299,7 +1380,7 @@ "snowmap": "enabled on this map", "snowmap_descr": "It will remember what you toggled for every map\n\n(by default: maps with wintery names have this toggled)", "snowautoreduce": "auto reduce", - "snowautoreduce_descr": "Automatically reduce snow when average FPS gets lower\n\n(re-enabling this needs time to readjust to average fps again", + "snowautoreduce_descr": "Automatically reduce snow when average FPS gets lower\n\n(re-enabling this needs time to readjust to average FPS again)", "snowamount": "amount", "snowamount_descr": "disable \"auto reduce\" option to see the max snow amount you have set", "xmas": "X-mas balls", @@ -1521,8 +1602,6 @@ "gridmenu_shiftkeymodifier_descr": "When holding down shift key and using hotkeys to queue units, queue this many units.", "keylayout": "Keyboard Layout", "keylayout_descr": "Set the keyboard layout", - "keybindings": "Keybind Preset", - "keybindings_descr": "Set the keybind preset. Buildmenu will automatically change to match preset, unless custom is chosen", "buildmenu": "Build menu", "buildmenu_bottom": "bottom position", "buildmenu_bottom_descr": "Relocate the buildmenu to the bottom of the screen", @@ -1663,7 +1742,7 @@ "metalspots_descr": "Shows a circle around (unoccupied) metal spots with the amount of metal in it", "metalspots_opacity": "opacity", "metalspots_values": "show values", - "metalspots_values_descr": "Display metal values (during game)\nPre-gamestart or when in metalmap view (f4) this will always be shown\n\nNote that it's significantly enough more costly to draw the text values", + "metalspots_values_descr": "Display metal values (during game)\nPre-gamestart or when in metalmap view (f4) this will always be shown\n\nNote that it's significantly more costly to draw the text values", "metalspots_metalviewonly": "limit to F4 (metalmap) view", "metalspots_metalviewonly_descr": "Limit display to only during pre-gamestart or when in metalmap view (f4)", "geospots": "Geothermals", @@ -1836,7 +1915,7 @@ "factoryrepeat_descr": "Sets new factories on Repeat mode", "settargetdefault": "Set-target as default", "settargetdefault_descr": "Replace default attack command to a set-target command\n(when rightclicked on enemy unit)", - "dgunnogroundenemies": "Dont snap DGun to ground units", + "dgunnogroundenemies": "Don't snap DGun to ground units", "dgunnogroundenemies_descr": "Prevents dgun aim to snap onto enemy ground units.\nholding SHIFT will still target units\n\nWill still snap to air, ships and hovers (when on water)", "dgunstallassist": "Conserve energy when DGunning", "dgunstallassist_descr": "When the D-Gun order is active, units that drain energy will pause their energy use.", @@ -1848,6 +1927,8 @@ "catchupminfps_descr": " ", "widgetselector": "Widget selector interface", "widgetselector_descr": "Allow the toggling of the widget selector interface (via F11)", + "windows_hideinterface": "Windows hide the interface", + "windows_hideinterface_descr": "While a window such as Settings, Keys or Stats is open, hide the rest of the interface and block its input. The top bar menu buttons, chat, votes and the pause overlay stay.", "devmode": "Developer UI", "devmode_descr": "Toggle between how a developer or player see the UI", "customwidgets": "Allow custom widgets", @@ -1980,11 +2061,11 @@ "unallocatedBudget": "Quick Start enabled: use your Base Budget first" } }, - "cmd":{ - "cheat":"Allow commands for cheating", - "give":"Give units (needs /cheat)", - "globallos":"See everything (needs /cheat)", - "godmode":"God mode (needs /cheat)", + "cmd": { + "cheat": "Allow commands for cheating", + "give": "Give units (needs /cheat)", + "globallos": "See everything (needs /cheat)", + "godmode": "God mode (needs /cheat)", "advmapshading": "Control advanced map shading mode", "advmodelshading": "Control advanced model shading mode", "aicontrol": "Creates a new instance of a Skirmish AI, to let it control a specific team", @@ -2014,6 +2095,14 @@ "debugapihighlightunit": "Toggle debug output for the Highlight Unit GL4 API widget", "debugapiunittracker": "[draw|level] Toggle Unit Tracker GL4 debug draw or set debug level", "debughealthbars": "Toggle debug mode for Health Bars GL4", + "debugtargetpriority": { + "_description": "Debug a selected unit's autotarget priority values.", + "on": "Show priorities", + "off": "Hide priorities", + "lines": "Show the line through targets in selection order", + "details": "Show the priority factor breakdown, plus rank, chase pick", + "weapon": "[n] Pick the selected unit's weapon number to debug" + }, "decalsgl4skipdraw": "Toggle Decals GL4 rendering on/off (debug)", "decalsgl4stats": "Print Decals GL4 runtime stats", "defrange": "Toggle defense range overlays", @@ -2079,7 +2168,7 @@ "debugshadowfrustum": "Enable/Disable drawing of shadow frustum shape", "debugtraceray": "Enable/Disable drawing of traceray debug-data", "debugvisibility": "Enable/Disable drawing of visible terrain", - "decguiopacity": "Decreases the the opacity(see-through-ness) of GUI elements", + "decguiopacity": "Decreases the opacity (see-through-ness) of GUI elements", "decreaseviewradius": "Decrease the view radius (higher performance, uglier view)", "deselect": "Deselects all currently selected units", "destroy": "Destroys one or multiple units by unitID immediately (needs /cheat)", @@ -2128,7 +2217,7 @@ "iconsasui": "Set whether unit icons are drawn as an UI element (true) or old LOD-like style (false, default).", "iconscaleui": "Set the multiplier for the size of the UI unit icons", "iconshidewithui": "Set whether unit icons are hidden when UI is hidden.", - "incguiopacity": "Increases the the opacity(see-through-ness) of GUI elements", + "incguiopacity": "Increases the opacity (see-through-ness) of GUI elements", "increaseviewradius": "Increase terrain tessellation level", "info": "Shows/Hides the player roster", "inputtextgeo": "Move and/or resize the input-text field (the \"Say: \" thing)", @@ -2220,7 +2309,7 @@ "AnimationMT": "Enable multithreaded execution of animation ticks", "AtiHacks": "Enables graphics drivers workarounds for users with AMD proprietary drivers.\n -1:=runtime detect, 0:=off, 1:=on", "AtiSwapRBFix": "No description available", - "AutoAddBuiltUnitsToFactoryGroup": "Controls whether or not units built by factories will inherit that factory\u0027s unit group.", + "AutoAddBuiltUnitsToFactoryGroup": "Controls whether or not units built by factories will inherit that factory's unit group.", "AutoAddBuiltUnitsToSelectedGroup": "No description available", "AutohostIP": "No description available", "AutohostPort": "Which port should the engine listen on for Autohost interface connections.", @@ -2243,12 +2332,12 @@ "CameraMoveSlowMult": "The multiplier applied to speed when camera is in moveslow state.", "CamFrameTimeCorrection": "Sets whether the camera interpolation factor should be the inverse of fps or last draw frame time (0 = lastdrawframetime, 1 = fpsinv)", "CamFreeAngVelTime": "No description available", - "CamFreeAutoTilt": "When free camera is locked, AutoTilt will point the camera in the direction of the ground\u0027s slope", + "CamFreeAutoTilt": "When free camera is locked, AutoTilt will point the camera in the direction of the ground's slope", "CamFreeEnabled": "No description available", "CamFreeFOV": "No description available", "CamFreeGoForward": "No description available", "CamFreeGravity": "When free camera is locked, Gravity will be used if you jump off of a ground ramp.", - "CamFreeGroundOffset": "Determines ground handling for the free camera.\n0 - the camera can move anywhere,\n\u003c 0 - the camera is always offset from the ground height by -CamFreeGroundOffset\n\u003e 0 - the camera can be \"locked\" to the ground by using SHIFT UP_ARROW. (and will use CamFreeGroundOffset as the offset). To release the lock, simply press SHIFT DOWN_ARROW.", + "CamFreeGroundOffset": "Determines ground handling for the free camera.\n0 - the camera can move anywhere,\n< 0 - the camera is always offset from the ground height by -CamFreeGroundOffset\n> 0 - the camera can be \"locked\" to the ground by using SHIFT UP_ARROW. (and will use CamFreeGroundOffset as the offset). To release the lock, simply press SHIFT DOWN_ARROW.", "CamFreeInvertAlt": "No description available", "CamFreeScrollSpeed": "No description available", "CamFreeSlide": "No description available", @@ -2265,7 +2354,7 @@ "CamSpringFOV": "No description available", "CamSpringHalflife": "For Spring Dampened camera. It is the time in milliseconds at which the camera should be approximately halfway towards the goal.", "CamSpringLockCardinalDirections": "Whether cardinal directions should be `locked` for a short time when rotating.", - "CamSpringMinZoomDistance": "Minimum camera zoom distance, note this is the distance from frustrum location to the ground in the direction of view", + "CamSpringMinZoomDistance": "Minimum camera zoom distance, note this is the distance from frustum location to the ground in the direction of view", "CamSpringScrollSpeed": "No description available", "CamSpringTrackMapHeightMode": "Camera height is influenced by terrain height. 0=Static 1=Terrain 2=Smoothmesh", "CamSpringZoomInToMousePos": "No description available", @@ -2295,7 +2384,7 @@ "DumpGameStateOnDesync": "Enable writing clientgamestate and servergamestate dumps when a desync is detected", "EdgeMoveDynamic": "If EdgeMove scrolling speed should fade with edge distance.", "EdgeMoveWidth": "The width (in percent of screen size) of the EdgeMove scrolling area.", - "ExtraTextureUpdateRate": "EXTREME CPU-HEAVY ON MEDIUM/BIG MAPS! DON\u0027T CHANGE DEFAULT!", + "ExtraTextureUpdateRate": "EXTREME CPU-HEAVY ON MEDIUM/BIG MAPS! DON'T CHANGE DEFAULT!", "FeatureDrawDistance": "Maximum distance at which features will be drawn.", "FeatureFadeDistance": "Distance at which features will begin to fade from view.", "FontFile": "Sets the font of Spring engine text.", @@ -2322,7 +2411,7 @@ "GLContextMajorVersion": "No description available", "GLContextMinorVersion": "No description available", "GrassDetail": "Sets how detailed the engine rendered grass will be on any given map.", - "GroundDecals": "Controls whether ground decals underneath buildings, unit tracks \u0026 footprints as well as ground scars from explosions will be rendered.", + "GroundDecals": "Controls whether ground decals underneath buildings, unit tracks & footprints as well as ground scars from explosions will be rendered.", "GroundDetail": "Controls how detailed the map geometry will be. On lowered settings, cliffs may appear to be jagged or \"melting\".", "GroundLODScaleReflection": "No description available", "GroundLODScaleRefraction": "No description available", @@ -2360,7 +2449,7 @@ "LODScaleRefraction": "No description available", "LODScaleShadow": "No description available", "LogClientData": "No description available", - "LogFlushLevel": "Flush the logfile when a message\u0027s level exceeds this value. ERROR is flushed by default, WARNING is not.", + "LogFlushLevel": "Flush the logfile when a message's level exceeds this value. ERROR is flushed by default, WARNING is not.", "LogRepeatLimit": "Allow at most this many consecutive identical messages to be logged.", "LogSections": "Comma-separated list of enabled logsections, see infolog.txt / console output for possible values.", "LuaGarbageCollectionMemLoadMult": "How much the amount of Lua memory in use increases the rate of garbage collection.", @@ -2405,7 +2494,7 @@ "MouseDragScrollThreshold": "No description available", "MouseDragSelectionThreshold": "Distance in pixels which the mouse must be dragged to trigger a selection box.", "MouseRelativeModeWarp": "No description available", - "MSAALevel": "Enables multisample anti-aliasing; \u0027level\u0027 is the number of samples used.", + "MSAALevel": "Enables multisample anti-aliasing; 'level' is the number of samples used.", "name": "Sets your name in the game. Since this is overridden by lobbies with your lobby username when playing, it usually only comes up when viewing replays or starting the engine directly for testing purposes.", "NetworkLossFactor": "No description available", "NetworkTimeout": "Number of seconds before connection to game server is considered lost.", @@ -2419,7 +2508,7 @@ "PathingThreadCount": "No description available", "PitchAdjust": "Adjusts sound pitch proportional to [if set to 1, the square root of] game speed. Set to 2 for linear scaling.", "PreloadModels": "The engine will preload all models", - "RapidTagResolutionOrder": "\u0027;\u0027 separated list of domains, preference order for resolving package from rapid tags", + "RapidTagResolutionOrder": "';' separated list of domains, preference order for resolving package from rapid tags", "ReconnectTimeout": "No description available", "RendererHash": "No description available", "ROAM": "Use ROAM for terrain mesh rendering: 0 to disable, 1=VBO mode to enable.", @@ -2465,10 +2554,10 @@ "SoftParticles": "Soften up CEG particles on clipping edges", "Sound": "Enables (OpenAL) or disables sound.", "SourcePort": "No description available", - "SpeedControl": "Sets how server adjusts speed according to player\u0027s load (CPU), 1: use average, 2: use highest", + "SpeedControl": "Sets how server adjusts speed according to player's load (CPU), 1: use average, 2: use highest", "SplashScreenDir": "No description available", - "SpringData": "List of additional data-directories, separated by \u0027;\u0027 on Windows and \u0027:\u0027 on other OSs", - "SpringDataRoot": "Optional custom data-directory content root (\u0027base\u0027, \u0027maps\u0027, ...) to scan for archives", + "SpringData": "List of additional data-directories, separated by ';' on Windows and ':' on other OSs", + "SpringDataRoot": "Optional custom data-directory content root ('base', 'maps', ...) to scan for archives", "SSMFTexAniso": "No description available", "StoreDefaultSettings": "springsettings.cfg will save the settings values, if they match the implicit defaults and were set by a user explicitly", "TCPAllowConnect": "No description available", @@ -2484,7 +2573,7 @@ "UnitIconDist": "No description available", "UnitIconFadeStart": "No description available", "UnitIconFadeVanish": "No description available", - "UnitIconsAsUI": "Draw unit icons like it is an UI element and not like unit\u0027s LOD.", + "UnitIconsAsUI": "Draw unit icons like it is an UI element and not like unit's LOD.", "UnitIconScaleUI": "No description available", "UnitIconsHideWithUI": "Hide unit icons when UI is hidden.", "UnitLodDist": "No description available", @@ -2541,7 +2630,7 @@ "teamhighlight": "Enables/Disables uncontrolled team blinking", "toggleinfo": "Toggles current info texture view", "togglelos": "Enable rendering of the auxiliary LOS-map overlay", - "tooltip": "Enables/Disables the general tool-tips, displayed when hovering over units. features or the map", + "tooltip": "Enables/Disables the general tool-tips, displayed when hovering over units, features, or the map", "track": "Start/stop following the selected unit(s) with the camera", "trackmode": "Shift through different ways of following selected unit(s)", "tset": "Set a config key=value pair in the overlay, meaning it will not be persisted for future games", @@ -2566,7 +2655,7 @@ "reloadcegs": "Reloads CEG scripts (needs /cheat)", "reloadcob": "Reloads COB scripts (needs /cheat)", "skip": "Fast-forwards to a given frame, or stops fast-forwarding", - "luarules":{ + "luarules": { "loadmissiles": "Load missiles into all stockpile units", "givecat": { "_description": "[name] Select units by (multiple) category filters (add 'no' in front of filter to exclude)", @@ -2863,8 +2952,8 @@ "desc": "You can distinguish different players but everyone sees different colors locally. Diplomacy is harder but possible using positions (e.g. \"Southeast, let's ally against Northeast\")." }, "disco": { - "name": "Shuffle Locally (Continiously)", - "desc": "Same as local shuffle, except that colors are reshuffled every 2 mins for extra spicyness." + "name": "Shuffle Locally (Continuously)", + "desc": "Same as local shuffle, except that colors are reshuffled every 2 mins for extra spiciness." }, "allred": { "name": "Everyone Is Red", @@ -2971,12 +3060,12 @@ "desc": "Disables Nuke Interceptor Units and Structures." }, "unit_restrictions_nolrpc": { - "name": "Disable Long Range Artilery (LRPC)", - "desc": "Disable Long Range Plasma Artilery (LRPC) structures" + "name": "Disable Long Range Artillery (LRPC)", + "desc": "Disable Long Range Plasma Artillery (LRPC) structures" }, "unit_restrictions_noendgamelrpc": { - "name": "Disable Endgame Artilery (LRPC)", - "desc": "Disable Endgame Long Range Plasma Artilery (LRPC) structures (AKA lolcannons)" + "name": "Disable Endgame Artillery (LRPC)", + "desc": "Disable Endgame Long Range Plasma Artillery (LRPC) structures (AKA lolcannons)" }, "options": { "name": "Other", @@ -3209,7 +3298,7 @@ }, "map_lavatiderhythm": { "name": "Lava Tides", - "desc": "Lava level periodicially cycles height when tides are present", + "desc": "Lava level periodically cycles height when tides are present", "items": { "default": { "name": "Default", @@ -3612,7 +3701,7 @@ }, "teamcolors_icon_dev_mode": { "name": "Icon Dev Mode", - "desc": "(Don't use in normal games) Forces teamcolors to be an specific one, for all teams", + "desc": "(Don't use in normal games) Forces teamcolors to be a specific one, for all teams", "items": { "disabled": { "name": "Disabled", @@ -3666,19 +3755,19 @@ }, "date_year": { "name": "Year", - "desc": "Spads (Multiplayer) / Skirmish Interface (Singleplayer) fed, auto-overwriten" + "desc": "Spads (Multiplayer) / Skirmish Interface (Singleplayer) fed, auto-overwritten" }, "date_month": { "name": "Month", - "desc": "Spads (Multiplayer) / Skirmish Interface (Singleplayer) fed, auto-overwriten" + "desc": "Spads (Multiplayer) / Skirmish Interface (Singleplayer) fed, auto-overwritten" }, "date_day": { "name": "Day", - "desc": "Spads (Multiplayer) / Skirmish Interface (Singleplayer) fed, auto-overwriten" + "desc": "Spads (Multiplayer) / Skirmish Interface (Singleplayer) fed, auto-overwritten" }, "date_hour": { "name": "Hour", - "desc": "Spads (Multiplayer) / Skirmish Interface (Singleplayer) fed, auto-overwriten" + "desc": "Spads (Multiplayer) / Skirmish Interface (Singleplayer) fed, auto-overwritten" }, "factionlimiter": { "name": "Faction Limiter: ON\nBITMASK", @@ -3702,7 +3791,7 @@ }, "options_cheats": { "name": "Cheats", - "desc": "Options that alter the game balance in unintended way, Use at your own risk." + "desc": "Options that alter the game balance in unintended ways, Use at your own risk." }, "dynamiccheats": { "name": "Dynamic Cheats", @@ -3908,5 +3997,4 @@ "desc": "A base64 encoded snippet of code that modifies game definitions." } } - -} \ No newline at end of file +} diff --git a/language/en/tips.json b/language/en/tips.json index fa3a416d9bc..3fcf8365a0a 100644 --- a/language/en/tips.json +++ b/language/en/tips.json @@ -123,7 +123,7 @@ "welcomeShort": "Welcome, Commander.", "buildMetal": "Choose the metal extractor and build it on a metal spot, indicated by the rotating circles on the map.", "buildEnergy": "You will need to produce energy to efficiently construct units. Build windmills or solar panels. ", - "buildFactory":"Well done! Now you have metal and energy income. It's time to produce mobile units. Choose and build your factory of choice.", + "buildFactory":"Well done! Now you have metal and energy income. It's time to produce mobile units. Choose and build your factory of choice.", "buildRadar":"In BAR, radar can tell you the approximate location of enemy units. You should construct a radar tower to be aware of any enemies from a distance.", "factoryAir": "You can now produce aircraft. They are specifically a good support class and give great speed, radar and line of sight.", "factorySeaplanes": "You can now produce seaplanes. These are slightly stronger than t1 aircraft and are able to land underwater.", diff --git a/language/en/units.json b/language/en/units.json index 1d067c1fe37..6fe63bee9c2 100644 --- a/language/en/units.json +++ b/language/en/units.json @@ -757,6 +757,7 @@ "legmlv": "Sapper", "legmoho": "Advanced Metal Extractor", "legmohobp": "Fortifier", + "legmohobpct": "Fortifier", "legmohocon": "Advanced Metal Fortifier", "legmohoconct": "Advanced Metal Fortifier", "legmohoconin": "Advanced Metal Fortifier", @@ -1127,7 +1128,7 @@ "armfort": "Advanced Fortification", "armfrad": "Early Warning System", "armfrock": "Floating Anti-Air Missile Battery", - "armfrt": "Floating Anti-air Tower", + "armfrt": "Floating Anti-Air Tower", "armgate": "Plasma Shield", "armfus": "Produces 750 Energy", "armgatet3": "Intercepts small weaponry energy signatures of familiar types", @@ -1137,7 +1138,7 @@ "armgremlin": "Stealth Tank", "armguard": "Area Control Plasma Artillery", "armhaap": "Produces Experimental Aircraft", - "armhaapuw": "Produce Advanced Aircraft", + "armhaapuw": "Produces Advanced Aircraft", "armhaca": "Experimental Combat Engineer", "armhack": "Experimental Combat Engineer", "armhacs": "Experimental Combat Engineer", @@ -1153,7 +1154,7 @@ "armjam": "Radar Jammer Vehicle", "armjamt": "Jammer Tower", "armjanus": "Twin Medium Rocket Launcher", - "armjeth": "Amphibious Anti-air Bot", + "armjeth": "Amphibious Anti-Air Bot", "armjuno": "Anti Radar / Jammer / Minefield / ScoutSpam Weapon", "armkam": "Light Gunship", "armkraken": "Floating Rapid-fire Plasma Tower", @@ -1202,7 +1203,7 @@ "armpnix": "Strategic Bomber", "armpship": "Assault Frigate", "armpshipt3": "Quad Gatling Hyper-Laser Assault Ship", - "armpt": "Stealthy Patrol Boat / Light Anti Air / Sonar", + "armpt": "Stealthy Patrol Boat / Light Anti-Air / Sonar", "armptt2": "Anti-Sub and Anti-Air Support Ship", "armpw": "Fast Infantry Bot", "armpwt4": "Amphibious Fast Infantry Bot", @@ -1213,7 +1214,7 @@ "armrectr": "Stealthy Rez / Repair / Reclaim Bot", "armrectrt4": "Stealthy Rez / Repair / Reclaim Bot", "armrespawn": "Assist & Repair in massive radius.", - "armrl": "Light Anti-air Tower", + "armrl": "Light Anti-Air Tower", "armrock": "Rocket Bot - good vs. static defenses", "armroy": "Destroyer", "armsaber": "Seaplane Gunship", @@ -1343,7 +1344,7 @@ "corcomlvl7": "Specialized in frontline warfare", "corcomlvl8": "Specialized in frontline warfare", "corcomlvl9": "Specialized in frontline warfare", - "corcrash": "Amphibious Anti-air Bot", + "corcrash": "Amphibious Anti-Air Bot", "corcrus": "Cruiser", "corcrw": "Flying Fortress", "corcrwh": "Flying Fortress", @@ -1376,7 +1377,7 @@ "corfast": "Combat Engineer", "corfatf": "Enhanced Radar Targeting", "corfav": "Light Scout Vehicle", - "corfblackhyt4": "Flagship with Vtol thrusters... wait what?", + "corfblackhyt4": "Flagship with VTOL thrusters... wait what?", "corfdoom": "Floating Multi-Weapon Platform", "corfdrag": "Naval Fortification", "corfgate": "Floating Plasma Shield", @@ -1391,7 +1392,7 @@ "corfort": "Advanced Fortification", "corfrad": "Early Warning System", "corfrock": "Floating Anti-Air Missile Battery", - "corfrt": "Floating Anti-air Tower", + "corfrt": "Floating Anti-Air Tower", "corfship": "Anti-Swarm Ship", "corftiger": "Main Battle Tank", "corfus": "Produces 850 Energy", @@ -1477,7 +1478,7 @@ "corprince": "Long-Range Heavy Bombardment Artillery Ship", "corprinter": "Armored Field Engineer", "corpship": "Assault Frigate", - "corpt": "Missile Corvette / Light Anti Air / Sonar", + "corpt": "Missile Corvette / Light Anti-Air / Sonar", "corpun": "Area Control Plasma Artillery", "corpyro": "Fast Assault Bot", "corrad": "Early Warning System", @@ -1485,7 +1486,7 @@ "correap": "Heavy Assault Tank", "correcl": "Resurrection Sub", "correspawn": "Assist & Repair in massive radius.", - "corrl": "Light Anti-air Tower", + "corrl": "Light Anti-Air Tower", "corroach": "Amphibious Crawling Bomb", "corroy": "Destroyer", "corsala": "Medium Heat Ray Amphibious Tank", @@ -1735,6 +1736,7 @@ "legmlv": "Stealthy Minelayer / Minesweeper", "legmoho": "Advanced Metal Extractor / Storage", "legmohobp": "Advanced Metal Extractor / Build Drone Pad", + "legmohobpct": "Advanced Metal Extractor / Build Drone Pad", "legmohocon": "Advanced Metal Extractor and Construction Turret", "legmohoconct": "Advanced Metal Extractor and Construction Turret", "legmohoconin": "You aren't supposed to see this one", @@ -1763,7 +1765,7 @@ "legperdition": "Long Range Napalm Launcher", "legphoenix": "Heavy Assault Heatray Bomber", "legrad": "Early Warning System", - "legrail": "Long-range Skirmisher / Anti-air", + "legrail": "Long-range Skirmisher / Anti-Air", "legrampart": "Geothermal Antinuke, Jammer, Radar and Drone Platform", "legrezbot": "Stealthy Resurrection / Repair / Reclaim Bot", "legrhapsis": "Salvo Anti-Air Missile Battery", diff --git a/language/es/commands.json b/language/es/commands.json new file mode 100644 index 00000000000..293a74a77f5 --- /dev/null +++ b/language/es/commands.json @@ -0,0 +1,154 @@ +{ + "commands": { + "move": "Mover", + "move_tooltip": "Mueve a una unidad a una posición, o sigue a otras unidades", + "stop": "Alto", + "stop_tooltip": "Cancela las acciones actuales de las unidades", + "attack": "Atacar", + "attack_tooltip": "Ataca a una unidad o zona del terreno", + "areaattack": "Ataque en área", + "areaattack_tooltip": "Dibuja un círculo y ataca a todo lo que esté dentro de él (clic + arrastrar)", + "manualfire": "Dgun", + "manualfire_tooltip": "Dispara el arma más poderosa del comandante: el cañón desintegrador", + "manuallaunch": "Lanzar", + "manuallaunch_tooltip": "Lanza un misil al objetivo", + "patrol": "Patrullar", + "patrol_tooltip": "Patrulla a lo largo de uno o más puntos de ruta", + "fight": "Luchar", + "fight_tooltip": "Ordena a las unidades que ataquen mientras se mueven a una posición", + "resurrect": "Resucitar", + "resurrect_tooltip": "Reconstruye restos y los convierte en unidades (clic + arrastrar en una zona)", + "guard": "Proteger", + "guard_tooltip": "Protege a otra unidad frente a los enemigos", + "factoryguard": "Proteger fábrica", + "factoryguard_tooltip": "Los constructores creados en esta fábrica la protegerán automáticamente", + "wait": "Esperar", + "wait_tooltip": "Pausa el procesado de órdenes/colas de construcción de una unidad/fábrica", + "repair": "Reparar", + "repair_tooltip": "Repara una unidad dañada", + "reclaim": "Reclamar", + "reclaim_tooltip": "Obtén metal/energía de escombros o de otros objetos (árboles/rocas)", + "restore": "Restaurar", + "restore_tooltip": "Devuelve una zona del mapa a su altura original", + "capture": "Capturar", + "capture_tooltip": "Convierte a las unidades que pertenecen al enemigo (o aliado)", + "settarget": "Establecer Objetivo", + "settarget_tooltip": "Establece un objetivo prioritario (priorizado cuando esté dentro del alcance)", + "canceltarget": "Desmarcar objetivo", + "canceltarget_tooltip": "Desmarca el objetivo prioritario", + "areamex": "Ext. de metal en área", + "areamex_tooltip": "Haz clic y arrastra para crear un área para automatizar la extracción de metal de los extractores de todos los nodos disponibles", + "loadunits": "Cargar unidades", + "loadunits_tooltip": "Carga una unidad o unidades dentro del área marcada en el transporte", + "unloadunits": "Descargar unidades", + "unloadunits_tooltip": "Descarga una unidad o unidades dentro del área marcada del transporte", + "stockpile": "Reservas %{stockpileStatus}", + "stockpile_tooltip": "[ cantidad de reservas ] / [ cantidad de reservas objetivo ]", + "stopproduction": "Borrar cola", + "stopproduction_tooltip": "Borra la cola de construcción y cuotas de todas las unidades en las fábricas seleccionadas.", + "morph": "Mejorar", + "morph_tooltip": "Mejora al siguiente nivel tecnológico (clic adicional para cancelar)", + "Spawning Disabled": "Desactivar producción", + "Spawning Enabled": "Producción activada", + "sellunit": "Vender unidad", + "sellunit_tooltip": "Habilita las unidades seleccionadas para su venta, de esa forma los aliados podrán comprarlas. ", + "For Sale": "A la venta", + "Not For Sale": "No está a la venta", + "Fire at will": "Auto Ataque", + "Hold fire": "Alto el fuego", + "Return fire": "Devolver fuego", + "firestate_tooltip": "Ajusta las condiciones bajo las que una unidad empezará a disparar a un enemigo (sin haber recibido una orden de ataque directa)", + "Hold pos": "Mantener posición", + "Maneuver": "Maniobrar", + "Roam": "Deambular", + "movestate_tooltip": "Ajusta la distancia máxima a la que una unidad se moverá para atacar a un enemigo", + "Repeat on": "Repetir ON", + "Repeat off": "Repetir OFF", + "repeat_tooltip": "Repite la cola de órdenes de la unidad", + "Low Prio": "Baja Prioridad", + "High Prio": "Alta Prioridad", + "priority_tooltip": "Asigna recursos para el uso de este constructor cuando no haya suficientes para todas las tareas", + "Decloaked": "Visible", + "Cloaked": "Camuflado", + "wantcloak_tooltip": "Estado de Visibilidad", + " On ": "Encendido", + " Off ": "Apagado", + "onoff_tooltip": "Estado activo: enciende/apaga una unidad", + " Fly ": "Volar", + "Land": "Aterrizar", + "idlemode_tooltip": "Configura la acción de una nave cuando no tiene órdenes asignadas", + "apLandAt_tooltip": "Configura la acción de una nave cuando despega de una fábrica aérea", + "csSpawning_tooltip": "Configura el estado de aparición del transporte", + "Low traj": "Trayectoria Baja", + "High traj": "Trayectoria Alta", + "trajectory_toggle_tooltip": "Cambia el ángulo de disparo de la artillería entre trayectoria baja, alta o automática.", + "trajectory_low": "Trayectoria baja", + "trajectory_high": "Trayectoria alta", + "trajectory_auto": "Autotrayectoria", + "hound_weapon_plasma": "Plasma pesado", + "hound_weapon_gauss": "Cañón Gauss", + "hound_weapon_toggle_tooltip": "Cambia entre un proyectil de plasma lento con una gran área de efecto, o un proyectil Gauss de alta velocidad.", + "blueprint_place": "Colocar plano", + "blueprint_place_tooltip": "Coloca un plano guardado", + "blueprint_create": "Guardar plano", + "blueprint_create_tooltip": "Guarda las unidades seleccionadas en un nuevo plano (en el mismo orden de selección)", + "factoryqueuemode_normal": "Modo Cola", + "factoryqueuemode_quota": "Modo Cuota", + "factoryqueuemode_tooltip": "Cola: Construye todas las unidades en cola una vez.\nCuota: Mantén una cantidad mínima de cada unidad en el campo de batalla.", + "customOnOff": { + "lowTrajectory": "Trayectoria baja", + "highTrajectory": "Trayectoria alta", + "trajectory_tooltip": "Cambia el ángulo de disparo de la artillería entre trayectoria baja o alta", + "gauss_tooltip": "Alterna entre el cañón Gauss y el cañón de plasma pesado." + }, + "quicksharetotarget": "Compartir unidad", + "quicksharetotarget_tooltip": "Comparte la unidad con el jugador objetivo." + }, + "categories": { + "selection": "Selección de unidades", + "orders": "Selección de órdenes", + "queues": "Secuencia de órdenes", + "camera": "Movimiento de cámara", + "drawing": "Dibujado", + "sound": "Sonido" + }, + "actions": { + "massSelect": { + "all": "Seleccionar todas las unidades", + "builders": "Seleccionar todos los constructores", + "sameType": "Seleccionar todas las unidades del mismo tipo seleccionado", + "removeAutoGroup": "Eliminar tipo de unidad del autogrupo" + }, + "orders": { + "cloak": "Camuflar", + "selfDestruct": "autodestruir" + }, + "queues": { + "prepend": "Añadir orden al inicio de sec. de órdenes" + }, + "buildOrders": { + "rotate": "Cambiar orientación de edificios" + }, + "issueBuildOrders": { + "spacingUp": "Aumentar espacio de construcción", + "spacingDown": "Disminuir espacio de construcción" + }, + "camera": { + "flip": "Girar cámara" + }, + "cameraModes": { + "mapmarks": "Revisar marcas del mapa", + "heightmap": "Mostrar mapa topográfico", + "traversability": "Mostrar transitabilidad (unidad seleccionada)", + "resourceSpots": "Mostrar mapa de metales", + "interface": "Ocultar IU general" + }, + "console": { + "erase": "Borra todos tus dibujos y marcas", + "pause": "Pausa" + }, + "sound": { + "mute": "Activar / Desactivar silenciado" + } + } +} diff --git a/language/es/interface.json b/language/es/interface.json index 752af9fb44b..eca3ff441d5 100644 --- a/language/es/interface.json +++ b/language/es/interface.json @@ -67,7 +67,6 @@ "tooManyConverters2Tooltip": "Deja de construir conversores de energía y aumenta tu producción energética." } }, - "playersList": { "spectators": "Espectadores %{amount}", "enemies": "Enemigos %{amount}", @@ -167,111 +166,7 @@ "areamex_tooltip": "Consejo: Haz clic sobre este extractor dos veces para usar el comando \"Construir extractores de metal en la zona\"" }, "orderMenu": { - "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}", - "move": "Mover", - "move_tooltip": "Mueve a una unidad a una posición, o sigue a otras unidades", - "stop": "Alto", - "stop_tooltip": "Cancela las acciones actuales de las unidades", - "attack": "Atacar", - "attack_tooltip": "Ataca a una unidad o zona del terreno", - "areaattack": "Ataque en área", - "areaattack_tooltip": "Dibuja un círculo y ataca a todo lo que esté dentro de él (clic + arrastrar)", - "manualfire": "Dgun", - "manualfire_tooltip": "Dispara el arma más poderosa del comandante: el cañón desintegrador", - "manuallaunch": "Lanzar", - "manuallaunch_tooltip": "Lanza un misil al objetivo", - "patrol": "Patrullar", - "patrol_tooltip": "Patrulla a lo largo de uno o más puntos de ruta", - "fight": "Luchar", - "fight_tooltip": "Ordena a las unidades que ataquen mientras se mueven a una posición", - "resurrect": "Resucitar", - "resurrect_tooltip": "Reconstruye restos y los convierte en unidades (clic + arrastrar en una zona)", - "guard": "Proteger", - "guard_tooltip": "Protege a otra unidad frente a los enemigos", - "factoryguard": "Proteger fábrica", - "factoryguard_tooltip": "Los constructores creados en esta fábrica la protegerán automáticamente", - "wait": "Esperar", - "wait_tooltip": "Pausa el procesado de órdenes/colas de construcción de una unidad/fábrica", - "repair": "Reparar", - "repair_tooltip": "Repara una unidad dañada", - "reclaim": "Reclamar", - "reclaim_tooltip": "Obtén metal/energía de escombros o de otros objetos (árboles/rocas)", - "restore": "Restaurar", - "restore_tooltip": "Devuelve una zona del mapa a su altura original", - "capture": "Capturar", - "capture_tooltip": "Convierte a las unidades que pertenecen al enemigo (o aliado)", - "settarget": "Establecer Objetivo", - "settarget_tooltip": "Establece un objetivo prioritario (priorizado cuando esté dentro del alcance)", - "canceltarget": "Desmarcar objetivo", - "canceltarget_tooltip": "Desmarca el objetivo prioritario", - "areamex": "Ext. de metal en área", - "areamex_tooltip": "Haz clic y arrastra para crear un área para automatizar la extracción de metal de los extractores de todos los nodos disponibles", - "loadunits": "Cargar unidades", - "loadunits_tooltip": "Carga una unidad o unidades dentro del área marcada en el transporte", - "unloadunits": "Descargar unidades", - "unloadunits_tooltip": "Descarga una unidad o unidades dentro del área marcada del transporte", - "stockpile": "Reservas %{stockpileStatus}", - "stockpile_tooltip": "[ cantidad de reservas ] / [ cantidad de reservas objetivo ]", - "stopproduction": "Borrar cola", - "stopproduction_tooltip": "Borra la cola de construcción y cuotas de todas las unidades en las fábricas seleccionadas.", - "morph": "Mejorar", - "morph_tooltip": "Mejora al siguiente nivel tecnológico (clic adicional para cancelar)", - "Spawning Disabled": "Desactivar producción", - "Spawning Enabled": "Producción activada", - "sellunit": "Vender unidad", - "sellunit_tooltip": "Habilita las unidades seleccionadas para su venta, de esa forma los aliados podrán comprarlas. ", - "For Sale": "A la venta", - "Not For Sale": "No está a la venta", - - "Fire at will": "Auto Ataque", - "Hold fire": "Alto el fuego", - "Return fire": "Devolver fuego", - "firestate_tooltip": "Ajusta las condiciones bajo las que una unidad empezará a disparar a un enemigo (sin haber recibido una orden de ataque directa)", - "Hold pos": "Mantener posición", - "Maneuver": "Maniobrar", - "Roam": "Deambular", - "movestate_tooltip": "Ajusta la distancia máxima a la que una unidad se moverá para atacar a un enemigo", - "Repeat on": "Repetir ON", - "Repeat off": "Repetir OFF", - "repeat_tooltip": "Repite la cola de órdenes de la unidad", - "Low Prio": "Baja Prioridad", - "High Prio": "Alta Prioridad", - "priority_tooltip": "Asigna recursos para el uso de este constructor cuando no haya suficientes para todas las tareas", - "Decloaked": "Visible", - "Cloaked": "Camuflado", - "wantcloak_tooltip": "Estado de Visibilidad", - " On ": "Encendido", - " Off ": "Apagado", - "onoff_tooltip": "Estado activo: enciende/apaga una unidad", - " Fly ": "Volar", - "Land": "Aterrizar", - "idlemode_tooltip": "Configura la acción de una nave cuando no tiene órdenes asignadas", - "apLandAt_tooltip": "Configura la acción de una nave cuando despega de una fábrica aérea", - "csSpawning_tooltip": "Configura el estado de aparición del transporte", - "Low traj": "Trayectoria Baja", - "High traj": "Trayectoria Alta", - "trajectory_toggle_tooltip": "Cambia el ángulo de disparo de la artillería entre trayectoria baja, alta o automática.", - "trajectory_low": "Trayectoria baja", - "trajectory_high": "Trayectoria alta", - "trajectory_auto": "Autotrayectoria", - "hound_weapon_plasma": "Plasma pesado", - "hound_weapon_gauss": "Cañón Gauss", - "hound_weapon_toggle_tooltip": "Cambia entre un proyectil de plasma lento con una gran área de efecto, o un proyectil Gauss de alta velocidad.", - "blueprint_place": "Colocar plano", - "blueprint_place_tooltip": "Coloca un plano guardado", - "blueprint_create": "Guardar plano", - "blueprint_create_tooltip": "Guarda las unidades seleccionadas en un nuevo plano (en el mismo orden de selección)", - "factoryqueuemode_normal": "Modo Cola", - "factoryqueuemode_quota": "Modo Cuota", - "factoryqueuemode_tooltip": "Cola: Construye todas las unidades en cola una vez.\nCuota: Mantén una cantidad mínima de cada unidad en el campo de batalla.", - "customOnOff": { - "lowTrajectory": "Trayectoria baja", - "highTrajectory": "Trayectoria alta", - "trajectory_tooltip": "Cambia el ángulo de disparo de la artillería entre trayectoria baja o alta", - "gauss_tooltip": "Alterna entre el cañón Gauss y el cañón de plasma pesado." - }, - "quicksharetotarget": "Compartir unidad", - "quicksharetotarget_tooltip": "Comparte la unidad con el jugador objetivo." + "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}" }, "idleBuilders": { "name": "Constructores inactivos", @@ -294,7 +189,7 @@ "keybinds": { "title": "Atajos de teclado", "disclaimer": "Estos son los atajos de teclado por defecto. Si eliminas o sustituyes algún widget de acceso directo, o si usas tus propios atajos, ¡podrían dejar de funcionar!", - "howtochangekeybinds":"Para modificarlos: en Ajustes/Pestaña \"Controles\", ajusta los atajos de teclado a \"Personalizados\" para crear un archivo BAR/data/uikeys.txt.\nEdita este archivo y escribe /keyreload en el chat para volverlos a cargar en el juego.", + "howtochangekeybinds": "Para modificarlos: en Ajustes/Pestaña \"Controles\", ajusta los atajos de teclado a \"Personalizados\" para crear un archivo BAR/data/uikeys.txt.\nEdita este archivo y escribe /keyreload en el chat para volverlos a cargar en el juego.", "chat": { "title": "Chat", "send": "Enviar mensaje de chat", @@ -311,7 +206,6 @@ "share": "Compartir unidades / recursos" }, "camera": { - "title": "Movimiento de cámara", "zoomKey": "Rueda de ratón", "zoom": "Acercar / alejar cámara", "panKey": "Flechas, cursor a borde de pantalla", @@ -319,8 +213,7 @@ "tiltKey": "CTRL + Rueda de ratón", "tilt": "Cambiar ángulo de cámara", "dragKey": "Clic central (+ arrastrar)", - "drag": "Arrastrar cámara", - "flip": "Girar cámara" + "drag": "Arrastrar cámara" }, "cameraModes": { "title": "Modos de cámara", @@ -329,21 +222,13 @@ "fullscreenKey": "Alt + Borrar", "fullscreen": "Cambiar a pantalla completa", "overview": "Ver cámara general", - "los": "Activar modo Campo de Visión", - "heightmap": "Mostrar mapa topográfico", - "traversability": "Mostrar transitabilidad (unidad seleccionada)", - "mapmarks": "Revisar marcas del mapa", - "resourceSpots": "Mostrar mapa de metales", - "interface": "Ocultar IU general" + "los": "Activar modo Campo de Visión" }, "sound": { - "title": "Sonido", "volumeKey": "-/+", - "volume": "Cambiar volumen", - "mute": "Activar / Desactivar silenciado" + "volume": "Cambiar volumen" }, "selection": { - "title": "Selección de unidades", "unitsKey": "Clic izquierdo (+ arrastrar)", "units": "Elegir o deseleccionar unidades" }, @@ -355,23 +240,8 @@ "formationOrder": "Dar orden de formación a unidad(es)" }, "orders": { - "title": "Selección de órdenes", "defaultKey": "(ninguna)", - "default": "Orden por defecto (estándar: moverse)", - "move": "mover", - "attack": "atacar", - "stop": "Detener (vacía la cola de órdenes)", - "repair": "reparar", - "reclaim": "reclamar", - "resurrect": "resucitar", - "patrol": "Patrullar", - "fight": "luchar", - "setTarget": "Marcar objetivo prioritario", - "cancelTarget": "Desmarcar objetivo prioritario", - "wait": "Esperar (pausa la orden actual)", - "cloak": "Camuflar", - "dGun": "disparo manual (cañón D)", - "selfDestruct": "autodestruir" + "default": "Orden por defecto (estándar: moverse)" }, "issueOrders": { "title": "Asignación de órdenes seleccionadas", @@ -383,11 +253,9 @@ "formation": "Dar orden de formación a unidad(es)" }, "queues": { - "title": "Secuencia de órdenes", "append": "Añadir orden al final de sec. de órdenes", "appendKey": "Mayús + (Orden)", - "prependKey": "Espacio + (Orden)", - "prepend": "Añadir orden al inicio de sec. de órdenes" + "prependKey": "Espacio + (Orden)" }, "buildOrders": { "title": "Selección de órdenes de construcción", @@ -401,8 +269,7 @@ "intel": "Cambiar entre radar/defensas/etc.", "factoriesKey": "v", "factories": "Cambiar entre fábricas", - "rotateKey": "[ ´ , + ] ", - "rotate": "Cambiar orientación de edificios" + "rotateKey": "[ ´ , + ] " }, "issueBuildOrders": { "title": "Asignación de órdenes de construcción", @@ -415,29 +282,22 @@ "gridKey": "Mayús + Alt + (Construir)", "grid": "Construir en cuadrado", "spacingUpKey": "Alt + Z", - "spacingUp": "Aumentar espacio de construcción", - "spacingDownKey": "Alt + X", - "spacingDown": "Disminuir espacio de construcción" + "spacingDownKey": "Alt + X" }, "massSelect": { "title": "Selección de grupos", "allKey": "CTRL + A", - "all": "Seleccionar todas las unidades", "buildersKey": "CTRL + B", - "builders": "Seleccionar todos los constructores", "createGroupKey": "CTRL + (Num)", "createGroup": "Añadir unidades al grupo (num=1,2,..)", "createAutoGroupKey": "Alt + (Num)", "createAutoGroup": "Añadir tipo de unidad al autogrupo (num=1,2,..)", "removeAutoGroupKey": "Alt + °", - "removeAutoGroup": "Eliminar tipo de unidad del autogrupo", "groupKey": "(num)", "group": "Seleccionar todas las unidades asignadas al grupo (num)", - "sameTypeKey": "CTRL + Z", - "sameType": "Seleccionar todas las unidades del mismo tipo seleccionado" + "sameTypeKey": "CTRL + Z" }, "drawing": { - "title": "Dibujado", "mapmarkKey": "` + doble clic", "mapmark": "Colocar marca de mapa", "drawKey": "` + Arrastrar clic izquierdo", @@ -448,9 +308,7 @@ "console": { "title": "Comandos de consola", "eraseKey": "/clearmapmarks", - "erase": "Borra todos tus dibujos y marcas", - "pauseKey": "/pause", - "pause": "Pausa" + "pauseKey": "/pause" } }, "chat": { @@ -532,7 +390,7 @@ "heightTitle": "Altura", "heightmap": "[%{keyset}] Muestra un color diferente para cada nivel de altura", "pathingTitle": "Transitabilidad", - "pathing":"[%{keyset}] Muestra dónde se puede mover la unidad seleccionada, Verde: bien, Rojo: problemático, Púrpura: no se puede mover", + "pathing": "[%{keyset}] Muestra dónde se puede mover la unidad seleccionada, Verde: bien, Rojo: problemático, Púrpura: no se puede mover", "resourcesTitle": "Recursos", "resources": "[%{keyset}] Resalta las manchas metálicas en verde y los respiraderos geotérmicos en amarillo.\n Los puntos metálicos ocupados se muestran en rojo." }, @@ -573,14 +431,14 @@ "airWave1": "¡Raptores voladores! ¡Ya llegan!", "airWave2": "¡%{unitCount} raptores!", "queenIsAngry1": { - "one" : "¡Ha llegado la reina!", - "other" : "¡Han llegado las reinas!" + "one": "¡Ha llegado la reina!", + "other": "¡Han llegado las reinas!" }, "queenIsAngry2": "¡Prepárate para la batalla final!", "queenResistant": "¡La reina se está volviendo resistente a los ataques de %{unit}!", - "resistanceUnits":{ - "one" : "La reina se está volviendo resistente a:", - "other" : "Las reinas se están volviendo resistentes a:" + "resistanceUnits": { + "one": "La reina se está volviendo resistente a:", + "other": "Las reinas se están volviendo resistentes a:" }, "wave1": "Oleada %{waveNumber}", "wave2": "¡%{unitCount} raptores!", @@ -593,17 +451,17 @@ "queenAngerAggression": "Agresión de jugador: +%{value}%%/s", "queenAngerEco": "Economía: +%{value}%%/s", "queenETA": { - "one" : "La reina llegará en: %{time}.", - "other" : "%{count} reinas llegarán en: %{time}" + "one": "La reina llegará en: %{time}.", + "other": "%{count} reinas llegarán en: %{time}" }, "queenHealth": { - "one" : "Salud de reina: %{health}%%", - "other" : "Salud de reinas: %{health}%%" + "one": "Salud de reina: %{health}%%", + "other": "Salud de reinas: %{health}%%" }, "queensKilled": "Reinas abatidas: %{nKilled}/%{nTotal}", "queenResistantToList": { - "one" : "La reina es resistente a:", - "other" : "Las reinas son resistentes a:" + "one": "La reina es resistente a:", + "other": "Las reinas son resistentes a:" }, "gracePeriod": "Periodo de gracia: %{time}", "burrowCount": "Madrigueras: %{count}", @@ -627,14 +485,14 @@ "firstWave1": "¡Los Carroñeros han llegado!", "firstWave2": "¡Prepárate para defenderte!", "bossIsAngry1": { - "one" : "¡Ha llegado el Jefe!", - "other" : "¡Han llegado los jefes!" + "one": "¡Ha llegado el Jefe!", + "other": "¡Han llegado los jefes!" }, "bossIsAngry2": "¡Prepárate para la batalla final!", "bossResistant": "¡El Jefe se está volviendo resistente a los ataques de %{unit}!", "resistanceUnits": { - "one" : "El Jefe se está volviendo resistente a:", - "other" : "Los jefes se están volviendo resistentes a:" + "one": "El Jefe se está volviendo resistente a:", + "other": "Los jefes se están volviendo resistentes a:" }, "wave1": "Oleada %{waveNumber}", "wave2": "%{unitCount} Carroñeros!", @@ -647,17 +505,17 @@ "bossAngerAggression": "Agresión de jugador: +%{value}%%/s", "bossAngerEco": "Economía: +%{value}%%/s", "bossETA": { - "one" : "El Jefe llegará en: %{time}.", - "other" : "%{count} jefes llegan en: %{time}" + "one": "El Jefe llegará en: %{time}.", + "other": "%{count} jefes llegan en: %{time}" }, "bossHealth": { - "one" : "Salud del Jefe: %{health}%%", - "other" : "Saludes de jefe: %{health}%%" + "one": "Salud del Jefe: %{health}%%", + "other": "Saludes de jefe: %{health}%%" }, "bossesKilled": "Jefes eliminados: %{nKilled}/%{nTotal}", "bossResistantToList": { - "one" : "El Jefe es resistente a:", - "other" : "Los jefes son resistentes a:" + "one": "El Jefe es resistente a:", + "other": "Los jefes son resistentes a:" }, "gracePeriod": "Periodo de gracia: %{time}", "burrowCount": "Madrigueras: %{count}", @@ -743,7 +601,7 @@ }, "moveAttackNotify": { "underAttack": "¡La unidad %{unit} está siendo atacada!", - "cantMove":"%{unit}: ¡No puedo llegar a mi destino!" + "cantMove": "%{unit}: ¡No puedo llegar a mi destino!" }, "unitShare": { "shared": "compartida %{units} a%{name}", @@ -940,15 +798,15 @@ "metalIncome_title": "Generación de metal", "metalIncome_tooltip": "Cantidad de metal generada por segundo", "energyConversionMetalIncome_title": "Conversión de metal", - "energyConversionMetalIncome_tooltip": "Cantidad de metal generada por conversión de energía" , + "energyConversionMetalIncome_tooltip": "Cantidad de metal generada por conversión de energía", "energyIncome_title": "Generación de energía", "energyIncome_tooltip": "Cantidad de energía generada por segundo", "buildPower_title": "Constructividad", "buildPower_tooltip": "Constructividad asignable", "metalProduced_title": "Metal producido", - "metalProduced_tooltip": "Cantidad total de metal producido" , + "metalProduced_tooltip": "Cantidad total de metal producido", "energyProduced_title": "Energía producida", - "energyProduced_tooltip": "Cantidad total de energía producida" , + "energyProduced_tooltip": "Cantidad total de energía producida", "metalExcess_title": "Exceso de metal", "metalExcess_tooltip": "Cantidad total de exceso de metal", "energyExcess_title": "Exceso de energía", diff --git a/language/ru/commands.json b/language/ru/commands.json new file mode 100644 index 00000000000..faee9ca23a4 --- /dev/null +++ b/language/ru/commands.json @@ -0,0 +1,154 @@ +{ + "commands": { + "move": "Двигаться", + "move_tooltip": "Передвижение на позицию или следовать за другими юнитами", + "stop": "Стоп", + "stop_tooltip": "Отменить текущие действия юнитов", + "attack": "Атака", + "attack_tooltip": "Атака юнита или наземной области", + "areaattack": "Атака Зоны", + "areaattack_tooltip": "Атака всего что находится в зоне круга (зажать+тянуть)", + "manualfire": "Д-Пушка", + "manualfire_tooltip": "Выстрелить из мощной Дезинтеграторной пушки Командира", + "manuallaunch": "Запуск", + "manuallaunch_tooltip": "Запуск ракеты по цели", + "patrol": "Патруль", + "patrol_tooltip": "Патрулировать вдоль одной или более точек", + "fight": "Бой", + "fight_tooltip": "Юниты будут останавливаться и атаковать противников в радиусе действия, пока перемещаются на позицию", + "resurrect": "Реанимировать", + "resurrect_tooltip": "Восстанавливает боевую единицу из обломков (зажать+тянуть для восст. всех в области)", + "guard": "Охранять", + "guard_tooltip": "Охранять другое подразделение от атакующих его вражеских подразделений", + "factoryguard": "Помощь Заводу", + "factoryguard_tooltip": "Строители из этого завода будут автоматически получать приказ охраны на него", + "wait": "Ждать", + "wait_tooltip": "Ставит на Паузу выполнение текущих команд и строительство", + "repair": "Ремонт", + "repair_tooltip": "Отремонтировать повреждённый юнит", + "reclaim": "Утилизация", + "reclaim_tooltip": "Собрать металл/энергию из обломков или окружения (деревьев/камней)", + "restore": "Восстановить", + "restore_tooltip": "Восстановить область карты до исходной высоты", + "capture": "Захват", + "capture_tooltip": "Взять под контроль юниты, принадлежащие противнику (или союзнику)", + "settarget": "Задать Цель", + "settarget_tooltip": "Задать приоритет для атаки (если цель находится в пределах досягаемости)", + "canceltarget": "Снять Цель", + "canceltarget_tooltip": "Удаляет приоритетную цель", + "areamex": "Зона \"МЭкс\"", + "areamex_tooltip": "Зажмите+Тяните, чтобы построить Металлоэкстракторы на всех доступных Металлических жилах", + "loadunits": "Погрузка", + "loadunits_tooltip": "Погрузить одного или несколько юнитов в области на транспорт", + "unloadunits": "Выгрузка", + "unloadunits_tooltip": "Выгрузить одного или несколько юнитов в область из транспорта", + "stockpile": "Запас %{stockpileStatus}", + "stockpile_tooltip": "[ накоплено ] / [ макс. запас ]", + "stopproduction": "Очистить очередь", + "stopproduction_tooltip": "Очистить очередь строительства и квоту для всех юнитов в выбранных фабриках", + "morph": "Улучшить", + "morph_tooltip": "Улучшить до следующего технического уровня (второе нажатие для отмены)", + "Spawning Disabled": "Спавн отключен", + "Spawning Enabled": "Спавн включен", + "sellunit": "Продать юнит", + "sellunit_tooltip": "Выставляет выделенных юнитов на продажу для покупки союзниками", + "For Sale": "Продаётся", + "Not For Sale": "Не продаётся", + "Fire at will": "По готовности", + "Hold fire": "Не стрелять", + "Return fire": "Ответный", + "firestate_tooltip": "Определяет, когда юнит должен открыть огонь по врагам (без прямого приказа атаки)", + "Hold pos": "Удержание", + "Maneuver": "Маневры", + "Roam": "Бродить", + "movestate_tooltip": "Определяет, как далеко от своей позиции отряд может отойти для атаки", + "Repeat on": "Повтор вкл", + "Repeat off": "Повтор выкл", + "repeat_tooltip": "Повторить выполнение команд для юнита", + "Low Prio": "Низк. Приор.", + "High Prio": "Выс. Приор.", + "priority_tooltip": "Определяет использование ресурсов этим конструктором при дефиците ресурсов", + "Decloaked": "Видимый", + "Cloaked": "Маскировка", + "wantcloak_tooltip": "Состояние маскировки", + " On ": "Вкл", + " Off ": "Выкл", + "onoff_tooltip": "Состояние активности: включен/выключен", + " Fly ": "Полёт", + "Land": "Посадка", + "idlemode_tooltip": "Определяет поведение самолёта при отсутствии приказов", + "apLandAt_tooltip": "Определяет поведение самолёта после покидания Завода", + "csSpawning_tooltip": "Определяет состояние спавна авианосца", + "Low traj": "Выс. траект.", + "High traj": "Выс. траект.", + "trajectory_toggle_tooltip": "Переключение угла стрельбы между низкой, высокой и автоматической траекторией", + "trajectory_low": "Низк. траектория", + "trajectory_high": "Выс. траектория", + "trajectory_auto": "Авто. Траектория ", + "hound_weapon_plasma": "Плазма-пушка", + "hound_weapon_gauss": "Гаусса-пушка", + "hound_weapon_toggle_tooltip": "Переключает орудие между медленными плазменными снарядами с широким радиусом и быстрой гаусс-пушкой", + "blueprint_place": "Чертеж", + "blueprint_place_tooltip": "Разместить сохраненный чертеж", + "blueprint_create": "Сохранить Чертеж", + "blueprint_create_tooltip": "Сохранить выбранные юниты в новый чертеж, в порядке их выделения", + "factoryqueuemode_normal": "Режим Очереди", + "factoryqueuemode_quota": "Режим Квоты", + "factoryqueuemode_tooltip": "Очередь: Построить каждого выбранного юнита единожды\nКвота: Поддерживать заданное количество юнитов на поле", + "customOnOff": { + "lowTrajectory": "Низк. траектория", + "highTrajectory": "Выс. траектория", + "trajectory_tooltip": "Переключить режим ведения огня: Навесная стрельба или Прямой наводкой", + "gauss_tooltip": "Переключает между Гаусс-пушкой и тяжёлой плазмо-пушкой" + }, + "quicksharetotarget": "Передать Юнит", + "quicksharetotarget_tooltip": "Передать юнита выбранному игроку." + }, + "categories": { + "selection": "Выбор юнитов", + "orders": "Выбор приказов", + "queues": "Очередь приказов", + "camera": "Движение камеры", + "drawing": "Рисование", + "sound": "Звук" + }, + "actions": { + "massSelect": { + "all": "Выбрать все юниты", + "builders": "Выбрать всех строителей", + "sameType": "Выбрать все юниты того же типа, что и выбранный", + "removeAutoGroup": "Удалить тип юнита из автогрупп" + }, + "orders": { + "cloak": "Маскировка", + "selfDestruct": "Самоуничтожение" + }, + "queues": { + "prepend": "Добавить приказ в начало очереди" + }, + "buildOrders": { + "rotate": "Вращать строение" + }, + "issueBuildOrders": { + "spacingUp": "Увеличить интервал между строениями", + "spacingDown": "Уменьшить интервал между строениями" + }, + "camera": { + "flip": "Отразить камеру" + }, + "cameraModes": { + "mapmarks": "Листать отметки на карте", + "heightmap": "Показать карту высот", + "traversability": "Показать проходимость (для выбранного юнита)", + "resourceSpots": "Показать карту металла", + "interface": "Скрыть интерфейс" + }, + "console": { + "erase": "Стереть все рисунки и отметки", + "pause": "Пауза" + }, + "sound": { + "mute": "Вкл/Откл Звук" + } + } +} diff --git a/language/ru/interface.json b/language/ru/interface.json index 0520653d4f2..888a1e85a99 100644 --- a/language/ru/interface.json +++ b/language/ru/interface.json @@ -67,7 +67,6 @@ "tooManyConverters2Tooltip": "Прекратите строить Преобразователи Энергии и увеличьте её производство." } }, - "playersList": { "spectators": "Наблюдатели %{amount}", "enemies": "Противники %{amount}", @@ -167,111 +166,7 @@ "areamex_tooltip": "Совет: выберите этот экстрактор дважды, чтобы использовать команду Зона МЭкс" }, "orderMenu": { - "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}", - "move": "Двигаться", - "move_tooltip": "Передвижение на позицию или следовать за другими юнитами", - "stop": "Стоп", - "stop_tooltip": "Отменить текущие действия юнитов", - "attack": "Атака", - "attack_tooltip": "Атака юнита или наземной области", - "areaattack": "Атака Зоны", - "areaattack_tooltip": "Атака всего что находится в зоне круга (зажать+тянуть)", - "manualfire": "Д-Пушка", - "manualfire_tooltip": "Выстрелить из мощной Дезинтеграторной пушки Командира", - "manuallaunch": "Запуск", - "manuallaunch_tooltip": "Запуск ракеты по цели", - "patrol": "Патруль", - "patrol_tooltip": "Патрулировать вдоль одной или более точек", - "fight": "Бой", - "fight_tooltip": "Юниты будут останавливаться и атаковать противников в радиусе действия, пока перемещаются на позицию", - "resurrect": "Реанимировать", - "resurrect_tooltip": "Восстанавливает боевую единицу из обломков (зажать+тянуть для восст. всех в области)", - "guard": "Охранять", - "guard_tooltip": "Охранять другое подразделение от атакующих его вражеских подразделений", - "factoryguard": "Помощь Заводу", - "factoryguard_tooltip": "Строители из этого завода будут автоматически получать приказ охраны на него", - "wait": "Ждать", - "wait_tooltip": "Ставит на Паузу выполнение текущих команд и строительство", - "repair": "Ремонт", - "repair_tooltip": "Отремонтировать повреждённый юнит", - "reclaim": "Утилизация", - "reclaim_tooltip": "Собрать металл/энергию из обломков или окружения (деревьев/камней)", - "restore": "Восстановить", - "restore_tooltip": "Восстановить область карты до исходной высоты", - "capture": "Захват", - "capture_tooltip": "Взять под контроль юниты, принадлежащие противнику (или союзнику)", - "settarget": "Задать Цель", - "settarget_tooltip": "Задать приоритет для атаки (если цель находится в пределах досягаемости)", - "canceltarget": "Снять Цель", - "canceltarget_tooltip": "Удаляет приоритетную цель", - "areamex": "Зона \"МЭкс\"", - "areamex_tooltip": "Зажмите+Тяните, чтобы построить Металлоэкстракторы на всех доступных Металлических жилах", - "loadunits": "Погрузка", - "loadunits_tooltip": "Погрузить одного или несколько юнитов в области на транспорт", - "unloadunits": "Выгрузка", - "unloadunits_tooltip": "Выгрузить одного или несколько юнитов в область из транспорта", - "stockpile": "Запас %{stockpileStatus}", - "stockpile_tooltip": "[ накоплено ] / [ макс. запас ]", - "stopproduction": "Очистить очередь", - "stopproduction_tooltip": "Очистить очередь строительства и квоту для всех юнитов в выбранных фабриках", - "morph": "Улучшить", - "morph_tooltip": "Улучшить до следующего технического уровня (второе нажатие для отмены)", - "Spawning Disabled": "Спавн отключен", - "Spawning Enabled": "Спавн включен", - "sellunit": "Продать юнит", - "sellunit_tooltip": "Выставляет выделенных юнитов на продажу для покупки союзниками", - "For Sale": "Продаётся", - "Not For Sale": "Не продаётся", - - "Fire at will": "По готовности", - "Hold fire": "Не стрелять", - "Return fire": "Ответный", - "firestate_tooltip": "Определяет, когда юнит должен открыть огонь по врагам (без прямого приказа атаки)", - "Hold pos": "Удержание", - "Maneuver": "Маневры", - "Roam": "Бродить", - "movestate_tooltip": "Определяет, как далеко от своей позиции отряд может отойти для атаки", - "Repeat on": "Повтор вкл", - "Repeat off": "Повтор выкл", - "repeat_tooltip": "Повторить выполнение команд для юнита", - "Low Prio": "Низк. Приор.", - "High Prio": "Выс. Приор.", - "priority_tooltip": "Определяет использование ресурсов этим конструктором при дефиците ресурсов", - "Decloaked": "Видимый", - "Cloaked": "Маскировка", - "wantcloak_tooltip": "Состояние маскировки", - " On ": "Вкл", - " Off ": "Выкл", - "onoff_tooltip": "Состояние активности: включен/выключен", - " Fly ": "Полёт", - "Land": "Посадка", - "idlemode_tooltip": "Определяет поведение самолёта при отсутствии приказов", - "apLandAt_tooltip": "Определяет поведение самолёта после покидания Завода", - "csSpawning_tooltip": "Определяет состояние спавна авианосца", - "Low traj": "Выс. траект.", - "High traj": "Выс. траект.", - "trajectory_toggle_tooltip": "Переключение угла стрельбы между низкой, высокой и автоматической траекторией", - "trajectory_low": "Низк. траектория", - "trajectory_high": "Выс. траектория", - "trajectory_auto": "Авто. Траектория ", - "hound_weapon_plasma": "Плазма-пушка", - "hound_weapon_gauss": "Гаусса-пушка", - "hound_weapon_toggle_tooltip": "Переключает орудие между медленными плазменными снарядами с широким радиусом и быстрой гаусс-пушкой", - "blueprint_place": "Чертеж", - "blueprint_place_tooltip": "Разместить сохраненный чертеж", - "blueprint_create": "Сохранить Чертеж", - "blueprint_create_tooltip": "Сохранить выбранные юниты в новый чертеж, в порядке их выделения", - "factoryqueuemode_normal": "Режим Очереди", - "factoryqueuemode_quota": "Режим Квоты", - "factoryqueuemode_tooltip": "Очередь: Построить каждого выбранного юнита единожды\nКвота: Поддерживать заданное количество юнитов на поле", - "customOnOff": { - "lowTrajectory": "Низк. траектория", - "highTrajectory": "Выс. траектория", - "trajectory_tooltip": "Переключить режим ведения огня: Навесная стрельба или Прямой наводкой", - "gauss_tooltip": "Переключает между Гаусс-пушкой и тяжёлой плазмо-пушкой" - }, - "quicksharetotarget": "Передать Юнит", - "quicksharetotarget_tooltip": "Передать юнита выбранному игроку." + "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}" }, "idleBuilders": { "name": "Незанятые строители", @@ -294,7 +189,7 @@ "keybinds": { "title": "Назначения клавиш", "disclaimer": "Эти \"быстрые клавиши\" установлены по умолчанию. Если вы удалите / замените виджеты \"быстрых клавиш\" или используете произвольный uikeys.txt, они могут перестать работать!", - "howtochangekeybinds":"Чтобы изменить их: в разделе Настройки/Управление установите Назначения клавиш на Пользовательские, чтобы создать файл BAR/data/uikeys.txt.\nОтредактируйте этот файл и введите /keyreload в чате, чтобы перезагрузить их.", + "howtochangekeybinds": "Чтобы изменить их: в разделе Настройки/Управление установите Назначения клавиш на Пользовательские, чтобы создать файл BAR/data/uikeys.txt.\nОтредактируйте этот файл и введите /keyreload в чате, чтобы перезагрузить их.", "chat": { "title": "Чат", "send": "Отправить сообщение в общий чат", @@ -311,7 +206,6 @@ "share": "Поделиться юнитами / ресурсами" }, "camera": { - "title": "Движение камеры", "zoomKey": "колесо прокрутки", "zoom": "Масштаб камеры", "panKey": "Клавиши-стрелки / курсор на границе экрана", @@ -319,8 +213,7 @@ "tiltKey": "ctrl + колесо прокрутки", "tilt": "Смена угла камеры", "dragKey": "колёсико мыши (+ тянуть)", - "drag": "Перетащить камеру", - "flip": "Отразить камеру" + "drag": "Перетащить камеру" }, "cameraModes": { "title": "Режимы камеры", @@ -329,21 +222,13 @@ "fullscreenKey": "alt + backspace", "fullscreen": "Полноэкранный режим", "overview": "Переключение обзорной камеры", - "los": "Переключить режим \"Область обзора\"", - "heightmap": "Показать карту высот", - "traversability": "Показать проходимость (для выбранного юнита)", - "mapmarks": "Листать отметки на карте", - "resourceSpots": "Показать карту металла", - "interface": "Скрыть интерфейс" + "los": "Переключить режим \"Область обзора\"" }, "sound": { - "title": "Звук", "volumeKey": "-/+", - "volume": "Изменить громкость", - "mute": "Вкл/Откл Звук" + "volume": "Изменить громкость" }, "selection": { - "title": "Выбор юнитов", "unitsKey": "ЛКМ (+ тянуть)", "units": "Выделить или снять выделение" }, @@ -355,23 +240,8 @@ "formationOrder": "Задать Построение юнитам" }, "orders": { - "title": "Выбор приказов", "defaultKey": "(нет)", - "default": "Приказ по умолчанию (Передвижение)", - "move": "Движение", - "attack": "Атака", - "stop": "Стоп (очистить очередь приказов)", - "repair": "Ремонт", - "reclaim": "Утилизация", - "resurrect": "реанимировать", - "patrol": "Патруль", - "fight": "Бой", - "setTarget": "Установить приоритетную цель", - "cancelTarget": "Отменить приоритет цели", - "wait": "Ждать (приостановить текущий приказ)", - "cloak": "Маскировка", - "dGun": "Ручной огонь (Д-пушка)", - "selfDestruct": "Самоуничтожение" + "default": "Приказ по умолчанию (Передвижение)" }, "issueOrders": { "title": "Отдача выбранного приказа", @@ -383,11 +253,9 @@ "formation": "Задать Построение юнитам" }, "queues": { - "title": "Очередь приказов", "append": "Добавить приказ в конец очереди", "appendKey": "shift + (приказ)", - "prependKey": "space + (приказ)", - "prepend": "Добавить приказ в начало очереди" + "prependKey": "space + (приказ)" }, "buildOrders": { "title": "Горячие клавиши для Строительства", @@ -401,8 +269,7 @@ "intel": "Листать радар/турели/т.д.", "factoriesKey": "v", "factories": "Листать Заводы", - "rotateKey": "] и [", - "rotate": "Вращать строение" + "rotateKey": "] и [" }, "issueBuildOrders": { "title": "Отдача приказов Строительства", @@ -415,29 +282,22 @@ "gridKey": "shift + alt + (приказ постройки)", "grid": "Строить матрицей", "spacingUpKey": "alt+z", - "spacingUp": "Увеличить интервал между строениями", - "spacingDownKey": "alt+x", - "spacingDown": "Уменьшить интервал между строениями" + "spacingDownKey": "alt+x" }, "massSelect": { "title": "Выбор групп", "allKey": "ctrl + a", - "all": "Выбрать все юниты", "buildersKey": "ctrl + b", - "builders": "Выбрать всех строителей", "createGroupKey": "ctrl + (0-9)", "createGroup": "Добавить юниты в группу (№=1,2,..)", "createAutoGroupKey": "alt + (0-9)", "createAutoGroup": "Добавить тип юнита в автогруппу (№=1,2,..)", "removeAutoGroupKey": "alt + ` ", - "removeAutoGroup": "Удалить тип юнита из автогрупп", "groupKey": "(0-9)", "group": "Выбрать все юниты, назначенные в группу (№=1,2,..)", - "sameTypeKey": "ctrl + z", - "sameType": "Выбрать все юниты того же типа, что и выбранный" + "sameTypeKey": "ctrl + z" }, "drawing": { - "title": "Рисование", "mapmarkKey": "` (тильда) + двойное нажатие ЛКМ", "mapmark": "Поставить метку на карте", "drawKey": "` (тильда) + потянуть с ЛКМ", @@ -448,9 +308,7 @@ "console": { "title": "Консольные команды", "eraseKey": "/clearmapmarks", - "erase": "Стереть все рисунки и отметки", - "pauseKey": "/pause", - "pause": "Пауза" + "pauseKey": "/pause" } }, "chat": { @@ -532,7 +390,7 @@ "heightTitle": "Высота", "heightmap": "[%{keyset}] Отображает разные цвета для каждого уровня высоты", "pathingTitle": "Проходимость", - "pathing":"[%{keyset}] Показывает где юниты могут передвигаться, Зеленый: ок, Красный: проблематично, Фиолетовый: не может двигаться", + "pathing": "[%{keyset}] Показывает где юниты могут передвигаться, Зеленый: ок, Красный: проблематично, Фиолетовый: не может двигаться", "resourcesTitle": "Ресурсы", "resources": "[%{keyset}] Выделяет месторождения металла зеленым цветом и геотермальные источники желтым цветом.\nЗанятые месторождения металла показаны красным цветом." }, @@ -573,14 +431,14 @@ "airWave1": "Летающие Рапторы наступают!", "airWave2": "%{unitCount} Рапторов!", "queenIsAngry1": { - "one" : "Королева прибыла!", - "other" : "Королевы уже здесь!" + "one": "Королева прибыла!", + "other": "Королевы уже здесь!" }, "queenIsAngry2": "Готовьтесь к последней битве!", "queenResistant": "Королева стала устойчива к атакам%{unit}!", - "resistanceUnits":{ - "one" : "Королева становится устойчивой к:", - "other" : "Королевы становятся устойчивы к:" + "resistanceUnits": { + "one": "Королева становится устойчивой к:", + "other": "Королевы становятся устойчивы к:" }, "wave1": "Волна %{waveNumber}", "wave2": "%{unitCount} Рапторов!", @@ -593,17 +451,17 @@ "queenAngerAggression": "Агрессия игроков: +%{value}%%/с", "queenAngerEco": "Экономика: +%{value}%%/с", "queenETA": { - "one" : "Королева прибывает через: %{time}.", - "other" : "%{count} Королев прибывает через: %{time}" + "one": "Королева прибывает через: %{time}.", + "other": "%{count} Королев прибывает через: %{time}" }, "queenHealth": { - "one" : "Здоровье Королевы: %{health}%%", - "other" : "Здоровье Королев: %{health}%%" + "one": "Здоровье Королевы: %{health}%%", + "other": "Здоровье Королев: %{health}%%" }, "queensKilled": "Королев Убито: %{nKilled}/%{nTotal}", "queenResistantToList": { - "one" : "Королева устойчива к:", - "other" : "Королевы устойчивы к:" + "one": "Королева устойчива к:", + "other": "Королевы устойчивы к:" }, "gracePeriod": "Период затишья: %{time}", "burrowCount": "Норы: %{count}", @@ -627,14 +485,14 @@ "firstWave1": "Мусорщики прибыли!", "firstWave2": "Готовьтесь защищаться!", "bossIsAngry1": { - "one" : "Босс уже здесь!", - "other" : "Боссы уже здесь!" + "one": "Босс уже здесь!", + "other": "Боссы уже здесь!" }, "bossIsAngry2": "Готовьтесь к финальной битве!", "bossResistant": "Босс становится устойчивым к атакам %{unit}!", "resistanceUnits": { - "one" : "Босс становится устойчивым к:", - "other" : "Боссы становятся устойчивы к:" + "one": "Босс становится устойчивым к:", + "other": "Боссы становятся устойчивы к:" }, "wave1": "Волна %{waveNumber}", "wave2": "%{unitCount} Мусорщики!", @@ -647,17 +505,17 @@ "bossAngerAggression": "Агрессия игроков: +%{value}%%/с", "bossAngerEco": "Экономика: +%{value}%%/с", "bossETA": { - "one" : "Босс прибывает через: %{time}.", - "other" : "%{count} Боссов прибывает через: %{time}" + "one": "Босс прибывает через: %{time}.", + "other": "%{count} Боссов прибывает через: %{time}" }, "bossHealth": { - "one" : "Здоровье Босса: %{health}%%", - "other" : "Здоровье Боссов: %{health}%%" + "one": "Здоровье Босса: %{health}%%", + "other": "Здоровье Боссов: %{health}%%" }, "bossesKilled": "Боссов Убито: %{nKilled}/%{nTotal}", "bossResistantToList": { - "one" : "Босс устойчив к:", - "other" : "Боссы устойчивы к:" + "one": "Босс устойчив к:", + "other": "Боссы устойчивы к:" }, "gracePeriod": "Период затишья: %{time}", "burrowCount": "Нор: %{count}", @@ -743,7 +601,7 @@ }, "moveAttackNotify": { "underAttack": "%{unit} был атакован!", - "cantMove":"%{unit}: Невозможно добраться до цели!" + "cantMove": "%{unit}: Невозможно добраться до цели!" }, "unitShare": { "shared": "Поделился(-ась) %{units} с %{name} ", @@ -940,15 +798,15 @@ "metalIncome_title": "Доход Металла", "metalIncome_tooltip": "Доход металла в секунду", "energyConversionMetalIncome_title": "Преобразование Металла", - "energyConversionMetalIncome_tooltip": "Доход металла от преобразования энергии" , + "energyConversionMetalIncome_tooltip": "Доход металла от преобразования энергии", "energyIncome_title": "Доход Энергии", "energyIncome_tooltip": "Доход энергии в секунду", "buildPower_title": "Произв. Мощность", "buildPower_tooltip": "Имеющаяся производственная мощность", "metalProduced_title": "Металла произведено", - "metalProduced_tooltip": "Общий доход металла" , + "metalProduced_tooltip": "Общий доход металла", "energyProduced_title": "Энергии произведено", - "energyProduced_tooltip": "Общий доход энергии" , + "energyProduced_tooltip": "Общий доход энергии", "metalExcess_title": "Излишки Металла", "metalExcess_tooltip": "Общие излишки металла", "energyExcess_title": "Излишки Энергии", diff --git a/language/test_unicode.lua b/language/test_unicode.lua index da45149a21c..894cec03cab 100644 --- a/language/test_unicode.lua +++ b/language/test_unicode.lua @@ -400,12 +400,12 @@ return { drawKey = "q + drag left mouse", draw = "Draw on map", eraseKey = "q + drag right mouse", - erase = "Erase drawings and markers", + erase = "Erase drawings and marks", }, console = { title = "Console commands", eraseKey = "/clearmapmarks", - erase = "Erase all drawings and markes", + erase = "Erase all drawings and marks", pauseKey = "/pause", pause = "Pause", }, @@ -1332,7 +1332,7 @@ return { armah = "Anti-Air Hovercraft", armalab = "Produces Level 2 Bots", armamb = "Cloakable Pop-up Plasma Artillery", - armamb_scav = "Powerfull Stealthy Defense", + armamb_scav = "Powerful Stealthy Defense", armamd = "Anti-Nuke System", armamex = "Stealthy Cloakable Metal Extractor", armamph = "Amphibious Bot", @@ -1784,7 +1784,7 @@ return { cortitan = "Torpedo Bomber", cortl = "Offshore Torpedo Launcher", cortoast = "Pop-up Plasma Artillery", - cortoast_scav = "Powerfull Stealthy Defense", + cortoast_scav = "Powerful Stealthy Defense", cortrem = "Heavy Artillery Vehicle", cortron = "Tactical Missile Launcher", cortship = "Armored Transport Ship", diff --git a/language/zh/commands.json b/language/zh/commands.json new file mode 100644 index 00000000000..b43a49d0713 --- /dev/null +++ b/language/zh/commands.json @@ -0,0 +1,154 @@ +{ + "commands": { + "move": "移动", + "move_tooltip": "将此单位移向指定位置或跟随指定单位", + "stop": "停止", + "stop_tooltip": "取消单位当前的行动", + "attack": "攻击", + "attack_tooltip": "攻击指定单位或地面", + "areaattack": "区域攻击", + "areaattack_tooltip": "区域攻击圆圈内的所有东西(点击-拖动)", + "manualfire": "裂解炮", + "manualfire_tooltip": "发射强大的指挥官裂解炮", + "manuallaunch": "发射", + "manuallaunch_tooltip": "向指定目标发射一枚导弹", + "patrol": "巡逻", + "patrol_tooltip": "沿着一个或多个指定目标点进行巡逻", + "fight": "战斗指令", + "fight_tooltip": "命令单位移动到指定位置并采取行动", + "resurrect": "复活", + "resurrect_tooltip": "复活残骸,使其重新投入战斗(点击拖动区域)", + "guard": "保护", + "guard_tooltip": "保护指定单位不受敌方单位的攻击", + "factoryguard": "协助工厂", + "factoryguard_tooltip": "工厂生产的建造者会自动协助工厂生产。", + "wait": "等待", + "wait_tooltip": "暂停指定单位/工厂的命令/建造队列", + "repair": "维修", + "repair_tooltip": "修复受伤的指定单位", + "reclaim": "回收", + "reclaim_tooltip": "从指定残骸或自然资源(树木/石头)中吸取金属/电力", + "restore": "重置", + "restore_tooltip": "将地图的指定区域重置到原来的高度", + "capture": "俘获", + "capture_tooltip": "俘获属于敌人(或盟友)的指定单位", + "settarget": "设定目标", + "settarget_tooltip": "设置优先目标(当目标在范围内时,优先选择目标)", + "canceltarget": "清理目标", + "canceltarget_tooltip": "移除优先级目标", + "areamex": "采矿区", + "areamex_tooltip": "点击拖动指定区域,为所有可用的金属点自动建造金属提炼器。", + "loadunits": "装载单位", + "loadunits_tooltip": "在指定的区域内装载单位或多个单位", + "unloadunits": "卸载单位", + "unloadunits_tooltip": "在指定的区域内卸下单位或多个单位", + "stockpile": "储存 %{stockpileStatus}", + "stockpile_tooltip": "[ stockpiled number ] / [ target stockpile number ]", + "stopproduction": "清除队列", + "stopproduction_tooltip": "清除指定工厂的所有单位的建造队列", + "morph": "升级", + "morph_tooltip": "升级到下一个科技级别(点两下可取消)", + "Spawning Disabled": "不自动生产", + "Spawning Enabled": "自动生产", + "sellunit": "出售单位", + "sellunit_tooltip": "切换让选择的单位可以出售,盟友将能够购买这些单位", + "For Sale": "待售", + "Not For Sale": "非卖品", + "Fire at will": "随意开火", + "Hold fire": "停火", + "Return fire": "回击", + "firestate_tooltip": "设置此单位在什么条件下应该开始向敌人开火(没有明确的攻击命令)", + "Hold pos": "保持位置", + "Maneuver": "灵活机动", + "Roam": "漫游", + "movestate_tooltip": "设置此单位在攻击敌人时应该移动多远的距离", + "Repeat on": "开启重复执行", + "Repeat off": "关闭重复执行", + "repeat_tooltip": "重复单位指令队列", + "Low Prio": "低优先级", + "High Prio": "高优先级", + "priority_tooltip": "当没有足够的资源给所有的建造者时,分配资源给这个建造者使用.", + "Decloaked": "隐形关闭", + "Cloaked": "隐形", + "wantcloak_tooltip": "隐形状态", + " On ": "开启", + " Off ": "关闭", + "onoff_tooltip": "当前状态:打开/关闭此单位", + " Fly ": "飞行", + "Land": "着陆", + "idlemode_tooltip": "设置飞机空闲时的动作", + "apLandAt_tooltip": "设定飞机离开飞行器工厂时的动作", + "csSpawning_tooltip": "设置航母单位生产模式", + "Low traj": "低抛", + "High traj": "高抛", + "trajectory_toggle_tooltip": "在低弹道、高弹道和自动弹道之间切换火炮射击角度", + "trajectory_low": "低弹道", + "trajectory_high": "高弹道", + "trajectory_auto": "自动调整弹道", + "hound_weapon_plasma": "重型等离子火炮", + "hound_weapon_gauss": "电磁炮弹", + "hound_weapon_toggle_tooltip": "把武器设置为 慢弹速但爆炸范围大的等离子炮 或 弹道快的电磁炮。", + "blueprint_place": "放置蓝图", + "blueprint_place_tooltip": "放置已保存的蓝图", + "blueprint_create": "保存蓝图", + "blueprint_create_tooltip": "将选中的单位按选择顺序保存为新的蓝图", + "factoryqueuemode_normal": "队列模式", + "factoryqueuemode_quota": "配额模式", + "factoryqueuemode_tooltip": "队列模式:只建造队列中的单位一次\n配额模式: 保证战场上至少有配额数量的单位", + "customOnOff": { + "lowTrajectory": "低弹道", + "highTrajectory": "高弹道", + "trajectory_tooltip": "在低弹道和高弹道之间切换炮台射击角度", + "gauss_tooltip": "在电磁炮 和 等离子炮 之间切换" + }, + "quicksharetotarget": "分享单位", + "quicksharetotarget_tooltip": "分享单位给目标玩家" + }, + "categories": { + "selection": "选择单位", + "orders": "选定后指令", + "queues": "队列指令", + "camera": "镜头", + "drawing": "地图标注", + "sound": "声音" + }, + "actions": { + "massSelect": { + "all": "选择所有单位", + "builders": "选择所有建造单位", + "sameType": "选择所有同种单位", + "removeAutoGroup": "从自动编队中移除兵种" + }, + "orders": { + "cloak": "隐形开启", + "selfDestruct": "自毁" + }, + "queues": { + "prepend": "把指令加到队列开头" + }, + "buildOrders": { + "rotate": "改变建筑朝向" + }, + "issueBuildOrders": { + "spacingUp": "增加建筑间隔", + "spacingDown": "减少建筑间隔" + }, + "camera": { + "flip": "反转镜头" + }, + "cameraModes": { + "mapmarks": "按地图标记循环移动", + "heightmap": "切换等高线地图", + "traversability": "显示可移动区域 (指定单位)", + "resourceSpots": "显示金属矿点地图", + "interface": "隐藏图形用户界面" + }, + "console": { + "erase": "清除所有涂鸦和标记", + "pause": "暂停" + }, + "sound": { + "mute": "静音" + } + } +} diff --git a/language/zh/interface.json b/language/zh/interface.json index ae7563246ee..10c0e0a2f51 100644 --- a/language/zh/interface.json +++ b/language/zh/interface.json @@ -166,111 +166,7 @@ "areamex_tooltip": "提示:双击金属采集器可以使用 划范围建造采集器 的命令" }, "orderMenu": { - "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}", - "move": "移动", - "move_tooltip": "将此单位移向指定位置或跟随指定单位", - "stop": "停止", - "stop_tooltip": "取消单位当前的行动", - "attack": "攻击", - "attack_tooltip": "攻击指定单位或地面", - "areaattack": "区域攻击", - "areaattack_tooltip": "区域攻击圆圈内的所有东西(点击-拖动)", - "manualfire": "裂解炮", - "manualfire_tooltip": "发射强大的指挥官裂解炮", - "manuallaunch": "发射", - "manuallaunch_tooltip": "向指定目标发射一枚导弹", - "patrol": "巡逻", - "patrol_tooltip": "沿着一个或多个指定目标点进行巡逻", - "fight": "战斗指令", - "fight_tooltip": "命令单位移动到指定位置并采取行动", - "resurrect": "复活", - "resurrect_tooltip": "复活残骸,使其重新投入战斗(点击拖动区域)", - "guard": "保护", - "guard_tooltip": "保护指定单位不受敌方单位的攻击", - "factoryguard": "协助工厂", - "factoryguard_tooltip": "工厂生产的建造者会自动协助工厂生产。", - "wait": "等待", - "wait_tooltip": "暂停指定单位/工厂的命令/建造队列", - "repair": "维修", - "repair_tooltip": "修复受伤的指定单位", - "reclaim": "回收", - "reclaim_tooltip": "从指定残骸或自然资源(树木/石头)中吸取金属/电力", - "restore": "重置", - "restore_tooltip": "将地图的指定区域重置到原来的高度", - "capture": "俘获", - "capture_tooltip": "俘获属于敌人(或盟友)的指定单位", - "settarget": "设定目标", - "settarget_tooltip": "设置优先目标(当目标在范围内时,优先选择目标)", - "canceltarget": "清理目标", - "canceltarget_tooltip": "移除优先级目标", - "areamex": "采矿区", - "areamex_tooltip": "点击拖动指定区域,为所有可用的金属点自动建造金属提炼器。", - "loadunits": "装载单位", - "loadunits_tooltip": "在指定的区域内装载单位或多个单位", - "unloadunits": "卸载单位", - "unloadunits_tooltip": "在指定的区域内卸下单位或多个单位", - "stockpile": "储存 %{stockpileStatus}", - "stockpile_tooltip": "[ stockpiled number ] / [ target stockpile number ]", - "stopproduction": "清除队列", - "stopproduction_tooltip": "清除指定工厂的所有单位的建造队列", - "morph": "升级", - "morph_tooltip": "升级到下一个科技级别(点两下可取消)", - "Spawning Disabled": "不自动生产", - "Spawning Enabled": "自动生产", - "sellunit": "出售单位", - "sellunit_tooltip": "切换让选择的单位可以出售,盟友将能够购买这些单位", - "For Sale": "待售", - "Not For Sale": "非卖品", - - "Fire at will": "随意开火", - "Hold fire": "停火", - "Return fire": "回击", - "firestate_tooltip": "设置此单位在什么条件下应该开始向敌人开火(没有明确的攻击命令)", - "Hold pos": "保持位置", - "Maneuver": "灵活机动", - "Roam": "漫游", - "movestate_tooltip": "设置此单位在攻击敌人时应该移动多远的距离", - "Repeat on": "开启重复执行", - "Repeat off": "关闭重复执行", - "repeat_tooltip": "重复单位指令队列", - "Low Prio": "低优先级", - "High Prio": "高优先级", - "priority_tooltip": "当没有足够的资源给所有的建造者时,分配资源给这个建造者使用.", - "Decloaked": "隐形关闭", - "Cloaked": "隐形", - "wantcloak_tooltip": "隐形状态", - " On ": "开启", - " Off ": "关闭", - "onoff_tooltip": "当前状态:打开/关闭此单位", - " Fly ": "飞行", - "Land": "着陆", - "idlemode_tooltip": "设置飞机空闲时的动作", - "apLandAt_tooltip": "设定飞机离开飞行器工厂时的动作", - "csSpawning_tooltip": "设置航母单位生产模式", - "Low traj": "低抛", - "High traj": "高抛", - "trajectory_toggle_tooltip": "在低弹道、高弹道和自动弹道之间切换火炮射击角度", - "trajectory_low": "低弹道", - "trajectory_high": "高弹道", - "trajectory_auto": "自动调整弹道", - "hound_weapon_plasma": "重型等离子火炮", - "hound_weapon_gauss": "电磁炮弹", - "hound_weapon_toggle_tooltip": "把武器设置为 慢弹速但爆炸范围大的等离子炮 或 弹道快的电磁炮。", - "blueprint_place": "放置蓝图", - "blueprint_place_tooltip": "放置已保存的蓝图", - "blueprint_create": "保存蓝图", - "blueprint_create_tooltip": "将选中的单位按选择顺序保存为新的蓝图", - "factoryqueuemode_normal": "队列模式", - "factoryqueuemode_quota": "配额模式", - "factoryqueuemode_tooltip": "队列模式:只建造队列中的单位一次\n配额模式: 保证战场上至少有配额数量的单位", - "customOnOff": { - "lowTrajectory": "低弹道", - "highTrajectory": "高弹道", - "trajectory_tooltip": "在低弹道和高弹道之间切换炮台射击角度", - "gauss_tooltip": "在电磁炮 和 等离子炮 之间切换" - }, - "quicksharetotarget": "分享单位", - "quicksharetotarget_tooltip": "分享单位给目标玩家" + "hotkeyTooltip": "%{highlightColor}%{hotkey}%{textColor} - %{tooltip}" }, "idleBuilders": { "name": "闲置建造单位", @@ -293,7 +189,7 @@ "keybinds": { "title": "快捷键", "disclaimer": "如果你改动了快捷键插件,或使用自己的快捷键插件,可能导致上述快捷键无效!", - "howtochangekeybinds":"如果要改变它们:在 设置/控制 栏目,将键位绑定设置为 自定义, 来创建 BAR/data/uikeys.txt file。\n编辑这个文件,然后在聊天框中输入 /keyreload 来加载自定义键位。", + "howtochangekeybinds": "如果要改变它们:在 设置/控制 栏目,将键位绑定设置为 自定义, 来创建 BAR/data/uikeys.txt file。\n编辑这个文件,然后在聊天框中输入 /keyreload 来加载自定义键位。", "chat": { "title": "聊天", "send": "发送消息", @@ -310,7 +206,6 @@ "share": "共享 单位 / 资源" }, "camera": { - "title": "镜头", "zoomKey": "滚轮", "zoom": "拉近/拉远", "panKey": "方向键 / 鼠标指向屏幕边缘", @@ -318,8 +213,7 @@ "tiltKey": "ctrl + 滚轮", "tilt": "镜头角度", "dragKey": "中键(拖动)", - "drag": "拖动镜头", - "flip": "反转镜头" + "drag": "拖动镜头" }, "cameraModes": { "title": "镜头模式", @@ -328,21 +222,13 @@ "fullscreenKey": "alt + 退格", "fullscreen": "切换全屏", "overview": "切换成俯视缩略图", - "los": "切换视野范围显示", - "heightmap": "切换等高线地图", - "traversability": "显示可移动区域 (指定单位)", - "mapmarks": "按地图标记循环移动", - "resourceSpots": "显示金属矿点地图", - "interface": "隐藏图形用户界面" + "los": "切换视野范围显示" }, "sound": { - "title": "声音", "volumeKey": "-/+", - "volume": "修改音量", - "mute": "静音" + "volume": "修改音量" }, "selection": { - "title": "选择单位", "unitsKey": "左键 (+ 选取)", "units": "选择或取消选择单位" }, @@ -354,23 +240,8 @@ "formationOrder": "给单位(组)发出编队指令" }, "orders": { - "title": "选定后指令", "defaultKey": "(none)", - "default": "默认命令(一般为移动)", - "move": "移动", - "attack": "攻击", - "stop": "停止 (取消指令队列)", - "repair": "维修", - "reclaim": "回收", - "resurrect": "复活", - "patrol": "巡逻", - "fight": "战斗", - "setTarget": "设置优先攻击目标", - "cancelTarget": "取消优先目标", - "wait": "等待 (暂停当前命令)", - "cloak": "隐形开启", - "dGun": "手动发射 (裂解炮)", - "selfDestruct": "自毁" + "default": "默认命令(一般为移动)" }, "issueOrders": { "title": "下达选择的命令", @@ -382,11 +253,9 @@ "formation": "下达列队命令" }, "queues": { - "title": "队列指令", "append": "把指令加到队列结尾", "appendKey": "shift + (指令)", - "prependKey": "space + (指令)", - "prepend": "把指令加到队列开头" + "prependKey": "space + (指令)" }, "buildOrders": { "title": "选择建造指令", @@ -400,8 +269,7 @@ "intel": "循环指定 雷达/防御/其它 建筑", "factoriesKey": "v", "factories": "循环指定生产工厂", - "rotateKey": "[ 与 ]", - "rotate": "改变建筑朝向" + "rotateKey": "[ 与 ]" }, "issueBuildOrders": { "title": "下达建造指令", @@ -414,29 +282,22 @@ "gridKey": "shift + alt + (建造指令)", "grid": "建成方阵", "spacingUpKey": "alt+z", - "spacingUp": "增加建筑间隔", - "spacingDownKey": "alt+x", - "spacingDown": "减少建筑间隔" + "spacingDownKey": "alt+x" }, "massSelect": { "title": "编队选择", "allKey": "ctrl + a", - "all": "选择所有单位", "buildersKey": "ctrl + b", - "builders": "选择所有建造单位", "createGroupKey": "ctrl + (编号)", "createGroup": "向编队中增加单位 (编号=1,2,..)", "createAutoGroupKey": "alt + (编号)", "createAutoGroup": "向自动编队中增加兵种 (编号=1,2,..)", "removeAutoGroupKey": "alt + `", - "removeAutoGroup": "从自动编队中移除兵种", "groupKey": "(编号)", "group": "选择编队中所有单位 (编号)", - "sameTypeKey": "ctrl + z", - "sameType": "选择所有同种单位" + "sameTypeKey": "ctrl + z" }, "drawing": { - "title": "地图标注", "mapmarkKey": "` + 左键双击", "mapmark": "地图标记", "drawKey": "` + 左键拖动", @@ -447,9 +308,7 @@ "console": { "title": "控制台", "eraseKey": "/clearmapmarks", - "erase": "清除所有涂鸦和标记", - "pauseKey": "/pause", - "pause": "暂停" + "pauseKey": "/pause" } }, "chat": { @@ -530,7 +389,7 @@ "heightTitle": "等高线地图", "heightmap": "[%{keyset}] 为每个地形高度标以不同颜色。", "pathingTitle": "地形通过性地图", - "pathing":"[%{keyset}] 为已选择的单位显示其地形通过性,绿色:OK,红色:艰难,紫色:无法移动", + "pathing": "[%{keyset}] 为已选择的单位显示其地形通过性,绿色:OK,红色:艰难,紫色:无法移动", "resourcesTitle": "资源图", "resources": "[%{keyset}] 高亮显示 绿色:金属矿,黄色:地热喷口. \n红色:已占领金属矿." }, @@ -571,14 +430,14 @@ "airWave1": "飞行虫群来袭!", "airWave2": "%{unitCount}只虫子!", "queenIsAngry1": { - "one" : "女王来袭!", - "other" : "女王来袭!" + "one": "女王来袭!", + "other": "女王来袭!" }, "queenIsAngry2": "准备好迎接最后的战斗吧!", "queenResistant": "女王开始对 %{unit} 的攻击产生耐受!", - "resistanceUnits":{ - "one" : "女王开始对此单位产生耐受:", - "other" : "女王开始对此单位产生耐受:" + "resistanceUnits": { + "one": "女王开始对此单位产生耐受:", + "other": "女王开始对此单位产生耐受:" }, "wave1": "第%{waveNumber}波", "wave2": "%{unitCount}只虫子!", @@ -591,17 +450,17 @@ "queenAngerAggression": "玩家攻击性:%{value}%%/s", "queenAngerEco": "经济:+%{value}%%/每秒", "queenETA": { - "one" : "%{time}后虫后降临", - "other" : "%{time}秒后%{count}只虫后降临" + "one": "%{time}后虫后降临", + "other": "%{time}秒后%{count}只虫后降临" }, "queenHealth": { - "one" : "虫后血量: %{health}%%", - "other" : "虫后血量: %{health}%%" + "one": "虫后血量: %{health}%%", + "other": "虫后血量: %{health}%%" }, "queensKilled": "虫后击杀:%{nKilled}/%{nTotal}", "queenResistantToList": { - "one" : "虫后对此有抵抗性:", - "other" : "虫后对此有抵抗性:" + "one": "虫后对此有抵抗性:", + "other": "虫后对此有抵抗性:" }, "gracePeriod": "和平期: %{time}", "burrowCount": "巢穴数量: %{count}", @@ -625,14 +484,14 @@ "firstWave1": "拾荒者已登陆!", "firstWave2": "准备好保卫自己吧!", "bossIsAngry1": { - "one" : "BOSS已抵达战场!", - "other" : "BOSS已抵达战场!" + "one": "BOSS已抵达战场!", + "other": "BOSS已抵达战场!" }, "bossIsAngry2": "准备好迎接最后的战斗吧!", "bossResistant": "BOSS开始对 %{unit} 的攻击产生耐受!", "resistanceUnits": { - "one" : "BOSS开始对此单位产生耐受:", - "other" : "BOSS开始对此单位产生耐受:" + "one": "BOSS开始对此单位产生耐受:", + "other": "BOSS开始对此单位产生耐受:" }, "wave1": "第%{waveNumber}波", "wave2": "%{unitCount}个拾荒者!", @@ -645,17 +504,17 @@ "bossAngerAggression": "玩家攻击性:%{value}%%/s", "bossAngerEco": "经济:+%{value}%%/每秒", "bossETA": { - "one" : "BOSS在%{time}秒后到达。", - "other" : "%{time}秒后%{count}个BOSS降临" + "one": "BOSS在%{time}秒后到达。", + "other": "%{time}秒后%{count}个BOSS降临" }, "bossHealth": { - "one" : "BOSS生命值:%{health}%%", - "other" : "BOSS生命值:%{health}%%" + "one": "BOSS生命值:%{health}%%", + "other": "BOSS生命值:%{health}%%" }, "bossesKilled": "BOSS击杀:%{nKilled}/%{nTotal}", "bossResistantToList": { - "one" : "BOSS开始对此单位产生耐受:", - "other" : "BOSS对此有抵抗性:" + "one": "BOSS开始对此单位产生耐受:", + "other": "BOSS对此有抵抗性:" }, "gracePeriod": "和平期: %{time}", "burrowCount": "巢穴数量: %{count}", @@ -735,7 +594,7 @@ }, "moveAttackNotify": { "underAttack": "%{unit} 正在被攻击!", - "cantMove":"%{unit}: 无法到达目的地!" + "cantMove": "%{unit}: 无法到达目的地!" }, "unitShare": { "shared": "分享了%{units}至%{name}", @@ -932,15 +791,15 @@ "metalIncome_title": "金属收入", "metalIncome_tooltip": "每秒金属收入", "energyConversionMetalIncome_title": "金属转换", - "energyConversionMetalIncome_tooltip": "通过能量转换获得的金属收入" , + "energyConversionMetalIncome_tooltip": "通过能量转换获得的金属收入", "energyIncome_title": "能量收入", "energyIncome_tooltip": "每秒能量收入", "buildPower_title": "建造能力", "buildPower_tooltip": "可用建造能力", "metalProduced_title": "已生产金属", - "metalProduced_tooltip": "已生产金属总量" , + "metalProduced_tooltip": "已生产金属总量", "energyProduced_title": "已生产能量", - "energyProduced_tooltip": "已生产能量总数" , + "energyProduced_tooltip": "已生产能量总数", "metalExcess_title": "金属溢出", "metalExcess_tooltip": "金属溢出总数", "energyExcess_title": "能量溢出", diff --git a/luaintro/Addons/loadprogress.lua b/luaintro/Addons/loadprogress.lua index e69c01b1e1c..5a7b36760b2 100644 --- a/luaintro/Addons/loadprogress.lua +++ b/luaintro/Addons/loadprogress.lua @@ -16,10 +16,6 @@ local startTime = -1 local cachedLoadTimes = VFS.FileExists("loadprogress_cached.lua") and VFS.Include("loadprogress_cached.lua") or {} local cachedTotalTime = (cachedLoadTimes[Game.mapName] or -1) * 0.97 --*0.97 cause else last rendered frame would show 99% -local function mix(x, y, a) - return x * (1 - a) + y * a -end - function SG.GetLoadProgress() if startTime < 0 then startTime = os.clock() diff --git a/luaintro/Addons/main.lua b/luaintro/Addons/main.lua index c7e5ce104c6..83e5d642530 100644 --- a/luaintro/Addons/main.lua +++ b/luaintro/Addons/main.lua @@ -372,15 +372,6 @@ local function gradientv(px, py, sx, sy, c1, c2) gl.Vertex(px, py, 0) end -local function gradienth(px, py, sx, sy, c1, c2) - gl.Color(c1) - gl.Vertex(sx, sy, 0) - gl.Vertex(sx, py, 0) - gl.Color(c2) - gl.Vertex(px, py, 0) - gl.Vertex(px, sy, 0) -end - local function bartexture(px, py, sx, sy, texLength, texHeight) local texHeight = texHeight or 1 local width = (sx - px) / texLength * 4 diff --git a/luarules/Utilities/damgam_lib/hashpostable.lua b/luarules/Utilities/damgam_lib/hashpostable.lua index 2c9a05977f9..f80504ffcbe 100644 --- a/luarules/Utilities/damgam_lib/hashpostable.lua +++ b/luarules/Utilities/damgam_lib/hashpostable.lua @@ -57,7 +57,6 @@ local function MakeHashedPosTable(resolution) -- returns the center of the Nth closest tile HashPos.sortedPositions = {} - local sortedRegions = {} function HashPos:SortNewRegion(hp) local thispos = {} diff --git a/luarules/Utilities/damgam_lib/position_checks.lua b/luarules/Utilities/damgam_lib/position_checks.lua index 0b48709d5e8..5eb7845e7ce 100644 --- a/luarules/Utilities/damgam_lib/position_checks.lua +++ b/luarules/Utilities/damgam_lib/position_checks.lua @@ -36,7 +36,7 @@ local function initializeStartPositionTable() allyTeamHasStartbox = false end AllyTeamStartboxes[testAllyTeamID + 1] = - { -- Lua Tables start at 1, AllyTeamID's start at 0, so we have to add 1 everytime + { -- Lua Tables start at 1, AllyTeamID's start at 0, so we have to add 1 every time allyTeamHasStartbox = allyTeamHasStartbox, xMin = xMin, zMin = zMin, @@ -294,7 +294,7 @@ local function StartboxCheck(posx, posy, posz, allyTeamID, returnTrueWhenNoStart return not returnTrueWhenNoStartbox end - if posx >= startbox.xMin and posz >= startbox.zMin and posx <= startbox.xMax and posz <= startbox.zMax then -- Lua Tables start at 1, AllyTeamID's start at 0, so we have to add 1 everytime + if posx >= startbox.xMin and posz >= startbox.zMin and posx <= startbox.xMax and posz <= startbox.zMax then -- Lua Tables start at 1, AllyTeamID's start at 0, so we have to add 1 every time return not returnTrueWhenNoStartbox else return returnTrueWhenNoStartbox @@ -413,8 +413,7 @@ local function SurfaceCheck(posx, posy, posz, posradius, sea) -- if true then po return true -- nothing failed, so it's good. end -local function ScavengerSpawnAreaCheck(posx, posy, posz, posradius) -- if true then position is within Scavengers spawn area. - local posradius = posradius or 1000 +local function ScavengerSpawnAreaCheck(posx, posy, posz, _posradius) -- if true then position is within Scavengers spawn area. if scavengerAllyTeamID then local scavTechPercentage = Spring.GetGameRulesParam("scavStatsTechPercentage") if scavTechPercentage then @@ -466,8 +465,7 @@ local function ScavengerSpawnAreaCheck(posx, posy, posz, posradius) -- if true t end end -local function LavaCheck(posx, posy, posz, posradius) -- Returns false if area is in lava - local posradius = posradius or 1000 +local function LavaCheck(posx, posy, posz, _posradius) -- Returns false if area is in lava local lavaLevel = Spring.GetGameRulesParam("lavaLevel") if lavaLevel and posy <= lavaLevel then return false diff --git a/luarules/Utilities/damgam_lib/spawn_queue.lua b/luarules/Utilities/damgam_lib/spawn_queue.lua index fa133a40e51..5ef05c9801c 100644 --- a/luarules/Utilities/damgam_lib/spawn_queue.lua +++ b/luarules/Utilities/damgam_lib/spawn_queue.lua @@ -42,7 +42,6 @@ end local function SpawnUnitsFromQueue(n) -- Call this every frame in your gadget. local QueuedSpawnsNumber = #QueuedSpawnList if QueuedSpawnsNumber > 0 then - local removedCount = 0 for i = 1, QueuedSpawnsNumber do local item = QueuedSpawnList[1] if item and n >= item.frame then diff --git a/luarules/callins/synthetic_callins.lua b/luarules/callins/synthetic_callins.lua index 9992b0af1b8..257ee1fcb64 100644 --- a/luarules/callins/synthetic_callins.lua +++ b/luarules/callins/synthetic_callins.lua @@ -117,13 +117,13 @@ local function makeStopMarking(marked, list, count) end ---Marked IDs this frame, as a set. ----@alias SummaryMarked table +---@alias SummaryMarked table ---Marked IDs this frame, as an array. ----@alias SummaryList integer[] +---@alias SummaryList ObjectID[] ---Signed sums per marked ID, kept while active. ----@alias SummaryTotals table +---@alias SummaryTotals table ---@class SummaryCount ---@field [1] integer? the batch size; nil is inactive @@ -132,7 +132,7 @@ end ---@field [1] true? whether accumulating totals ---Sticky-state per marked ID, kept while active. ----@alias SummaryLatched table +---@alias SummaryLatched table local function createSummary(callinName) if not syntheticCallinSummaries[callinName] then @@ -174,6 +174,9 @@ local function createSummary(callinName) active[1] = true elseif active[1] then for i = 1, count[1] or 0 do + -- ObjectID is a union of two integer aliases, which the checker will not + -- accept as the key of a table it is clearing an entry from. + ---@diagnostic disable-next-line: inject-field totals[list[i]] = nil end active[1] = nil diff --git a/luarules/configs/ai_namer/contributors.lua b/luarules/configs/ai_namer/contributors.lua index 80a4b514e9b..98a07d8c6b7 100644 --- a/luarules/configs/ai_namer/contributors.lua +++ b/luarules/configs/ai_namer/contributors.lua @@ -39,6 +39,7 @@ local ContributorAINames = { "EnderRobo", "Endorphins", "Errrrrrr", + "Eunice3x", "Fireball", "FireStorm", "Flaka", diff --git a/luarules/configs/collisionvolumes.lua b/luarules/configs/collisionvolumes.lua index 91ac8d6b7c1..08430f574a5 100644 --- a/luarules/configs/collisionvolumes.lua +++ b/luarules/configs/collisionvolumes.lua @@ -207,14 +207,25 @@ unitCollisionVolume.legsolar = { off = { 40, 76, 40, 0, -10, 1, 0, 1, 0 }, } -for name, v in pairs(unitCollisionVolume) do - for udid, ud in pairs(UnitDefs) do - if string.find(ud.name, name) then - unitCollisionVolume[ud.name] = v +-- copy each entry to its scavenger variants, matched via the customparams that scav def +-- generation stamps (isscavenger + fromunit backlink). The old substring propagation +-- corrupted units whose name merely contained another entry's name (armannit3/cordoomt3 +-- got armanni/cordoom's whole-unit volumes, clobbering their per-piece definitions) +local function propagateToScavCopies(tbl) + local scavCopies = {} + for _, unitDef in pairs(UnitDefs) do + local baseName = unitDef.customParams.isscavenger and unitDef.customParams.fromunit + if baseName and tbl[baseName] then + scavCopies[unitDef.name] = tbl[baseName] end end + for name, v in pairs(scavCopies) do + tbl[name] = v + end end +propagateToScavCopies(unitCollisionVolume) + pieceCollisionVolume.corhrk = { ["2"] = { 35, 40, 30, 0, -8, 0, 2, 1 }, } @@ -387,7 +398,7 @@ pieceCollisionVolume.cortrem = { ["0"] = { 40, 32, 44, 0, 0, 0, 2, 1 }, ["1"] = { 24, 64, 24, 0, 0, 0, 2, 1 }, } -pieceCollisionVolume.seal = { +pieceCollisionVolume.corseal = { ["0"] = { 28, 25, 34, 0, 0, 0, 2, 1 }, ["1"] = { 12, 16, 12, 0, 0, 0, 2, 1 }, } @@ -460,13 +471,14 @@ pieceCollisionVolume['legkeres'] = { ['2']={44,19,48,0,9.5,2,2,0}, } -for name, v in pairs(pieceCollisionVolume) do - for udid, ud in pairs(UnitDefs) do - if string.find(ud.name, name) then - pieceCollisionVolume[ud.name] = v - end - end -end +-- variants that previously inherited their base unit's volumes via substring matching +pieceCollisionVolume.corgolt4 = pieceCollisionVolume.corgol +pieceCollisionVolume.corhalab = pieceCollisionVolume.corhal +pieceCollisionVolume.leggatet3 = pieceCollisionVolume.leggat +pieceCollisionVolume.leginfestor = pieceCollisionVolume.leginf +pieceCollisionVolume.legsrailt4 = pieceCollisionVolume.legsrail + +propagateToScavCopies(pieceCollisionVolume) dynamicPieceCollisionVolume.corvipe = { on = { @@ -479,12 +491,6 @@ dynamicPieceCollisionVolume.corvipe = { offsets = { 0, 8, 0 }, --['offsets']={0,10,0}, TODO: revert back when issue fixed: https://springrts.com/mantis/view.php?id=5144 }, } -for name, v in pairs(dynamicPieceCollisionVolume) do - for udid, ud in pairs(UnitDefs) do - if string.find(ud.name, name) then - dynamicPieceCollisionVolume[ud.name] = v - end - end -end +propagateToScavCopies(dynamicPieceCollisionVolume) return unitCollisionVolume, pieceCollisionVolume, dynamicPieceCollisionVolume diff --git a/luarules/configs/icon_generator.lua b/luarules/configs/icon_generator.lua index c6776532b8a..0b889523546 100644 --- a/luarules/configs/icon_generator.lua +++ b/luarules/configs/icon_generator.lua @@ -116,27 +116,6 @@ halo = IconConfig[selConfig].halo --// backgrounds background = true -local water = "LuaRules/Images/bg_water.png" -local builder = "LuaRules/Images/constructionunit.png" - -local function Greater30(a) - return a > 30 -end -local function GreaterEq15(a) - return a >= 15 -end -local function GreaterZero(a) - return a > 0 -end -local function GreaterEqZero(a) - return a >= 0 -end -local function GreaterFour(a) - return a > 4 -end -local function LessEqZero(a) - return a <= 0 -end backgrounds = { --{check={waterline=GreaterEq15,minWaterDepth=GreaterZero},texture=water}, diff --git a/luarules/configs/quick_start_build_defs.lua b/luarules/configs/quick_start_build_defs.lua index 38199231e98..0fa7053b1de 100644 --- a/luarules/configs/quick_start_build_defs.lua +++ b/luarules/configs/quick_start_build_defs.lua @@ -1,24 +1,5 @@ local quickStartConfig = { - discountableFactories = { - armap = true, - armfhp = true, - armhp = true, - armlab = true, - armsy = true, - armvp = true, - corap = true, - corfhp = true, - corhp = true, - corlab = true, - corsy = true, - corvp = true, - legap = true, - legfhp = true, - leghp = true, - leglab = true, - legsy = true, - legvp = true, - }, + -- discountable factories are marked via customparams.quickstart_discountable on the unit defs commanderNonLabOptions = { armcom = { windmill = "armwin", diff --git a/luarules/configs/scav_spawn_defs.lua b/luarules/configs/scav_spawn_defs.lua index cd672a6e237..27f9387f457 100644 --- a/luarules/configs/scav_spawn_defs.lua +++ b/luarules/configs/scav_spawn_defs.lua @@ -21,8 +21,6 @@ economyScale = math.min(5, (economyScale * 0.33) + 0.67) local teams = Spring.GetTeamList() local humanTeamCount = -1 -- starts at -1 to disregard gaia -local scavTeamCount -local scavTeamID for _, teamID in ipairs(teams) do local teamLuaAI = Spring.GetTeamLuaAI(teamID) if not (teamLuaAI and string.find(teamLuaAI, "ScavengersAI")) then @@ -3173,7 +3171,7 @@ local highValueTargetsNames = { -- Priority targets for Scav. Must be immobile t local highValueTargets = {} for unitName, params in pairs(highValueTargetsNames) do if not UnitDefNames[unitName] then - Spring.Log(gadget:GetInfo().name, LOG.ERROR, "couldnt find unit name: " .. unitName) + Spring.Log(gadget:GetInfo().name, LOG.ERROR, "couldn't find unit name: " .. unitName) else highValueTargets[UnitDefNames[unitName].id] = params end diff --git a/luarules/gadgets/ai_ruins.lua b/luarules/gadgets/ai_ruins.lua index 75155f12915..8faf51bcc97 100644 --- a/luarules/gadgets/ai_ruins.lua +++ b/luarules/gadgets/ai_ruins.lua @@ -270,6 +270,9 @@ end -- CreateUnit does not snap; Pos2BuildPos uses even vs odd grid from footprint parity. local function createSnappedUnit(defID, x, y, z, facing, teamID) + if UnitDefs[defID].customParams.modoption_blocked then + return nil + end x, y, z = Spring.Pos2BuildPos(defID, x, y, z, facing) return Spring.CreateUnit(defID, x, y, z, facing, teamID) end diff --git a/luarules/gadgets/ai_zombies.lua b/luarules/gadgets/ai_zombies.lua new file mode 100644 index 00000000000..2c50eb63eca --- /dev/null +++ b/luarules/gadgets/ai_zombies.lua @@ -0,0 +1,1220 @@ +function gadget:GetInfo() + return { + name = "Zombie AI", + desc = "Controls autonomous Gaia zombie behavior", + author = "SethDGamre", + date = "August 2026", + license = "GNU GPL, v2 or later", + layer = 3, -- after game_zombies.lua + enabled = true, + } +end + +if not gadgetHandler:IsSyncedCode() then + return false +end + +local spring = Spring +local modOptions = spring.GetModOptions() +local modOptionEnabled = modOptions.zombies ~= "disabled" +local isIdleMode = GG.Zombies and GG.Zombies.IdleMode == true or false +if not modOptionEnabled and not isIdleMode then + return false +end + +local random = math.random +local distance2dSquared = math.distance2dSquared +local TAU = 2 * math.pi +local cos = math.cos +local sin = math.sin +local atan2 = math.atan2 +local DEGREES_TO_RADIANS = math.pi / 180 + +local ZOMBIE_ORDER_CHECK_INTERVAL = Game.gameSpeed * 3 +local STUCK_CHECK_INTERVAL = Game.gameSpeed * 12 +local AGGRO_CHECK_INTERVAL = Game.gameSpeed * 30 +local AGGRO_DURATION = Game.gameSpeed * 60 +local AGGRO_MIN_START_FRAME = Game.gameSpeed * 60 * 15 + +local STUCK_DISTANCE = 50 +local STUCK_DISTANCE_SQUARED = STUCK_DISTANCE ^ 2 +local NOGO_ZONE_RADIUS = 600 +local NOGO_ZONE_RADIUS_SQUARED = NOGO_ZONE_RADIUS ^ 2 +local ENEMY_ATTACK_DISTANCE = 1000 +local ORDER_DISTANCE = 1600 +local OBJECTIVE_REACHED_DISTANCE = 200 +local OBJECTIVE_REACHED_DISTANCE_SQUARED = OBJECTIVE_REACHED_DISTANCE ^ 2 +local COMBAT_TARGET_MOVE_REFRESH_DISTANCE = 100 +local COMBAT_TARGET_MOVE_REFRESH_DISTANCE_SQUARED = COMBAT_TARGET_MOVE_REFRESH_DISTANCE ^ 2 +local POSITION_VARIANCE = 50 + +local ZOMBIE_MAX_ORDER_ATTEMPTS = 10 +local ZOMBIE_FACTORY_BUILD_COUNT = 20 +local MAX_NOGO_ZONES = 10 +local AGGRO_ZOMBIE_TO_PLAYER_POWER_RATIO = 0.1 -- the threshold of relative power where zombies stop wandering and swarm players +local COMBAT_ENGAGE_RANGE_RATIO = 0.5 + +local NORMAL_OBJECTIVE_ANGLE_VARIANCE = 90 * DEGREES_TO_RADIANS +local AGGRO_OBJECTIVE_ANGLE_VARIANCE = 22.5 * DEGREES_TO_RADIANS +local COMBAT_SECONDARY_ANGLE_OFFSET = 45 * DEGREES_TO_RADIANS +local COMBAT_SECONDARY_ANGLE_COS = cos(COMBAT_SECONDARY_ANGLE_OFFSET) +local COMBAT_SECONDARY_ANGLE_SIN = sin(COMBAT_SECONDARY_ANGLE_OFFSET) + +local CMD_REPEAT = CMD.REPEAT +local CMD_MOVE_STATE = CMD.MOVE_STATE +local CMD_FIRE_STATE = CMD.FIRE_STATE +local CMD_IDLEMODE = CMD.IDLEMODE +local CMD_MOVE = CMD.MOVE +local CMD_FIGHT = CMD.FIGHT +local CMD_CAPTURE = CMD.CAPTURE +local CMD_STOP = CMD.STOP +local CMD_OPT_SHIFT = { "shift" } + +local FIRE_STATE_FIRE_AT_ALL = 3 +local FIRE_STATE_RETURN_FIRE = 1 +local MOVE_STATE_ROAM = 2 +local IDLEMODE_FLY = 0 +local ENABLE_REPEAT = 1 +local NULL_ATTACKER = -1 +local ENVIRONMENTAL_DAMAGE_ID = Game.envDamageTypes.GroundCollision + +local MAP_SIZE_X = Game.mapSizeX +local MAP_SIZE_Z = Game.mapSizeZ +local MAP_PERIMETER = 2 * (MAP_SIZE_X + MAP_SIZE_Z) +local OBJECTIVE_TYPE_NORMAL = 1 +local OBJECTIVE_TYPE_AGGRO = 2 + +local spGetUnitNearestEnemy = spring.GetUnitNearestEnemy +local spValidUnitID = spring.ValidUnitID +local spGetGroundHeight = spring.GetGroundHeight +local spGetUnitPosition = spring.GetUnitPosition +local spGetUnitDefID = spring.GetUnitDefID +local spGiveOrderToUnit = spring.GiveOrderToUnit +local spGiveOrderArrayToUnit = spring.GiveOrderArrayToUnit +local spGetFactoryCommandCount = spring.GetFactoryCommandCount +local spGetUnitIsDead = spring.GetUnitIsDead +local spGetUnitHealth = spring.GetUnitHealth +local spGetUnitRulesParam = spring.GetUnitRulesParam +local spTestMoveOrder = spring.TestMoveOrder +local spGetUnitCurrentCommand = spring.GetUnitCurrentCommand +local spGetUnitHeight = spring.GetUnitHeight +local spGetUnitTeam = spring.GetUnitTeam +local spGetUnitLosState = spring.GetUnitLosState +local spGetUnitsInCylinder = spring.GetUnitsInCylinder +local spAreTeamsAllied = spring.AreTeamsAllied + +local gaiaTeamID = spring.GetGaiaTeamID() +local gaiaAllyTeamID = select(6, spring.GetTeamInfo(gaiaTeamID)) +local readAsGaia = { ctrl = gaiaTeamID, read = gaiaTeamID, select = gaiaTeamID } +local scavTeamID +for _, teamID in ipairs(spring.GetTeamList()) do + local teamLuaAI = spring.GetTeamLuaAI(teamID) + if teamLuaAI and string.find(teamLuaAI, "ScavengersAI", 1, true) then + scavTeamID = teamID + break + end +end + +local ordersEnabled = true +local isPacified = false +local autoOrdersSuspended = false +local gameFrame = 0 +local totalMobileZombiePower = 0 +local aggroExpirationTimestamp = 0 + +local mobileUnitDefs = {} +local aircraftUnitDefs = {} +local factoriesWithCombatOptions = {} +local unitDefWeaponRanges = {} +local capturingUnits = {} +local zombieAggros = {} +local allyTeamUnits = {} +local unitAllyTeamIDs = {} +local unitAllyTeamIndices = {} +local zombieWatch = {} +local flyingUnits = {} +local zombieOrderBuckets = {} +local zombieStuckBuckets = {} + +for unitDefID, unitDef in pairs(UnitDefs) do + if unitDef.canCapture then + capturingUnits[unitDefID] = true + end + + if unitDef.weapons and #unitDef.weapons > 0 then + local maximumGroundWeaponRange = 0 + local maximumAirWeaponRange = 0 + local maximumUnderwaterWeaponRange = 0 + + for weaponIndex = 1, #unitDef.weapons do + local weapon = unitDef.weapons[weaponIndex] + local weaponDefID = weapon.weaponDef + if weaponDefID then + local weaponDef = WeaponDefs[weaponDefID] + if + weaponDef + and weaponDef.range + and weaponDef.range > 0 + and not (weaponDef.customParams and weaponDef.customParams.bogus) + then + local isAAWeapon = weapon.onlyTargets + and weapon.onlyTargets.vtol + and not weapon.onlyTargets.ground + local isUnderwaterOnly = weaponDef.type == "TorpedoLauncher" + + if isAAWeapon then + maximumAirWeaponRange = math.max(maximumAirWeaponRange, weaponDef.range) + elseif isUnderwaterOnly then + maximumUnderwaterWeaponRange = math.max(maximumUnderwaterWeaponRange, weaponDef.range) + else + maximumGroundWeaponRange = math.max(maximumGroundWeaponRange, weaponDef.range) + if weapon.onlyTargets and weapon.onlyTargets.vtol then + maximumAirWeaponRange = math.max(maximumAirWeaponRange, weaponDef.range) + end + if weaponDef.waterWeapon then + maximumUnderwaterWeaponRange = + math.max(maximumUnderwaterWeaponRange, weaponDef.range) + end + end + end + end + end + + if maximumGroundWeaponRange > 0 or maximumAirWeaponRange > 0 or maximumUnderwaterWeaponRange > 0 then + unitDefWeaponRanges[unitDefID] = { + ground = maximumGroundWeaponRange, + air = maximumAirWeaponRange, + underwater = maximumUnderwaterWeaponRange, + } + end + end +end + +for unitDefID, unitDef in pairs(UnitDefs) do + if unitDef.speed > 0 then + mobileUnitDefs[unitDefID] = true + if unitDef.canFly then + aircraftUnitDefs[unitDefID] = true + end + elseif #unitDef.buildOptions > 0 then + local combatOptions = {} + for optionIndex = 1, #unitDef.buildOptions do + local optionDefID = unitDef.buildOptions[optionIndex] + if unitDefWeaponRanges[optionDefID] then + combatOptions[#combatOptions + 1] = optionDefID + end + end + if #combatOptions > 0 then + factoriesWithCombatOptions[unitDefID] = combatOptions + end + end +end + +for bucketIndex = 1, ZOMBIE_ORDER_CHECK_INTERVAL do + zombieOrderBuckets[bucketIndex] = {} +end + +for bucketIndex = 1, STUCK_CHECK_INTERVAL do + zombieStuckBuckets[bucketIndex] = {} +end + +local function removeZombieFromBucket(unitID, bucket, unitIndex, indexField) + local lastIndex = #bucket + local lastUnitID = bucket[lastIndex] + bucket[unitIndex] = lastUnitID + bucket[lastIndex] = nil + if lastUnitID ~= unitID then + zombieWatch[lastUnitID][indexField] = unitIndex + end +end + +local function unwatchZombie(unitID) + local zombieData = zombieWatch[unitID] + if not zombieData then + return + end + if mobileUnitDefs[zombieData.unitDefID] then + totalMobileZombiePower = totalMobileZombiePower - zombieData.power + end + removeZombieFromBucket(unitID, zombieOrderBuckets[unitID % ZOMBIE_ORDER_CHECK_INTERVAL + 1], zombieData.orderBucketIndex, "orderBucketIndex") + removeZombieFromBucket(unitID, zombieStuckBuckets[unitID % STUCK_CHECK_INTERVAL + 1], zombieData.stuckBucketIndex, "stuckBucketIndex") + zombieWatch[unitID] = nil + zombieAggros[unitID] = nil +end + +local function setAggroExpiration() + aggroExpirationTimestamp = gameFrame + AGGRO_DURATION +end + +local function getActiveZombieAggro(unitID) + if gameFrame >= aggroExpirationTimestamp then + return nil + end + return zombieAggros[unitID] +end + +local function addAllyTeamUnit(unitID, allyTeamID) + if not allyTeamID or unitAllyTeamIDs[unitID] then + return + end + local unitList = allyTeamUnits[allyTeamID] + if not unitList then + unitList = {} + allyTeamUnits[allyTeamID] = unitList + end + local unitIndex = #unitList + 1 + unitList[unitIndex] = unitID + unitAllyTeamIDs[unitID] = allyTeamID + unitAllyTeamIndices[unitID] = unitIndex +end + +local function removeAllyTeamUnit(unitID) + local allyTeamID = unitAllyTeamIDs[unitID] + if not allyTeamID then + return + end + local unitList = allyTeamUnits[allyTeamID] + local unitIndex = unitAllyTeamIndices[unitID] + local lastIndex = #unitList + local lastUnitID = unitList[lastIndex] + unitList[unitIndex] = lastUnitID + unitList[lastIndex] = nil + if lastUnitID ~= unitID then + unitAllyTeamIndices[lastUnitID] = unitIndex + end + unitAllyTeamIDs[unitID] = nil + unitAllyTeamIndices[unitID] = nil +end + +local function isZombie(unitID) + return spGetUnitRulesParam(unitID, "zombie") == 1 +end + +local function issueRandomFactoryBuildOrders(unitID, unitDefID, buildCount) + local combatOptions = factoriesWithCombatOptions[unitDefID] + local buildOrders = {} + for buildIndex = 1, buildCount do + buildOrders[#buildOrders + 1] = { -combatOptions[random(1, #combatOptions)], 0, 0 } + end + spGiveOrderArrayToUnit(unitID, buildOrders) +end + +local function clearUnitOrders(unitID) + if spValidUnitID(unitID) then + spGiveOrderToUnit(unitID, CMD_STOP, {}, {}) + end +end + +local function getWeaponRangeForTarget(attackerDefID, targetID, targetYPosition) + local weaponRanges = unitDefWeaponRanges[attackerDefID] + if not weaponRanges then + return + end + local targetDef = UnitDefs[spGetUnitDefID(targetID)] + local weaponRange + if flyingUnits[targetID] or targetDef.canFly then + weaponRange = weaponRanges.air + elseif targetYPosition + (spGetUnitHeight(targetID) or 0) < 0 then + weaponRange = weaponRanges.underwater + else + weaponRange = weaponRanges.ground + end + if weaponRange and weaponRange > 0 then + return weaponRange + end + return nil +end + +local function isUnitInGaiaLos(unitID) + local losState = spGetUnitLosState(unitID, gaiaAllyTeamID, true) + return losState and losState % 2 == 1 -- raw LOS mask: odd means currently in Gaia sight +end + +local function getCombatTargetData(unitDefID, targetID) + if not targetID or not spValidUnitID(targetID) or spGetUnitIsDead(targetID) then + return + end + local targetTeamID = spGetUnitTeam(targetID) + if not targetTeamID or spAreTeamsAllied(gaiaTeamID, targetTeamID) then + return + end + local targetX, targetY, targetZ = spGetUnitPosition(targetID) + if not targetX then + return + end + local targetDefID = spGetUnitDefID(targetID) + local shouldCapture = capturingUnits[unitDefID] + and UnitDefs[targetDefID].capturable ~= false + and isUnitInGaiaLos(targetID) + local weaponRange = getWeaponRangeForTarget(unitDefID, targetID, targetY) + if shouldCapture or (weaponRange and weaponRange > 0) then + return targetX, targetZ, shouldCapture, weaponRange + end +end + +local function getNearestCombatTarget(unitID, unitDefID) + local nearestEnemyID = spGetUnitNearestEnemy(unitID, ENEMY_ATTACK_DISTANCE, true) + local targetX, targetZ, shouldCapture, weaponRange = getCombatTargetData(unitDefID, nearestEnemyID) + if targetX then + return nearestEnemyID, targetX, targetZ, shouldCapture, weaponRange + end + + local unitX, _, unitZ = spGetUnitPosition(unitID) + if not unitX then + return + end + local enemyUnits = CallAsTeam(readAsGaia, spGetUnitsInCylinder, unitX, unitZ, ENEMY_ATTACK_DISTANCE, spring.ENEMY_UNITS) -- nearest-enemy API can return an unshootable unit, so fall back to a cylinder scan + local bestTargetID + local bestTargetX + local bestTargetZ + local bestShouldCapture + local bestWeaponRange + local bestDistanceSquared + for enemyIndex = 1, #enemyUnits do + local enemyID = enemyUnits[enemyIndex] + targetX, targetZ, shouldCapture, weaponRange = getCombatTargetData(unitDefID, enemyID) + if targetX then + local targetDistanceSquared = distance2dSquared(unitX, unitZ, targetX, targetZ) + if not bestDistanceSquared or targetDistanceSquared < bestDistanceSquared then + bestTargetID = enemyID + bestTargetX = targetX + bestTargetZ = targetZ + bestShouldCapture = shouldCapture + bestWeaponRange = weaponRange + bestDistanceSquared = targetDistanceSquared + end + end + end + return bestTargetID, bestTargetX, bestTargetZ, bestShouldCapture, bestWeaponRange +end + +local function setRandomEdgeObjective(zombieData) + local perimeterPosition = random() * MAP_PERIMETER -- map a random perimeter length onto one of the four edges + local objectiveX + local objectiveZ + if perimeterPosition < MAP_SIZE_X then + objectiveX = perimeterPosition + objectiveZ = 0 + elseif perimeterPosition < MAP_SIZE_X + MAP_SIZE_Z then + objectiveX = MAP_SIZE_X + objectiveZ = perimeterPosition - MAP_SIZE_X + elseif perimeterPosition < MAP_SIZE_X * 2 + MAP_SIZE_Z then + objectiveX = MAP_SIZE_X * 2 + MAP_SIZE_Z - perimeterPosition + objectiveZ = MAP_SIZE_Z + else + objectiveX = 0 + objectiveZ = MAP_PERIMETER - perimeterPosition + end + objectiveX = math.max(POSITION_VARIANCE, math.min(MAP_SIZE_X - POSITION_VARIANCE, objectiveX)) + objectiveZ = math.max(POSITION_VARIANCE, math.min(MAP_SIZE_Z - POSITION_VARIANCE, objectiveZ)) + zombieData.objective = { type = OBJECTIVE_TYPE_NORMAL, x = objectiveX, z = objectiveZ } +end + +local function getRandomValidAllyTeamUnit(allyTeamID) + local unitList = allyTeamUnits[allyTeamID] + if not unitList or #unitList == 0 then + return + end + local attemptsRemaining = #unitList + while attemptsRemaining > 0 do + local targetUnitID = unitList[random(1, #unitList)] + if spValidUnitID(targetUnitID) and not spGetUnitIsDead(targetUnitID) then + local targetX, _, targetZ = spGetUnitPosition(targetUnitID) + if targetX then + return targetUnitID, targetX, targetZ + end + end + removeAllyTeamUnit(targetUnitID) + attemptsRemaining = attemptsRemaining - 1 + end +end + +local function setAggroObjective(zombieData, allyTeamID) + local targetUnitID, targetX, targetZ = getRandomValidAllyTeamUnit(allyTeamID) + if not targetUnitID then + return false + end + zombieData.objective = { + type = OBJECTIVE_TYPE_AGGRO, + x = targetX, + z = targetZ, + targetUnitID = targetUnitID, + } + return true +end + +local function getLeastAssignedAlly(eligibleAllies) + local leastAssignedAlly = eligibleAllies[1] + for allyIndex = 2, #eligibleAllies do + local allyData = eligibleAllies[allyIndex] + if + allyData.assignedPower < leastAssignedAlly.assignedPower + or ( + allyData.assignedPower == leastAssignedAlly.assignedPower + and allyData.allyTeamID < leastAssignedAlly.allyTeamID + ) + then + leastAssignedAlly = allyData + end + end + return leastAssignedAlly +end + +local function compareZombiePower(firstZombie, secondZombie) + if firstZombie.power == secondZombie.power then + return firstZombie.unitID < secondZombie.unitID + end + return firstZombie.power > secondZombie.power +end + +local function isObjectiveReached(unitID, objective, unitX, unitZ) + if not unitX then + unitX, _, unitZ = spGetUnitPosition(unitID) + end + if not unitX then + return false + end + return distance2dSquared(unitX, unitZ, objective.x, objective.z) <= OBJECTIVE_REACHED_DISTANCE_SQUARED +end + +local function isAggroObjectiveValid(unitID, objective, allyTeamID) + if + not objective + or objective.type ~= OBJECTIVE_TYPE_AGGRO + or not spValidUnitID(objective.targetUnitID) + or spGetUnitIsDead(objective.targetUnitID) + or unitAllyTeamIDs[objective.targetUnitID] ~= allyTeamID + then + return false + end + return not isObjectiveReached(unitID, objective) +end + +local function assignZombieAggroEvenly() + local playerTeams = GG.PowerLib.PlayerTeams + local teamPowers = GG.PowerLib.TeamPowers + local allyPowers = {} + for teamID in pairs(playerTeams) do + local allyTeamID = select(6, spring.GetTeamInfo(teamID)) + local teamPower = teamPowers[teamID] or 0 + allyPowers[allyTeamID] = (allyPowers[allyTeamID] or 0) + teamPower + end + + local eligibleAllies = {} + local eligibleAlliesByID = {} + for allyTeamID, allyPower in pairs(allyPowers) do + if allyPower > 0 and getRandomValidAllyTeamUnit(allyTeamID) then + local allyData = { + allyTeamID = allyTeamID, + assignedPower = 0, + } + eligibleAllies[#eligibleAllies + 1] = allyData + eligibleAlliesByID[allyTeamID] = allyData + end + end + + local zombiesNeedingAggro = {} + for unitID, zombieData in pairs(zombieWatch) do + if mobileUnitDefs[zombieData.unitDefID] then + local allyTeamID = zombieAggros[unitID] + local allyData = allyTeamID and eligibleAlliesByID[allyTeamID] + if allyData and isAggroObjectiveValid(unitID, zombieData.objective, allyTeamID) then + allyData.assignedPower = allyData.assignedPower + zombieData.power + else + zombieAggros[unitID] = nil + if zombieData.objective and zombieData.objective.type == OBJECTIVE_TYPE_AGGRO then + zombieData.objective = nil + end + clearUnitOrders(unitID) + zombiesNeedingAggro[#zombiesNeedingAggro + 1] = { + unitID = unitID, + power = zombieData.power, + } + end + else + zombieAggros[unitID] = nil + if zombieData.objective and zombieData.objective.type == OBJECTIVE_TYPE_AGGRO then + zombieData.objective = nil + end + end + end + + if #eligibleAllies == 0 then + return + end + + table.sort(zombiesNeedingAggro, compareZombiePower) -- keep valid assignments, then give strongest leftovers to the least-pressured ally + for zombieIndex = 1, #zombiesNeedingAggro do + local pendingZombie = zombiesNeedingAggro[zombieIndex] + local zombieData = zombieWatch[pendingZombie.unitID] + local leastAssignedAlly = getLeastAssignedAlly(eligibleAllies) + if setAggroObjective(zombieData, leastAssignedAlly.allyTeamID) then + zombieAggros[pendingZombie.unitID] = leastAssignedAlly.allyTeamID + leastAssignedAlly.assignedPower = leastAssignedAlly.assignedPower + pendingZombie.power + end + end +end + +local function rememberEnemyDirection(unitID, zombieData, targetX, targetZ) + local unitX, _, unitZ = spGetUnitPosition(unitID) + if not unitX then + return + end + local deltaX = targetX - unitX + local deltaZ = targetZ - unitZ + if deltaX == 0 and deltaZ == 0 then + return + end + local xScale = math.huge -- project the enemy bearing out to the map edge for later pursuit + if deltaX > 0 then + xScale = (MAP_SIZE_X - unitX) / deltaX + elseif deltaX < 0 then + xScale = -unitX / deltaX + end + local zScale = math.huge + if deltaZ > 0 then + zScale = (MAP_SIZE_Z - unitZ) / deltaZ + elseif deltaZ < 0 then + zScale = -unitZ / deltaZ + end + local boundaryScale = math.min(xScale, zScale) + zombieData.rememberedObjectiveX = math.max(POSITION_VARIANCE, math.min(MAP_SIZE_X - POSITION_VARIANCE, unitX + deltaX * boundaryScale)) + zombieData.rememberedObjectiveZ = math.max(POSITION_VARIANCE, math.min(MAP_SIZE_Z - POSITION_VARIANCE, unitZ + deltaZ * boundaryScale)) +end + +local function ensureMovementObjective(unitID, zombieData, allyTeamID) + local objective = zombieData.objective -- aggro target, else last-seen enemy edge, else a new map-edge wander + if allyTeamID then + if isAggroObjectiveValid(unitID, objective, allyTeamID) then + return objective, false + end + if objective and objective.type == OBJECTIVE_TYPE_AGGRO then + zombieData.objective = nil + end + if setAggroObjective(zombieData, allyTeamID) then + return zombieData.objective, true + end + if zombieAggros[unitID] == allyTeamID then + zombieAggros[unitID] = nil + end + end + + objective = zombieData.objective + if zombieData.rememberedObjectiveX then + local isRememberedObjective = + objective + and objective.type == OBJECTIVE_TYPE_NORMAL + and objective.x == zombieData.rememberedObjectiveX + and objective.z == zombieData.rememberedObjectiveZ + if isRememberedObjective and isObjectiveReached(unitID, objective) then + zombieData.rememberedObjectiveX = nil + zombieData.rememberedObjectiveZ = nil + zombieData.objective = nil + objective = nil + elseif + not objective + or objective.type ~= OBJECTIVE_TYPE_NORMAL + or objective.x ~= zombieData.rememberedObjectiveX + or objective.z ~= zombieData.rememberedObjectiveZ + then + zombieData.objective = { + type = OBJECTIVE_TYPE_NORMAL, + x = zombieData.rememberedObjectiveX, + z = zombieData.rememberedObjectiveZ, + } + return zombieData.objective, true + else + return objective, false + end + end + + if not objective or objective.type ~= OBJECTIVE_TYPE_NORMAL or isObjectiveReached(unitID, objective) then + setRandomEdgeObjective(zombieData) + return zombieData.objective, true + end + return objective, false +end + +local function isInNoGoZone(zombieData, targetX, targetZ) + for _, zone in ipairs(zombieData.noGoZones) do + local deltaX = targetX - zone.x + local deltaZ = targetZ - zone.z + if deltaX * deltaX + deltaZ * deltaZ < NOGO_ZONE_RADIUS_SQUARED then + return true + end + end + return false +end + +local function isMoveTargetTraversable(unitDefID, targetX, targetY, targetZ) + if aircraftUnitDefs[unitDefID] then + return true + end + return spTestMoveOrder(unitDefID, targetX, targetY, targetZ) +end + +local function getMovementCommand(unitDefID) + if aircraftUnitDefs[unitDefID] then + return CMD_FIGHT + end + return CMD_MOVE +end + +local function getObjectiveMoveTarget(unitDefID, zombieData, objective, originX, originZ) + local deltaX = objective.x - originX + local deltaZ = objective.z - originZ + local objectiveDistance = math.sqrt(deltaX * deltaX + deltaZ * deltaZ) + local objectiveAngle = atan2(deltaZ, deltaX) + local angleVariance = objective.type == OBJECTIVE_TYPE_AGGRO and AGGRO_OBJECTIVE_ANGLE_VARIANCE or NORMAL_OBJECTIVE_ANGLE_VARIANCE + + for attemptIndex = 1, ZOMBIE_MAX_ORDER_ATTEMPTS do + local movementAngle + local movementDistance = math.min(ORDER_DISTANCE, objectiveDistance) + if attemptIndex == ZOMBIE_MAX_ORDER_ATTEMPTS then + movementAngle = random() * TAU -- last try: ignore the objective and pick any passable heading + movementDistance = ORDER_DISTANCE + else + movementAngle = objectiveAngle + (random() * 2 - 1) * angleVariance + end + local candidateTargetX = math.max(POSITION_VARIANCE, math.min(MAP_SIZE_X - POSITION_VARIANCE, originX + movementDistance * cos(movementAngle) + random(-POSITION_VARIANCE, POSITION_VARIANCE))) + local candidateTargetZ = math.max(POSITION_VARIANCE, math.min(MAP_SIZE_Z - POSITION_VARIANCE, originZ + movementDistance * sin(movementAngle) + random(-POSITION_VARIANCE, POSITION_VARIANCE))) + if not isInNoGoZone(zombieData, candidateTargetX, candidateTargetZ) then + local candidateTargetY = spGetGroundHeight(candidateTargetX, candidateTargetZ) + if isMoveTargetTraversable(unitDefID, candidateTargetX, candidateTargetY, candidateTargetZ) then + return candidateTargetX, candidateTargetY, candidateTargetZ + end + end + end +end + +local function issueObjectiveMove(unitID, unitDefID, zombieData, objective) + local unitX, _, unitZ = spGetUnitPosition(unitID) + if not unitX then + return + end + if + zombieData.rememberedObjectiveX + and objective.type == OBJECTIVE_TYPE_NORMAL + and objective.x == zombieData.rememberedObjectiveX + and objective.z == zombieData.rememberedObjectiveZ + and isObjectiveReached(unitID, objective, unitX, unitZ) + then + zombieData.rememberedObjectiveX = nil + zombieData.rememberedObjectiveZ = nil + zombieData.objective = nil + setRandomEdgeObjective(zombieData) + objective = zombieData.objective + end + + local movementCommand = getMovementCommand(unitDefID) + local firstTargetX, firstTargetY, firstTargetZ = + getObjectiveMoveTarget(unitDefID, zombieData, objective, unitX, unitZ) + if firstTargetX then + spGiveOrderToUnit(unitID, movementCommand, { firstTargetX, firstTargetY, firstTargetZ }, 0) + local secondTargetX, secondTargetY, secondTargetZ = + getObjectiveMoveTarget(unitDefID, zombieData, objective, firstTargetX, firstTargetZ) -- pre-queue the next hop so they don't stall between order ticks + if secondTargetX then + spGiveOrderToUnit( + unitID, + movementCommand, + { secondTargetX, secondTargetY, secondTargetZ }, + CMD_OPT_SHIFT + ) + end + return + end + + clearUnitOrders(unitID) + if objective.type == OBJECTIVE_TYPE_AGGRO or not zombieData.rememberedObjectiveX then + zombieData.objective = nil + ensureMovementObjective(unitID, zombieData, getActiveZombieAggro(unitID)) + end +end + +local function issueCombatMove(unitID, unitDefID, weaponRange, targetX, targetZ, zombieData) + local unitX, _, unitZ = spGetUnitPosition(unitID) + if not unitX then + zombieData.lastCombatTargetX = nil + zombieData.lastCombatTargetZ = nil + clearUnitOrders(unitID) + return + end + local deltaX = unitX - targetX + local deltaZ = unitZ - targetZ + local distance = math.sqrt(deltaX * deltaX + deltaZ * deltaZ) + if distance == 0 then + clearUnitOrders(unitID) + return + end + local desiredRange = weaponRange * COMBAT_ENGAGE_RANGE_RATIO + local targetMoveX = targetX + deltaX / distance * desiredRange + local targetMoveZ = targetZ + deltaZ / distance * desiredRange + if targetMoveX < 0 or targetMoveX > MAP_SIZE_X or targetMoveZ < 0 or targetMoveZ > MAP_SIZE_Z then + zombieData.combatTargetID = nil + zombieData.lastCombatTargetX = nil + zombieData.lastCombatTargetZ = nil + clearUnitOrders(unitID) + local fallbackObjective = ensureMovementObjective(unitID, zombieData, getActiveZombieAggro(unitID)) + issueObjectiveMove(unitID, unitDefID, zombieData, fallbackObjective) + return + end + local targetMoveY = spGetGroundHeight(targetMoveX, targetMoveZ) + local isTargetMoveValid = isMoveTargetTraversable(unitDefID, targetMoveX, targetMoveY, targetMoveZ) + if not isTargetMoveValid then + zombieData.combatTargetID = nil + zombieData.lastCombatTargetX = nil + zombieData.lastCombatTargetZ = nil + clearUnitOrders(unitID) + local fallbackObjective = ensureMovementObjective(unitID, zombieData, getActiveZombieAggro(unitID)) + issueObjectiveMove(unitID, unitDefID, zombieData, fallbackObjective) + return + end + local movementCommand = getMovementCommand(unitDefID) + spGiveOrderToUnit(unitID, movementCommand, { targetMoveX, targetMoveY, targetMoveZ }, 0) + local radialX = targetMoveX - targetX -- queue a 45° orbit around the target so they don't halt at engage range + local radialZ = targetMoveZ - targetZ + local rotationDirection = random() < 0.5 and -1 or 1 + local issuedSecondaryMove = false + for attemptIndex = 1, 2 do + local signedSin = COMBAT_SECONDARY_ANGLE_SIN * rotationDirection + local secondaryTargetX = + targetX + radialX * COMBAT_SECONDARY_ANGLE_COS - radialZ * signedSin + local secondaryTargetZ = + targetZ + radialX * signedSin + radialZ * COMBAT_SECONDARY_ANGLE_COS + if + secondaryTargetX >= 0 + and secondaryTargetX <= MAP_SIZE_X + and secondaryTargetZ >= 0 + and secondaryTargetZ <= MAP_SIZE_Z + then + local secondaryTargetY = spGetGroundHeight(secondaryTargetX, secondaryTargetZ) + if isMoveTargetTraversable(unitDefID, secondaryTargetX, secondaryTargetY, secondaryTargetZ) then + spGiveOrderToUnit( + unitID, + movementCommand, + { secondaryTargetX, secondaryTargetY, secondaryTargetZ }, + CMD_OPT_SHIFT + ) + issuedSecondaryMove = true + break + end + end + rotationDirection = -rotationDirection + end + if not issuedSecondaryMove then + local secondaryTargetX = targetX + radialX * COMBAT_ENGAGE_RANGE_RATIO + local secondaryTargetZ = targetZ + radialZ * COMBAT_ENGAGE_RANGE_RATIO + local secondaryTargetY = spGetGroundHeight(secondaryTargetX, secondaryTargetZ) + if isMoveTargetTraversable(unitDefID, secondaryTargetX, secondaryTargetY, secondaryTargetZ) then + spGiveOrderToUnit( + unitID, + movementCommand, + { secondaryTargetX, secondaryTargetY, secondaryTargetZ }, + CMD_OPT_SHIFT + ) + end + end + zombieData.lastCombatTargetX = targetX + zombieData.lastCombatTargetZ = targetZ +end + +local function updateOrders(unitID, unitDefID) + local zombieData = zombieWatch[unitID] + if mobileUnitDefs[unitDefID] then + local previousCombatTargetID = zombieData.combatTargetID + local currentCommand = spGetUnitCurrentCommand(unitID) + local movementCommand = getMovementCommand(unitDefID) + if + currentCommand == CMD_CAPTURE -- capture needs LOS, so drop the order if Gaia loses sight + and previousCombatTargetID + and not isUnitInGaiaLos(previousCombatTargetID) + then + zombieData.combatTargetID = nil + clearUnitOrders(unitID) + end + local targetX, targetZ, shouldCapture, weaponRange = + getCombatTargetData(unitDefID, zombieData.combatTargetID) + if not targetX then + zombieData.combatTargetID = nil + end + if not zombieData.combatTargetID and (capturingUnits[unitDefID] or unitDefWeaponRanges[unitDefID]) then + local closestKnownEnemy + closestKnownEnemy, targetX, targetZ, shouldCapture, weaponRange = + getNearestCombatTarget(unitID, unitDefID) + if targetX then + zombieData.combatTargetID = closestKnownEnemy + rememberEnemyDirection(unitID, zombieData, targetX, targetZ) + end + end + + if zombieData.combatTargetID then + if shouldCapture then + if currentCommand ~= CMD_CAPTURE or previousCombatTargetID ~= zombieData.combatTargetID then + zombieData.lastCombatTargetX = nil + zombieData.lastCombatTargetZ = nil + spGiveOrderToUnit(unitID, CMD_CAPTURE, { zombieData.combatTargetID }, 0) + end + else + local combatTargetMoved = not zombieData.lastCombatTargetX + or distance2dSquared( + targetX, + targetZ, + zombieData.lastCombatTargetX, + zombieData.lastCombatTargetZ + ) + >= COMBAT_TARGET_MOVE_REFRESH_DISTANCE_SQUARED + if + currentCommand ~= movementCommand + or previousCombatTargetID ~= zombieData.combatTargetID + or combatTargetMoved + then + issueCombatMove(unitID, unitDefID, weaponRange, targetX, targetZ, zombieData) + end + end + else + zombieData.lastCombatTargetX = nil + zombieData.lastCombatTargetZ = nil + local objective, objectiveChanged = ensureMovementObjective( + unitID, + zombieData, + getActiveZombieAggro(unitID) + ) + if + previousCombatTargetID + or objectiveChanged + or currentCommand ~= movementCommand + then + issueObjectiveMove(unitID, unitDefID, zombieData, objective) + end + end + end + + if factoriesWithCombatOptions[unitDefID] then + local factoryCommandCount = spGetFactoryCommandCount(unitID) or 0 + if factoryCommandCount < ZOMBIE_FACTORY_BUILD_COUNT then + issueRandomFactoryBuildOrders( + unitID, + unitDefID, + ZOMBIE_FACTORY_BUILD_COUNT - factoryCommandCount + ) + end + end +end + +local function setZombieStates(unitID, unitDefID) + if factoriesWithCombatOptions[unitDefID] then + spGiveOrderToUnit(unitID, CMD_REPEAT, ENABLE_REPEAT, 0) + end + spGiveOrderToUnit(unitID, CMD_MOVE_STATE, MOVE_STATE_ROAM, 0) + if aircraftUnitDefs[unitDefID] then + spGiveOrderToUnit(unitID, CMD_IDLEMODE, IDLEMODE_FLY, 0) + end + if not isPacified then + spGiveOrderToUnit(unitID, CMD_FIRE_STATE, FIRE_STATE_FIRE_AT_ALL, 0) + else + spGiveOrderToUnit(unitID, CMD_FIRE_STATE, FIRE_STATE_RETURN_FIRE, 0) + end + spring.SetUnitRulesParam(unitID, "resurrected", 0, { inlos = true }) +end + +local function initializeZombie(unitID, unitDefID) + if zombieWatch[unitID] or (scavTeamID and spring.GetUnitTeam(unitID) == scavTeamID) then + return + end + local unitX, _, unitZ = spGetUnitPosition(unitID) + if not unitX then + return + end + local unitPower = UnitDefs[unitDefID].power or 0 + zombieWatch[unitID] = { + unitDefID = unitDefID, + lastX = unitX, + lastZ = unitZ, + noGoZones = {}, + power = unitPower, + } + local zombieData = zombieWatch[unitID] + local orderBucket = zombieOrderBuckets[unitID % ZOMBIE_ORDER_CHECK_INTERVAL + 1] + zombieData.orderBucketIndex = #orderBucket + 1 + orderBucket[zombieData.orderBucketIndex] = unitID + local stuckBucket = zombieStuckBuckets[unitID % STUCK_CHECK_INTERVAL + 1] + zombieData.stuckBucketIndex = #stuckBucket + 1 + stuckBucket[zombieData.stuckBucketIndex] = unitID + if mobileUnitDefs[unitDefID] then + setRandomEdgeObjective(zombieData) + totalMobileZombiePower = totalMobileZombiePower + unitPower + end + setZombieStates(unitID, unitDefID) + if ordersEnabled then + updateOrders(unitID, unitDefID) + end +end + +local function clearAllOrders() + for zombieID in pairs(zombieWatch) do + clearUnitOrders(zombieID) + end +end + +local function pacifyZombies(enabled) + isPacified = enabled + ordersEnabled = not isPacified and not autoOrdersSuspended + if isPacified then + clearAllOrders() + end + local fireState = isPacified and FIRE_STATE_RETURN_FIRE or FIRE_STATE_FIRE_AT_ALL + for zombieID in pairs(zombieWatch) do + if spValidUnitID(zombieID) then + spGiveOrderToUnit(zombieID, CMD_FIRE_STATE, fireState) + end + end +end + +local function hasGameEndExplosionStarted() -- last living ally means the end-game explosion; stop attack orders + if not GG.maxDeathFrame then + return false + end + local livingAllyTeams = 0 + local allyTeamList = spring.GetAllyTeamList() + for allyIndex = 1, #allyTeamList do + local teamList = spring.GetTeamList(allyTeamList[allyIndex]) + local allyTeamIsAlive = false + for teamIndex = 1, #teamList do + local teamID = teamList[teamIndex] + if teamID ~= gaiaTeamID then + local teamLuaAI = spring.GetTeamLuaAI(teamID) + if not (teamLuaAI and (string.find(teamLuaAI, "Scavengers", 1, true) or string.find(teamLuaAI, "Raptors", 1, true))) then + local _, _, isDead = spring.GetTeamInfo(teamID) + if not isDead then + allyTeamIsAlive = true + break + end + end + end + end + if allyTeamIsAlive then + livingAllyTeams = livingAllyTeams + 1 + if livingAllyTeams > 1 then + return false + end + end + end + return true +end + +local function suspendAutoOrders(enabled) + autoOrdersSuspended = enabled + ordersEnabled = not isPacified and not autoOrdersSuspended + if autoOrdersSuspended then + clearAllOrders() + end +end + +local function aggroAllZombiesToAllyTeam(allyTeamID) + local markedAny = false + for zombieID in pairs(zombieWatch) do + local zombieData = zombieWatch[zombieID] + if spValidUnitID(zombieID) and mobileUnitDefs[zombieData.unitDefID] then + clearUnitOrders(zombieID) + zombieAggros[zombieID] = allyTeamID + markedAny = true + else + zombieAggros[zombieID] = nil + if zombieData.objective and zombieData.objective.type == OBJECTIVE_TYPE_AGGRO then + zombieData.objective = nil + end + end + end + + if markedAny then + setAggroExpiration() + end + return markedAny +end + +local function aggroTeamID(teamID) + local _, _, isDead, _, _, allyTeamID = spring.GetTeamInfo(teamID) + if isDead ~= false or not allyTeamID then + return false + end + return aggroAllZombiesToAllyTeam(allyTeamID) +end + +local function aggroAllyID(allyTeamID) + local allyTeams = spring.GetTeamList(allyTeamID) + if not allyTeams or #allyTeams == 0 then + return false + end + return aggroAllZombiesToAllyTeam(allyTeamID) +end + +local function killAllZombies() + for zombieID in pairs(zombieWatch) do + if spValidUnitID(zombieID) and not spGetUnitIsDead(zombieID) then + local currentHealth = spGetUnitHealth(zombieID) + if currentHealth and currentHealth > 0 then + spring.AddUnitDamage(zombieID, currentHealth, 0, NULL_ATTACKER, ENVIRONMENTAL_DAMAGE_ID) + end + end + end +end + +local function updateAggro() + if gameFrame % AGGRO_CHECK_INTERVAL ~= 1 or gameFrame < AGGRO_MIN_START_FRAME then + return + end + local totalPlayerPower = GG.PowerLib.TotalPlayerTeamsPower() + local powerCheckSucceeded = totalMobileZombiePower > totalPlayerPower * AGGRO_ZOMBIE_TO_PLAYER_POWER_RATIO + if powerCheckSucceeded then + assignZombieAggroEvenly() + setAggroExpiration() + else + zombieAggros = {} + aggroExpirationTimestamp = 0 + end +end + +local function updateZombieOrders() + local orderBucket = zombieOrderBuckets[gameFrame % ZOMBIE_ORDER_CHECK_INTERVAL + 1] + local bucketIndex = 1 + while bucketIndex <= #orderBucket do + local unitID = orderBucket[bucketIndex] + local zombieData = zombieWatch[unitID] + local unitDefID = zombieData.unitDefID + if not spValidUnitID(unitID) or spGetUnitIsDead(unitID) then + unwatchZombie(unitID) + else + updateOrders(unitID, unitDefID) + bucketIndex = bucketIndex + 1 + end + end +end + +local function updateStuckZombies() + local stuckBucket = zombieStuckBuckets[gameFrame % STUCK_CHECK_INTERVAL + 1] + local bucketIndex = 1 + while bucketIndex <= #stuckBucket do + local unitID = stuckBucket[bucketIndex] + local zombieData = zombieWatch[unitID] + if not spValidUnitID(unitID) or spGetUnitIsDead(unitID) then + unwatchZombie(unitID) + else + local unitX, _, unitZ = spGetUnitPosition(unitID) + if unitX then + local unitDefID = zombieData.unitDefID + local objective = zombieData.objective + local movedDistanceSquared = distance2dSquared(unitX, unitZ, zombieData.lastX, zombieData.lastZ) + local isAtRememberedObjective = zombieData.rememberedObjectiveX + and objective + and objective.type == OBJECTIVE_TYPE_NORMAL + and objective.x == zombieData.rememberedObjectiveX + and objective.z == zombieData.rememberedObjectiveZ + and isObjectiveReached(unitID, objective, unitX, unitZ) + if + mobileUnitDefs[unitDefID] -- if they haven't moved, blacklist this spot and reroute; keep a remembered-enemy goal + and not isAtRememberedObjective + and movedDistanceSquared < STUCK_DISTANCE_SQUARED + then + clearUnitOrders(unitID) + zombieData.combatTargetID = nil + zombieData.lastCombatTargetX = nil + zombieData.lastCombatTargetZ = nil + if + objective + and (objective.type == OBJECTIVE_TYPE_AGGRO or not zombieData.rememberedObjectiveX) + then + zombieData.objective = nil + end + if not isInNoGoZone(zombieData, unitX, unitZ) then + if #zombieData.noGoZones >= MAX_NOGO_ZONES then + table.remove(zombieData.noGoZones, 1) + end + table.insert(zombieData.noGoZones, { x = unitX, z = unitZ }) + end + local recoveryObjective = ensureMovementObjective(unitID, zombieData, getActiveZombieAggro(unitID)) + issueObjectiveMove(unitID, unitDefID, zombieData, recoveryObjective) + end + zombieData.lastX = unitX + zombieData.lastZ = unitZ + end + bucketIndex = bucketIndex + 1 + end + end +end + +function gadget:Initialize() + gameFrame = spring.GetGameFrame() + for _, unitID in ipairs(spring.GetAllUnits()) do + local unitTeam = spring.GetUnitTeam(unitID) + local allyTeamID = select(6, spring.GetTeamInfo(unitTeam)) + addAllyTeamUnit(unitID, allyTeamID) + if unitTeam == gaiaTeamID and isZombie(unitID) then + initializeZombie(unitID, spGetUnitDefID(unitID)) + end + end + + GG.ZombieAI = { + InitializeZombie = initializeZombie, + PacifyZombies = pacifyZombies, + SuspendAutoOrders = suspendAutoOrders, + AggroTeamID = aggroTeamID, + AggroAllyID = aggroAllyID, + KillAllZombies = killAllZombies, + ClearAllOrders = clearAllOrders, + } +end + +function gadget:Shutdown() + GG.ZombieAI = nil +end + +function gadget:GameFrame(frame) + gameFrame = frame + if not isPacified and hasGameEndExplosionStarted() then + pacifyZombies(true) + end + updateAggro() + if ordersEnabled then + updateZombieOrders() + updateStuckZombies() + end +end + +function gadget:UnitCreated(unitID, unitDefID, unitTeam) + local allyTeamID = select(6, spring.GetTeamInfo(unitTeam)) + addAllyTeamUnit(unitID, allyTeamID) +end + +function gadget:UnitFinished(unitID, unitDefID, unitTeam) + if unitTeam == gaiaTeamID and isZombie(unitID) then + initializeZombie(unitID, unitDefID) + end +end + +function gadget:UnitDestroyed(unitID) + flyingUnits[unitID] = nil + unwatchZombie(unitID) + removeAllyTeamUnit(unitID) +end + +function gadget:UnitGiven(unitID, unitDefID, newTeam) + removeAllyTeamUnit(unitID) + if not spValidUnitID(unitID) or spGetUnitIsDead(unitID) then + return + end + local newAllyTeamID = select(6, spring.GetTeamInfo(newTeam)) + addAllyTeamUnit(unitID, newAllyTeamID) + if newTeam == gaiaTeamID and isZombie(unitID) then + initializeZombie(unitID, unitDefID) + else + unwatchZombie(unitID) + end +end + +function gadget:UnitEnteredAir(unitID) + flyingUnits[unitID] = true +end + +function gadget:UnitLeftAir(unitID) + flyingUnits[unitID] = nil +end diff --git a/luarules/gadgets/api_teamstats.lua b/luarules/gadgets/api_teamstats.lua new file mode 100644 index 00000000000..9f52116a515 --- /dev/null +++ b/luarules/gadgets/api_teamstats.lua @@ -0,0 +1,695 @@ +local gadget = gadget ---@type Gadget + +function gadget:GetInfo() + return { + name = "Team stats", + desc = "Per-team economy, build power, conversion, unit composition, value traded and milestones: live on request, sampled into a history", + author = "Floris", + date = "September 2026", + license = "GNU GPL, v2 or later", + layer = 0, + enabled = true, + } +end + +-- Unsynced on purpose. The unsynced half of LuaRules gets every unit event and reads +-- every team, so the tally, the history and the milestones are built here from the +-- same events the simulation runs on: the same on every client and in every replay, +-- without a byte through the synced state, the network or the replay file. LuaUI cannot +-- call into a gadget (its Script table reaches LuaUI alone), so the gadget serves it the +-- other way round: while a widget keeps a receiver registered it is handed the live +-- values every second, and a request for history is answered with the backlog. A widget +-- that is reloaded, or a player who resigned and may now see the other side, gets what +-- it may see within a second. Nothing is handed over while no widget asks. +-- +-- Should a synced consumer ever need the numbers (an awards gadget computing in synced, +-- say), the tally takes only callin arguments and reads the same engine calls exist +-- synced, so it can run there as it is; the awards gadget could as well move its +-- accounting to this side, since its results only ever go to LuaUI. +if gadgetHandler:IsSyncedCode() then + return +end + +---------------------------------------------------------------- +-- Configuration +---------------------------------------------------------------- + +-- Frames between two history samples: the engine's own team statistics period, so the +-- two histories line up sample for sample. Between samples the live values are read on +-- request and never stored. +local SAMPLE_PERIOD = 450 + +-- Frames between two hand-overs to LuaUI while a widget is listening: the responsive +-- number between two samples, read fresh each time and never stored. +local LIVE_PERIOD = 30 + +-- Energy counts at a sixtieth of metal in a unit's value, the game's usual exchange. +local ENERGY_PER_METAL = 60 + +-- What a unit counts as, from its def. Builders come before the rest so a constructor +-- with a gun is a builder; strategic and economy come before the medium split so a +-- floating nuke silo is strategic, not sea. +local BUCKETS = { "army", "air", "sea", "defense", "strategic", "factories", "builders", "economy", "utility" } + +-- The moments worth remembering, and what marks them. `built` is tested on every unit +-- a team finishes and `lost` on every unit it loses; a milestone is kept once per team, +-- or every time when `every` is set. The unit that reached it is stored with it. +local MILESTONES = { + { + key = "factory", + built = function(ud) + return ud.isFactory + end, + }, + { + key = "tech2", + built = function(ud) + return (tonumber(ud.customParams.techlevel) or 1) == 2 + end, + }, + { + key = "tech3", + built = function(ud) + return (tonumber(ud.customParams.techlevel) or 1) >= 3 + end, + }, + { + key = "nuke", + built = function(ud) + return ud.customParams.unitgroup == "nuke" + end, + }, + { + key = "antinuke", + built = function(ud) + return ud.customParams.unitgroup == "antinuke" + end, + }, + { + key = "lrpc", + built = function(ud) + return ud.customParams.islrpc ~= nil + end, + }, + { + key = "commanderLost", + lost = function(ud) + return ud.customParams.iscommander ~= nil + end, + every = true, + }, + { key = "teamDied" }, +} + +---------------------------------------------------------------- +-- What a sample holds +---------------------------------------------------------------- + +-- Every value a sample carries, in one fixed order. A live request returns the same +-- keys, read at that moment. +local SAMPLED = { + "metalIncome", + "metalExpense", + "metalCurrent", + "metalStorage", + "energyIncome", + "energyExpense", + "energyCurrent", + "energyStorage", + "convCapacity", + "convUse", + "buildPower", + "buildPowerActive", + "unitCount", + "unitValue", + "killedValue", + "killedArmyValue", + "killedEcoValue", + "lostValue", + "teamKillValue", + "comKills", + "comLost", +} +local countKey, valueKey = {}, {} +for i = 1, #BUCKETS do + local bucket = BUCKETS[i] + local cap = bucket:sub(1, 1):upper() .. bucket:sub(2) + countKey[bucket] = "count" .. cap + valueKey[bucket] = "value" .. cap + SAMPLED[#SAMPLED + 1] = countKey[bucket] + SAMPLED[#SAMPLED + 1] = valueKey[bucket] +end + +-- The keys the tally keeps as running counters; the rest of a sample is read live. +local TALLIED = { + "unitCount", + "unitValue", + "buildPower", + "killedValue", + "killedArmyValue", + "killedEcoValue", + "lostValue", + "teamKillValue", + "comKills", + "comLost", +} +for i = 1, #BUCKETS do + TALLIED[#TALLIED + 1] = countKey[BUCKETS[i]] + TALLIED[#TALLIED + 1] = valueKey[BUCKETS[i]] +end + +---------------------------------------------------------------- +-- Unit defs +---------------------------------------------------------------- + +local spGetTeamResources = Spring.GetTeamResources +local spGetTeamRulesParam = Spring.GetTeamRulesParam +local spGetUnitCurrentBuildPower = Spring.GetUnitCurrentBuildPower +local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt +local spGetGameFrame = Spring.GetGameFrame +local spGetMyAllyTeamID = Spring.GetMyAllyTeamID +local spGetSpectatingState = Spring.GetSpectatingState + +---@type table +local defCost = {} +---@type table +local defBucket = {} +---@type table +local defCountKey = {} +---@type table +local defValueKey = {} +---@type table +local defBuildSpeed = {} +---@type table +local defIsCommander = {} +---@type table +local defBuiltMilestones = {} +---@type table +local defLostMilestones = {} +---@type table +local killedAs = { + army = "killedArmyValue", + air = "killedArmyValue", + sea = "killedArmyValue", + defense = "killedArmyValue", + strategic = "killedArmyValue", + economy = "killedEcoValue", +} + +local ARMED_GROUPS = + { weapon = true, aa = true, sub = true, emp = true, explo = true, weaponaa = true, weaponsub = true } +---@type table +local SEA_CLASSES = { BOAT = true, UBOAT = true, EPICSHIP = true } + +local function bucketOf(ud) + local cp = ud.customParams + local group = cp.unitgroup or "" + if ud.isFactory then + return "factories" + end + if ud.isBuilder then + return "builders" + end + if group == "nuke" or group == "antinuke" or cp.islrpc then + return "strategic" + end + if group == "energy" or group == "metal" then + return "economy" + end + if group == "util" then + return "utility" + end + local armed = #ud.weapons > 0 or ARMED_GROUPS[group] + if ud.speed == 0 then + return armed and "defense" or "utility" + end + if ud.canFly then + return "air" + end + local moveClass = ud.moveDef and ud.moveDef.name or "" + if SEA_CLASSES[moveClass:match("^%u+") or ""] then + return "sea" + end + return armed and "army" or "utility" +end + +for unitDefID, ud in pairs(UnitDefs) do + defCost[unitDefID] = ud.metalCost + ud.energyCost / ENERGY_PER_METAL + local bucket = bucketOf(ud) + defBucket[unitDefID] = bucket + defCountKey[unitDefID] = countKey[bucket] + defValueKey[unitDefID] = valueKey[bucket] + if ud.buildSpeed > 0 and (ud.isBuilder or ud.isFactory) then + defBuildSpeed[unitDefID] = ud.buildSpeed + end + defIsCommander[unitDefID] = ud.customParams.iscommander ~= nil + for i = 1, #MILESTONES do + local m = MILESTONES[i] + if m.built and m.built(ud) then + local list = defBuiltMilestones[unitDefID] or {} + list[#list + 1] = m + defBuiltMilestones[unitDefID] = list + end + if m.lost and m.lost(ud) then + local list = defLostMilestones[unitDefID] or {} + list[#list + 1] = m + defLostMilestones[unitDefID] = list + end + end +end + +---------------------------------------------------------------- +-- State +---------------------------------------------------------------- + +-- [teamID] = the running counters, one per TALLIED key. +---@type table?> +local teams = {} +---@type table +local allyOf = {} +-- [unitID] = the team a finished unit is counted for; a unit under construction is not +-- in here and counts for nothing until it is done. +---@type table +local finished = {} +-- [teamID][unitID] = build speed, for every finished builder and factory. +---@type table> +local builders = {} +-- [teamID] = { frames = { ... }, values = { [key] = { ... } } }, one entry per sample. +---@type table }> +local history = {} +-- [teamID] = { { key, frame, unitDefID, unitID }, ... } in the order they were reached. +---@type table +local milestones = {} +local reached = {} +local dead = {} +local gameOver = false + +local function newTally() + local t = {} + for i = 1, #TALLIED do + t[TALLIED[i]] = 0 + end + return t +end + +local function newHistory() + local h = { frames = {}, values = {} } + for i = 1, #SAMPLED do + h.values[SAMPLED[i]] = {} + end + return h +end + +---------------------------------------------------------------- +-- The tally +---------------------------------------------------------------- + +local function addUnit(unitID, unitDefID, teamID) + local t = teams[teamID] + if not t or finished[unitID] then + return + end + finished[unitID] = teamID + local cost = defCost[unitDefID] + t.unitCount = t.unitCount + 1 + t.unitValue = t.unitValue + cost + local ck, vk = defCountKey[unitDefID], defValueKey[unitDefID] + t[ck] = t[ck] + 1 + t[vk] = t[vk] + cost + local buildSpeed = defBuildSpeed[unitDefID] + if buildSpeed then + builders[teamID][unitID] = buildSpeed + t.buildPower = t.buildPower + buildSpeed + end +end + +local function removeUnit(unitID, unitDefID) + local teamID = finished[unitID] + if not teamID then + return + end + finished[unitID] = nil + local t = teams[teamID] + ---@cast t -? + local cost = defCost[unitDefID] + t.unitCount = t.unitCount - 1 + t.unitValue = t.unitValue - cost + local ck, vk = defCountKey[unitDefID], defValueKey[unitDefID] + t[ck] = t[ck] - 1 + t[vk] = t[vk] - cost + local buildSpeed = builders[teamID][unitID] + if buildSpeed then + builders[teamID][unitID] = nil + t.buildPower = t.buildPower - buildSpeed + end +end + +local function markMilestone(teamID, m, unitDefID, unitID) + if not m.every then + if reached[teamID][m.key] then + return + end + reached[teamID][m.key] = true + end + local list = milestones[teamID] + list[#list + 1] = { key = m.key, frame = spGetGameFrame(), unitDefID = unitDefID, unitID = unitID } +end + +---------------------------------------------------------------- +-- Reading the live values +---------------------------------------------------------------- + +-- Fills `out` with every SAMPLED key for the team, as things stand right now: the +-- counters from the tally, the economy and the conversion from the engine, and the +-- build power in use from every builder's nano activity. +local function readLive(teamID, out) + local t = teams[teamID] + ---@cast t -? + local cur, storage, _, income, expense = spGetTeamResources(teamID, "metal") + out.metalIncome = income or 0 + out.metalExpense = expense or 0 + out.metalCurrent = cur or 0 + out.metalStorage = storage or 0 + cur, storage, _, income, expense = spGetTeamResources(teamID, "energy") + out.energyIncome = income or 0 + out.energyExpense = expense or 0 + out.energyCurrent = cur or 0 + out.energyStorage = storage or 0 + out.convCapacity = spGetTeamRulesParam(teamID, "mmCapacity") or 0 + out.convUse = spGetTeamRulesParam(teamID, "mmUse") or 0 + ---@type number + local active = 0 + for unitID, buildSpeed in pairs(builders[teamID]) do + active = active + buildSpeed * (spGetUnitCurrentBuildPower(unitID) or 0) + end + out.buildPowerActive = active + for i = 1, #TALLIED do + local key = TALLIED[i] + out[key] = t[key] + end + return out +end + +local scratch = {} + +local function sample(frame) + for teamID, h in pairs(history) do + if not dead[teamID] then + readLive(teamID, scratch) + local n = #h.frames + 1 + h.frames[n] = frame + local values = h.values + for i = 1, #SAMPLED do + local key = SAMPLED[i] + values[key][n] = scratch[key] + end + end + end +end + +---------------------------------------------------------------- +-- Callins +---------------------------------------------------------------- + +local function unitCreated(unitID, unitDefID, unitTeam) + if not spGetUnitIsBeingBuilt(unitID) then + addUnit(unitID, unitDefID, unitTeam) + end +end + +function gadget:UnitCreated(unitID, unitDefID, unitTeam) + unitCreated(unitID, unitDefID, unitTeam) +end + +function gadget:UnitFinished(unitID, unitDefID, unitTeam) + addUnit(unitID, unitDefID, unitTeam) + local list = defBuiltMilestones[unitDefID] + if list and teams[unitTeam] then + for i = 1, #list do + markMilestone(unitTeam, list[i], unitDefID, unitID) + end + end +end + +function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) + if finished[unitID] then + removeUnit(unitID, unitDefID) + addUnit(unitID, unitDefID, newTeam) + end +end + +function gadget:UnitDestroyed(unitID, unitDefID, unitTeam, attackerID, attackerDefID, attackerTeam) + local value = defCost[unitDefID] + if not finished[unitID] then + -- A unit still under construction is worth what was put into it. + local _, progress = spGetUnitIsBeingBuilt(unitID) + value = value * (progress or 0) + end + removeUnit(unitID, unitDefID) + + local victim = teams[unitTeam] + if victim then + victim.lostValue = victim.lostValue + value + if defIsCommander[unitDefID] then + victim.comLost = victim.comLost + 1 + end + local list = defLostMilestones[unitDefID] + if list then + for i = 1, #list do + markMilestone(unitTeam, list[i], unitDefID, unitID) + end + end + end + + local killer = attackerTeam and teams[attackerTeam] + if not killer or attackerTeam == unitTeam or value == 0 then + return + end + if allyOf[attackerTeam] == allyOf[unitTeam] then + killer.teamKillValue = killer.teamKillValue + value + return + end + killer.killedValue = killer.killedValue + value + local split = killedAs[defBucket[unitDefID]] + if split then + killer[split] = killer[split] + value + end + if defIsCommander[unitDefID] then + killer.comKills = killer.comKills + 1 + end +end + +function gadget:TeamDied(teamID) + if not teams[teamID] or dead[teamID] then + return + end + dead[teamID] = true + for i = 1, #MILESTONES do + if MILESTONES[i].key == "teamDied" then + markMilestone(teamID, MILESTONES[i]) + end + end +end + +-- The last sample is the state at the end: what happens in the minutes after is not +-- the game. +function gadget:GameOver() + if gameOver then + return + end + gameOver = true + sample(spGetGameFrame()) +end + +---------------------------------------------------------------- +-- What LuaUI may see +---------------------------------------------------------------- + +-- The same rule the engine applies to its own team statistics: a team's numbers are for +-- its allies, for a spectator watching everything, and for everyone once the game is +-- over. A spectator watching one side sees that side. +local function visible(teamID) + if not teams[teamID] then + return false + end + if gameOver then + return true + end + local spec, fullView = spGetSpectatingState() + if spec and fullView then + return true + end + return allyOf[teamID] == spGetMyAllyTeamID() +end + +local function copyMilestones(teamID) + local out = {} + local list = milestones[teamID] + for i = 1, #list do + local m = list[i] + out[i] = { key = m.key, frame = m.frame, unitDefID = m.unitDefID, unitID = m.unitID } + end + return out +end + +local function liveOf(teamID) + local out = readLive(teamID, {}) + out.dead = dead[teamID] or false + out.milestones = copyMilestones(teamID) + return out +end + +-- The values as they stand right now, for one team or for every team the caller may +-- see, keyed by team. Read fresh on every call and never stored: it is the responsive +-- number between two samples. +local function GetTeamStatsLive(teamID) + if teamID ~= nil then + if not visible(teamID) then + return nil + end + return liveOf(teamID) + end + local out = {} + for id in pairs(teams) do + if visible(id) then + out[id] = liveOf(id) + end + end + return out +end + +-- The samples from `from` (1 by default) on: the frames they were taken at and each +-- value's run, so a caller with the first n only asks for what came after them. +local function GetTeamStatsHistory(teamID, from) + if not visible(teamID) then + return nil + end + from = math.max(1, from or 1) + local h = history[teamID] + local out = { period = SAMPLE_PERIOD, from = from, frames = {}, values = {} } + for n = from, #h.frames do + out.frames[n - from + 1] = h.frames[n] + end + for i = 1, #SAMPLED do + local key = SAMPLED[i] + local run, src = {}, h.values[key] + for n = from, #src do + run[n - from + 1] = src[n] + end + out.values[key] = run + end + return out +end + +local function GetTeamStatsMilestones(teamID) + if not visible(teamID) then + return nil + end + return copyMilestones(teamID) +end + +-- What the numbers mean: the sample period, the keys in their order, the buckets and +-- the milestone kinds, so a caller need not hardcode them. +local function GetTeamStatsInfo() + local kinds = {} + for i = 1, #MILESTONES do + kinds[i] = MILESTONES[i].key + end + local keys = {} + for i = 1, #SAMPLED do + keys[i] = SAMPLED[i] + end + local buckets = {} + for i = 1, #BUCKETS do + buckets[i] = BUCKETS[i] + end + return { + period = SAMPLE_PERIOD, + keys = keys, + buckets = buckets, + milestones = kinds, + energyPerMetal = ENERGY_PER_METAL, + } +end + +-- For other unsynced gadgets, as globals. +local exports = { + GetTeamStatsLive = GetTeamStatsLive, + GetTeamStatsHistory = GetTeamStatsHistory, + GetTeamStatsMilestones = GetTeamStatsMilestones, + GetTeamStatsInfo = GetTeamStatsInfo, +} + +---------------------------------------------------------------- +-- Serving LuaUI +---------------------------------------------------------------- + +-- The engine's cross-state calls: Script.LuaUI.(...) runs a global of the LuaUI +-- state, and Script.LuaUI("") says whether there is one to run. +---@diagnostic disable-next-line: undefined-global +local Script = Script + +-- A widget takes part by registering globals: `TeamStatsLive(all, frame)` is handed +-- the live values of every team the viewer may see, keyed by team, every LIVE_PERIOD +-- frames for as long as it is registered; `TeamStatsHistoryRequest()` returning +-- { [teamID] = fromIndex } is answered through `TeamStatsHistory(teamID, history)` +-- with the samples from that index on (see GetTeamStatsHistory for the shape), so a +-- caller holding the first n samples asks for what came after them. +local function serveLuaUI(frame) + if Script.LuaUI("TeamStatsLive") then + Script.LuaUI.TeamStatsLive(GetTeamStatsLive(), frame) + end + if Script.LuaUI("TeamStatsHistoryRequest") and Script.LuaUI("TeamStatsHistory") then + local wanted = Script.LuaUI.TeamStatsHistoryRequest() + if type(wanted) == "table" then + for teamID, from in pairs(wanted) do + local h = GetTeamStatsHistory(teamID, from) + if h then + Script.LuaUI.TeamStatsHistory(teamID, h) + end + end + end + end +end + +function gadget:GameFrame(frame) + if frame % SAMPLE_PERIOD == 0 and not gameOver then + sample(frame) + end + if frame % LIVE_PERIOD == 0 then + serveLuaUI(frame) + end +end + +function gadget:Initialize() + local gaia = Spring.GetGaiaTeamID() + local teamList = Spring.GetTeamList() + ---@cast teamList -? + for _, teamID in ipairs(teamList) do + if teamID ~= gaia then + teams[teamID] = newTally() + allyOf[teamID] = select(6, Spring.GetTeamInfo(teamID, false)) + builders[teamID] = {} + history[teamID] = newHistory() + milestones[teamID] = {} + reached[teamID] = {} + local _, _, isDead = Spring.GetTeamInfo(teamID, false) + dead[teamID] = isDead == true or nil + end + end + -- Units already standing, after a reload or when the game is joined late. + for _, unitID in ipairs(Spring.GetAllUnits()) do + local unitDefID = Spring.GetUnitDefID(unitID) + ---@cast unitDefID -? + unitCreated(unitID, unitDefID, Spring.GetUnitTeam(unitID)) + end + for name, fn in pairs(exports) do + gadgetHandler:RegisterGlobal(name, fn) + end +end + +function gadget:Shutdown() + for name in pairs(exports) do + gadgetHandler:DeregisterGlobal(name) + end +end diff --git a/luarules/gadgets/cmd_clone_tool.lua b/luarules/gadgets/cmd_clone_tool.lua index 68e12b51945..4da341b9324 100644 --- a/luarules/gadgets/cmd_clone_tool.lua +++ b/luarules/gadgets/cmd_clone_tool.lua @@ -11,12 +11,22 @@ function gadget:GetInfo() end if not gadgetHandler:IsSyncedCode() then - function gadget:RecvFromSynced(name, undoCount, redoCount) - if name == "CloneToolStacks" then - if Script.LuaUI("CloneToolStackUpdate") then - Script.LuaUI.CloneToolStackUpdate(undoCount, redoCount) - end + -- Registered as a sync action (table lookup by message name) instead of a + -- RecvFromSynced callin, which would be invoked for every SendToUnsynced + -- message from every synced gadget. Returning true stops the broadcast. + local function onStacks(_, undoCount, redoCount) + if Script.LuaUI("CloneToolStackUpdate") then + Script.LuaUI.CloneToolStackUpdate(undoCount, redoCount) end + return true + end + + function gadget:Initialize() + gadgetHandler:AddSyncAction("CloneToolStacks", onStacks) + end + + function gadget:Shutdown() + gadgetHandler:RemoveSyncAction("CloneToolStacks") end return end @@ -49,18 +59,13 @@ local spGetGroundHeight = Spring.GetGroundHeight local spSetHeightMapFunc = Spring.SetHeightMapFunc local spLevelHeightMap = Spring.LevelHeightMap local spSetMetalAmount = Spring.SetMetalAmount -local spGetMetalAmount = Spring.GetMetalAmount local spCreateFeature = Spring.CreateFeature local spDestroyFeature = Spring.DestroyFeature local spGetFeaturesInRectangle = Spring.GetFeaturesInRectangle -local spGetFeatureDefID = Spring.GetFeatureDefID -local spGetFeaturePosition = Spring.GetFeaturePosition -local spGetFeatureHeading = Spring.GetFeatureHeading local spEcho = Spring.Echo local SendToUnsynced = SendToUnsynced local min = math.min -local max = math.max local floor = math.floor local tonumber = tonumber @@ -71,7 +76,6 @@ local undoStack = {} local redoStack = {} local totalVertexCount = 0 local MAX_UNDO = 100 -local MAX_SNAPSHOT_VERTICES = 4000000 -- --------------------------------------------------------------------------- -- Height map application (same pattern as terraform brush) diff --git a/luarules/gadgets/cmd_dev_helpers.lua b/luarules/gadgets/cmd_dev_helpers.lua index b94d1dc42cc..0ba9bcd7718 100644 --- a/luarules/gadgets/cmd_dev_helpers.lua +++ b/luarules/gadgets/cmd_dev_helpers.lua @@ -543,8 +543,7 @@ if gadgetHandler:IsSyncedCode() then subPermission = "modmarker" end - local bypassSyncedAuthorization = cmd == "godmode" or cmd == "godmodeally" - if not bypassSyncedAuthorization and not isAuthorized(playerID, subPermission) then + if not isAuthorized(playerID, subPermission) then return end @@ -2131,7 +2130,6 @@ else -- UNSYNCED local mx, my = Spring.GetMouseState() local t, pos = Spring.TraceScreenRay(mx, my, true) if type(pos) == "table" then - local n = 0 local ox, oy, oz = math.floor(pos[1]), math.floor(pos[2] + height), math.floor(pos[3]) local x, y, z = ox, oy, oz local msg = "spawnceg " @@ -2808,6 +2806,9 @@ else -- UNSYNCED or (ud.metalMake or 0) > 0 or (cp and cp.unitgroup == "metal") end) + addFilter("rework", function(ud) + return string.find(string.lower(ud.name or ""), "_rework", 1, true) ~= nil + end) addFilter("all", function() return true end) @@ -2874,7 +2875,6 @@ else -- UNSYNCED local mx, my = Spring.GetMouseState() local t, pos = Spring.TraceScreenRay(mx, my, true) if type(pos) == "table" then - local n = 0 local ox, oz = math.floor(pos[1]), math.floor(pos[3]) local x, z = ox, oz diff --git a/luarules/gadgets/cmd_feature_placer.lua b/luarules/gadgets/cmd_feature_placer.lua index 4d4ae362b92..3de06471c60 100644 --- a/luarules/gadgets/cmd_feature_placer.lua +++ b/luarules/gadgets/cmd_feature_placer.lua @@ -11,23 +11,34 @@ function gadget:GetInfo() end if not gadgetHandler:IsSyncedCode() then - function gadget:RecvFromSynced(name, a, b) - if name == "FeaturePlacerHistory" then - if Script.LuaUI("terraform_feature_history") then - Script.LuaUI.terraform_feature_history(a, b) - end - elseif name == "feature_save_begin" then - if Script.LuaUI("terraform_feature_save_begin") then - Script.LuaUI.terraform_feature_save_begin(a) - end - elseif name == "feature_save_data" then - if Script.LuaUI("terraform_feature_save_data") then - Script.LuaUI.terraform_feature_save_data(a) - end - elseif name == "feature_save_end" then - if Script.LuaUI("terraform_feature_save_end") then - Script.LuaUI.terraform_feature_save_end(a) + -- Registered as sync actions (table lookup by message name) instead of a + -- RecvFromSynced callin, which would be invoked for every SendToUnsynced + -- message from every synced gadget. Returning true stops the broadcast. + local function forwardToLuaUI(luaUIName) + return function(_, a, b) + if Script.LuaUI(luaUIName) then + Script.LuaUI[luaUIName](a, b) end + return true + end + end + + local syncActions = { + FeaturePlacerHistory = forwardToLuaUI("terraform_feature_history"), + feature_save_begin = forwardToLuaUI("terraform_feature_save_begin"), + feature_save_data = forwardToLuaUI("terraform_feature_save_data"), + feature_save_end = forwardToLuaUI("terraform_feature_save_end"), + } + + function gadget:Initialize() + for name, func in pairs(syncActions) do + gadgetHandler:AddSyncAction(name, func) + end + end + + function gadget:Shutdown() + for name in pairs(syncActions) do + gadgetHandler:RemoveSyncAction(name) end end return @@ -86,6 +97,15 @@ local GetFeatureRotation = Spring.GetFeatureRotation local SetFeaturePosition = Spring.SetFeaturePosition local SetFeatureMoveCtrl = Spring.SetFeatureMoveCtrl local GetGameFrame = Spring.GetGameFrame +local GetFeatureRootPiece = Spring.GetFeatureRootPiece +local GetFeaturePieceMatrix = Spring.GetFeaturePieceMatrix +local SetFeaturePieceMatrix = Spring.SetFeaturePieceMatrix +local GetFeatureCollisionVolumeData = Spring.GetFeatureCollisionVolumeData +local SetFeatureCollisionVolumeData = Spring.SetFeatureCollisionVolumeData +local GetFeatureRadius = Spring.GetFeatureRadius +local GetFeatureHeight = Spring.GetFeatureHeight +local SetFeatureRadiusAndHeight = Spring.SetFeatureRadiusAndHeight +local SetFeatureMidAndAimPos = Spring.SetFeatureMidAndAimPos -- Same containment module the widget draws its brush outline from, so removal -- matches the shape the user sees. The copy that used to live here was @@ -100,6 +120,107 @@ local undoStack = {} local redoStack = {} local gaiaTeamID +-- Visual scale applied per feature, keyed by live featureID. Nothing engine-side +-- records it (see applyFeatureScale), so this table is the only authority for +-- capture/save. Entries exist only for features that were actually scaled. +local featureScales = {} + +---------------------------------------------------------------- +-- Per-feature scaling +---------------------------------------------------------------- +-- There is no way to scale a feature's model from Lua on current engines, so +-- placement-time scaling ships as pre-baked model variants and this path stays +-- dormant: Spring.SetFeaturePieceMatrix looks like the API for it, but +-- LocalModelPiece::SetPieceSpaceMatrix only validates the matrix and throws the +-- geometry away, leaving a piece's transform derived solely from its pos/rot/ +-- scale, which nothing outside a unit animation script can write. +-- +-- Kept because the rest of it is correct and cheap: were the matrix honoured, +-- visual scale alone would desync interaction, so the collision volume, the +-- selection/reclaim radius+height, and the mid/aim positions are scaled to +-- match. Footprint blocking stays def-side, which is fine for the 1x1 feature +-- defs this tool mostly places. +local SCALE_EPSILON = 0.001 +local SCALE_MIN = 0.05 +local SCALE_MAX = 10 + +local function applyFeatureScale(featureID, s) + if not s or abs(s - 1) < SCALE_EPSILON then + return + end + if not (SetFeaturePieceMatrix and GetFeatureRootPiece) then + return + end + -- Model-less defs (editor_geocrack and friends) never get a LocalModel, and + -- the piece callouts deref the empty piece list -- an access violation, the + -- same crash the Initialize comment below documents for map features. + local def = FeatureDefs[GetFeatureDefID(featureID) or -1] + if not def or (def.modelname or "") == "" then + return + end + s = max(SCALE_MIN, min(SCALE_MAX, s)) + + local root = GetFeatureRootPiece(featureID) or 1 + -- Compose onto the piece's current local matrix rather than assuming + -- identity. M * diag(s,s,s,1) scales the three basis columns -- the first + -- twelve floats of the engine's flat matrix -- and leaves translation alone. + local m = { GetFeaturePieceMatrix(featureID, root) } + if not m[16] then + m = { s, 0, 0, 0, 0, s, 0, 0, 0, 0, s, 0, 0, 0, 0, 1 } + else + for i = 1, 12 do + m[i] = m[i] * s + end + end + -- Engines to date cannot scale a feature at all: SetPieceSpaceMatrix only + -- validates the matrix with IsRotOrRotTranMatrix() and discards it, which + -- is why scaling ships as pre-baked model variants instead. Bail when the + -- call reports the matrix unusable, so collision and radius are not scaled + -- away from a model that stayed its original size. If a future engine + -- accepts the matrix, this path lights up as written. + if not SetFeaturePieceMatrix(featureID, root, m) then + return + end + + local vsx, vsy, vsz, vox, voy, voz, vtype, ttype, axis = GetFeatureCollisionVolumeData(featureID) + if vsx and SetFeatureCollisionVolumeData then + SetFeatureCollisionVolumeData( + featureID, + vsx * s, + vsy * s, + vsz * s, + vox * s, + voy * s, + voz * s, + vtype, + ttype, + axis + ) + end + + local r = GetFeatureRadius and GetFeatureRadius(featureID) + local h = GetFeatureHeight and GetFeatureHeight(featureID) + if r and h and SetFeatureRadiusAndHeight then + SetFeatureRadiusAndHeight(featureID, r * s, h * s) + end + + local bx, by, bz, mx, my, mz, ax, ay, az = GetFeaturePosition(featureID, true, true) + if mx and SetFeatureMidAndAimPos then + SetFeatureMidAndAimPos( + featureID, + (mx - bx) * s, + (my - by) * s, + (mz - bz) * s, + (ax - bx) * s, + (ay - by) * s, + (az - bz) * s, + true + ) + end + + featureScales[featureID] = s +end + ---------------------------------------------------------------- -- Wobble animation ---------------------------------------------------------------- @@ -198,9 +319,12 @@ local function applyTransform(featureID, t) end -- Wire format, entries separated by "|": --- defName x z heading [pitch roll y] --- The optional tail is omitted for the flat-on-ground case, which is almost --- every feature, keeping messages small. +-- defName x z heading (4 tokens, the common case) +-- defName x z heading scale (5) +-- defName x z heading pitch roll y (7) +-- defName x z heading pitch roll y scale (8) +-- Disambiguated by token count; each optional tail is omitted whenever it holds +-- the default, so an untouched map's wire traffic stays byte-identical. local function parsePlacement(entry) local parts = {} for word in entry:gmatch("%S+") do @@ -217,14 +341,30 @@ local function parsePlacement(entry) x = max(0, min(Game.mapSizeX, x)) z = max(0, min(Game.mapSizeZ, z)) + local n = #parts + local scale + if n == 5 then + scale = tonumber(parts[5]) + elseif n >= 8 then + scale = tonumber(parts[8]) + end + + local pitch, roll, y + if n >= 6 then + pitch = tonumber(parts[5]) or 0 + roll = tonumber(parts[6]) or 0 + y = tonumber(parts[7]) + end + return { defName = defName, x = x, z = z, heading = (tonumber(parts[4]) or 0) % 65536, - pitch = tonumber(parts[5]) or 0, - roll = tonumber(parts[6]) or 0, - y = tonumber(parts[7]) or GetGroundHeight(x, z), + pitch = pitch or 0, + roll = roll or 0, + y = y or GetGroundHeight(x, z), + scale = scale, } end @@ -247,6 +387,11 @@ local function createFromPlacement(p) SetFeatureRotation(id, p.pitch, yaw or 0, p.roll) end + -- After creation on purpose: FeatureCreated callins (the dynamic collision + -- volume gadget among them) fire inside CreateFeature, so scaling here + -- multiplies on top of whatever they set rather than being stomped by it. + applyFeatureScale(id, p.scale) + return id end @@ -388,6 +533,7 @@ local function captureFeature(featureID) heading = GetFeatureHeading(featureID) or 0, pitch = pitch or 0, roll = roll or 0, + scale = featureScales[featureID], resting = isRestingOrientation(featureID, def, x, z), } end @@ -613,9 +759,16 @@ local function exportAllFeatures() -- "Tilted" means tilted away from the engine's own resting alignment, -- not simply non-zero pitch: ground-aligned features on a slope have -- plenty of that without anyone having touched them. - if not snapshot.resting or abs(snapshot.y - GetGroundHeight(snapshot.x, snapshot.z)) > LIFT_EPSILON then + local tilted = not snapshot.resting + or abs(snapshot.y - GetGroundHeight(snapshot.x, snapshot.z)) > LIFT_EPSILON + if tilted then entry = entry .. string.format(" %.4f %.4f %.1f", snapshot.pitch, snapshot.roll, snapshot.y) end + -- Scale rides as the 5th token (no tilt) or 8th (tilt): the token + -- count is what tells the two tails apart on the other side. + if snapshot.scale then + entry = entry .. string.format(" %.3f", snapshot.scale) + end data[#data + 1] = entry end end @@ -778,10 +931,28 @@ end function gadget:Initialize() gaiaTeamID = GetGaiaTeamID() + + -- Deliberately NO walk over existing features here. Calling + -- GetFeatureRootPiece / GetFeaturePieceMatrix during LuaRules load CRASHES + -- the engine (access violation): map features' local piece models are only + -- instantiated when the drawer first touches them, and the piece callouts + -- deref an empty piece list before that. Runtime calls on freshly created + -- features are fine -- load-time calls on map features are not. The only + -- cost is that the (currently dormant) runtime-scale bookkeeping forgets + -- its entries across a /luarules reload; baked variant defs, the shipping + -- mechanism, carry their scale in the def and are unaffected. + -- Idle until the first wobble is queued (see addWobble). gadgetHandler:RemoveCallIn("GameFrame") end +-- Feature ids are recycled by the engine; without this a reclaimed or burned +-- scaled feature would leave its stale scale behind for whatever feature +-- inherits the id. +function gadget:FeatureDestroyed(featureID, allyTeamID) + featureScales[featureID] = nil +end + function gadget:GameFrame(frame) for fid, info in pairs(wobbleQueue) do local elapsed = frame - info.start diff --git a/luarules/gadgets/cmd_map_project_units.lua b/luarules/gadgets/cmd_map_project_units.lua index eae75b342e0..38de2775b44 100644 --- a/luarules/gadgets/cmd_map_project_units.lua +++ b/luarules/gadgets/cmd_map_project_units.lua @@ -19,23 +19,34 @@ end -- mid-swap (game-over logic watches for exactly that). if not gadgetHandler:IsSyncedCode() then - function gadget:RecvFromSynced(name, a) - if name == "mpunits_save_begin" then - if Script.LuaUI("mapproject_units_save_begin") then - Script.LuaUI.mapproject_units_save_begin(a) - end - elseif name == "mpunits_save_data" then - if Script.LuaUI("mapproject_units_save_data") then - Script.LuaUI.mapproject_units_save_data(a) - end - elseif name == "mpunits_save_end" then - if Script.LuaUI("mapproject_units_save_end") then - Script.LuaUI.mapproject_units_save_end(a) - end - elseif name == "mpunits_save_denied" then - if Script.LuaUI("mapproject_units_save_denied") then - Script.LuaUI.mapproject_units_save_denied(a) + -- Registered as sync actions (table lookup by message name) instead of a + -- RecvFromSynced callin, which would be invoked for every SendToUnsynced + -- message from every synced gadget. Returning true stops the broadcast. + local function forwardToLuaUI(luaUIName) + return function(_, a) + if Script.LuaUI(luaUIName) then + Script.LuaUI[luaUIName](a) end + return true + end + end + + local syncActions = { + mpunits_save_begin = forwardToLuaUI("mapproject_units_save_begin"), + mpunits_save_data = forwardToLuaUI("mapproject_units_save_data"), + mpunits_save_end = forwardToLuaUI("mapproject_units_save_end"), + mpunits_save_denied = forwardToLuaUI("mapproject_units_save_denied"), + } + + function gadget:Initialize() + for name, func in pairs(syncActions) do + gadgetHandler:AddSyncAction(name, func) + end + end + + function gadget:Shutdown() + for name in pairs(syncActions) do + gadgetHandler:RemoveSyncAction(name) end end return diff --git a/luarules/gadgets/cmd_metal_brush.lua b/luarules/gadgets/cmd_metal_brush.lua index a1bb23ff9b2..93b807f5d2e 100644 --- a/luarules/gadgets/cmd_metal_brush.lua +++ b/luarules/gadgets/cmd_metal_brush.lua @@ -45,9 +45,6 @@ local MAP_Z = Game.mapSizeZ local METAL_MAP_X = math.floor(MAP_X / METAL_SQ) local METAL_MAP_Z = math.floor(MAP_Z / METAL_SQ) --- Reference values from map_metal_spot_placer.lua for standard metal spots -local REF_METAL_BUDGET_PER_UNIT = 0.43 * 9 * 255 -- total raw metal budget per 1.0 extraction rate - local floor = math.floor local max = math.max local min = math.min diff --git a/luarules/gadgets/cmd_terraform_brush.lua b/luarules/gadgets/cmd_terraform_brush.lua index 8b9f291901f..026601ab096 100644 --- a/luarules/gadgets/cmd_terraform_brush.lua +++ b/luarules/gadgets/cmd_terraform_brush.lua @@ -13,12 +13,22 @@ function gadget:GetInfo() end if not gadgetHandler:IsSyncedCode() then - function gadget:RecvFromSynced(name, undoCount, redoCount) - if name == "TerraformBrushStacks" then - if Script.LuaUI("TerraformBrushStackUpdate") then - Script.LuaUI.TerraformBrushStackUpdate(undoCount, redoCount) - end + -- Registered as a sync action (table lookup by message name) instead of a + -- RecvFromSynced callin, which would be invoked for every SendToUnsynced + -- message from every synced gadget. Returning true stops the broadcast. + local function onStacks(_, undoCount, redoCount) + if Script.LuaUI("TerraformBrushStackUpdate") then + Script.LuaUI.TerraformBrushStackUpdate(undoCount, redoCount) end + return true + end + + function gadget:Initialize() + gadgetHandler:AddSyncAction("TerraformBrushStacks", onStacks) + end + + function gadget:Shutdown() + gadgetHandler:RemoveSyncAction("TerraformBrushStacks") end return end @@ -112,6 +122,9 @@ local ERODE_HEADER = "$terraform_erode$" local ERODE_HEADER_LENGTH = #ERODE_HEADER local WARM_HEADER = "$terraform_warm$" local WARM_HEADER_LENGTH = #WARM_HEADER +local REMAP_HEADER = "$terraform_remap$" +local REMAP_HEADER_LENGTH = #REMAP_HEADER +local AUTORAMP_HEADER = "$terraform_autoramp$" local HEIGHT_STEP = 8 local MAX_UNDO = 10000 -- Total vertex budget across all undo+redo entries. Each vertex = 3 array @@ -138,13 +151,13 @@ local importDoneCounter = 0 -- Active drag session: all pushSnapshot/pushSnapshotFromFlat calls merge into mergeSnapshot until -- MERGE_END is received (sent by widget on mouse release). No time window — MERGE_END is authoritative. local mergeSnapshot = nil -- the active snapshot being merged into; nil = no drag in progress -local mergeVertexSet = nil -- set of numeric keys already in mergeSnapshot local mergeSnapshotLen = 0 -- explicit length of mergeSnapshot (avoids # on growing tables) local currentStrokeId = 0 -- incremented on each STROKE_END; tags all entries in a stroke local lastUndoFrame = -1 -- throttle: only one undo per game frame local MAX_RADIUS = 2000 local MIN_RADIUS = 8 -local MAX_BLUR_STEP = 6 -- smooth mode: widest neighbor spacing (grid cells) at max intensity +local MAX_BLUR_STEP = 6 -- smooth mode: widest box half-width (grid cells) at max intensity +local MAX_SMUDGE_BUFFERS = 16 -- smudge mode: carried height grabs alive at once (one per stroke chain) -- ── Diagnostics ────────────────────────────────────────────────────────────── local DIAG = false -- set false to silence @@ -157,6 +170,9 @@ local scratchHeightData = {} local scratchHeightDataMax = 0 -- high-water mark for reliable trimming (avoids # on reused table) local scratchSnapFlat = {} -- flat buffer: x,z,h,x,z,h,... (no sub-table allocation) local scratchBlurHeights = {} -- padded (sw+2)x(sh+2) height grid for smooth-mode local blur +local scratchBlurSAT = {} -- summed-area table over scratchBlurHeights (dense box mean) +local smudgeBuffers = {} -- smudge mode: carried brush-space height grabs, one per active stroke chain +local smudgeClock = 0 -- smudge dab counter, used to retire chains whose stroke has ended local scratchParts = {} -- Parse a space-separated payload into scratchParts, reusing the table @@ -173,11 +189,6 @@ local function parseParts(payload) return scratchParts end --- Numeric key for merge vertex set: avoids per-vertex string allocation -local function vertexKey(x, z) - return x * 65536 + z -end - local floor = math.floor local max = math.max local min = math.min @@ -430,20 +441,17 @@ local function finalizeMerge() mergeSnapshot.vertexCount = mergeSnapshotLen / 3 end mergeSnapshot = nil - mergeVertexSet = nil mergeSnapshotLen = 0 end --- Hot-path: convert a flat {x,z,h,...} buffer to a bbox-grid snapshot and push. --- ONE ENTRY PER TICK is mandatory (see bar_stripy_terrain_bug.md). All snapshot --- callers route through this; pushSnapshot below flattens sub-tables first. -local function pushSnapshotFromFlat(flatBuf, vertexCount) - if vertexCount == 0 then - return - end - if vertexCount > MAX_SNAPSHOT_VERTICES then - return - end +-- Convert a flat {x,z,h,...} buffer to a bbox-grid snapshot and push it. +-- ONE ENTRY PER TICK is mandatory (see bar_stripy_terrain_bug.md): brush dabs +-- commit through flushBatch (one entry per STROKE message); the ramp, noise, +-- erode and fill ops route through here; pushSnapshot flattens sub-tables first. +-- Push a ready bbox-grid snapshot as a new undo entry. Bookkeeping shared by +-- the flat converter below and the per-tick batch commit (flushBatch). +local function pushBboxSnapshot(snapshot) + local vertexCount = snapshot.vertexCount or 0 finalizeMerge() for i = 1, #redoStack do @@ -451,7 +459,6 @@ local function pushSnapshotFromFlat(flatBuf, vertexCount) end redoStack = {} - local snapshot = flatToBboxSnapshot(flatBuf, vertexCount) snapshot.strokeId = currentStrokeId undoStack[#undoStack + 1] = snapshot totalVertexCount = totalVertexCount + vertexCount @@ -468,6 +475,16 @@ local function pushSnapshotFromFlat(flatBuf, vertexCount) SendToUnsynced("TerraformBrushStacks", #undoStack, #redoStack) end +local function pushSnapshotFromFlat(flatBuf, vertexCount) + if vertexCount == 0 then + return + end + if vertexCount > MAX_SNAPSHOT_VERTICES then + return + end + pushBboxSnapshot(flatToBboxSnapshot(flatBuf, vertexCount)) +end + -- Sub-table format {{x,z,h},...} cold path: flatten via scratchSnapFlat then -- route through pushSnapshotFromFlat. Currently unused but kept for API stability. local function pushSnapshot(snapshot) @@ -489,6 +506,135 @@ local function pushSnapshot(snapshot) pushSnapshotFromFlat(buf, vertexCount) end +-- Snapshot every heightmap vertex into one undo entry. +-- +-- Deliberately bypasses the per-call MAX_SNAPSHOT_VERTICES gate in +-- pushSnapshotFromFlat: whole-map operations are explicit user actions, and +-- they are the ideal case for the orig-delta encoding anyway (every cell that +-- still matches its map original stores a mask bit and no height). +local function snapshotWholeMap() + finalizeMerge() + local squareSize = Game.squareSize + local snapFlat = scratchSnapFlat + local vCount = 0 + for iz = 0, Game.mapSizeZ, squareSize do + for ix = 0, Game.mapSizeX, squareSize do + local base = vCount * 3 + snapFlat[base + 1] = ix + snapFlat[base + 2] = iz + snapFlat[base + 3] = GetGroundHeight(ix, iz) + vCount = vCount + 1 + end + end + for i = 1, #redoStack do + totalVertexCount = totalVertexCount - (redoStack[i].vertexCount or 0) + end + redoStack = {} + local snapshot = flatToBboxSnapshot(snapFlat, vCount) + snapshot.strokeId = currentStrokeId + undoStack[#undoStack + 1] = snapshot + totalVertexCount = totalVertexCount + vCount + if #undoStack > MAX_UNDO then + local old = undoStack[1] + totalVertexCount = totalVertexCount - (old.vertexCount or 0) + table.remove(undoStack, 1) + end + evictOldSnapshots() +end + +-- Re-snap every feature to the ground after a whole-map height change, so +-- trees and rocks do not end up floating or buried. +local function resnapAllFeatures() + local features = Spring.GetAllFeatures() + for i = 1, #features do + local fx, fy, fz = Spring.GetFeaturePosition(features[i]) + if fx then + Spring.SetFeaturePosition(features[i], fx, fy, fz, true) + end + end +end + +-- Whole-map height range edit, the engine behind the Dimensions window. +-- +-- "scale" remaps every vertex linearly from the live extremes onto +-- [newMin, newMax], so the relief is stretched or squashed and nothing is +-- lost. "clamp" only cuts vertices that fall outside the range, which is what +-- the old /clampminheight + /clampmaxheight pair did and is still useful for +-- shaving a runaway peak. +-- +-- Only the live heightmap is touched, never the original: that keeps the +-- operation undoable like every other brush stroke, and leaves the restore +-- brush anchored to the map as it was loaded. +local function remapMapHeights(newMin, newMax, clampOnly) + local squareSize = Game.squareSize + local mapSizeX, mapSizeZ = Game.mapSizeX, Game.mapSizeZ + + -- Scan for the true extremes rather than trusting Spring.GetGroundExtremes: + -- the mapping is only exact if the source range is exact, and this is what + -- makes the terrain land on [newMin, newMax] to the elmo. + local curMin, curMax = math.huge, -math.huge + for iz = 0, mapSizeZ, squareSize do + for ix = 0, mapSizeX, squareSize do + local h = GetGroundHeight(ix, iz) + if h < curMin then + curMin = h + end + if h > curMax then + curMax = h + end + end + end + + local scale + if not clampOnly then + -- A flat map has no relief to stretch and would divide by zero. + if curMax - curMin < 0.01 then + echoGate("[Terraform Brush] Map is flat - nothing to rescale. Raise some terrain first.") + return + end + scale = (newMax - newMin) / (curMax - curMin) + elseif curMin >= newMin and curMax <= newMax then + echoGate("[Terraform Brush] Terrain already inside that range - nothing to clamp.") + return + end + + snapshotWholeMap() + + Spring.SetHeightMapFunc(function() + for iz = 0, mapSizeZ, squareSize do + for ix = 0, mapSizeX, squareSize do + local h = GetGroundHeight(ix, iz) + local nh + if clampOnly then + nh = (h < newMin and newMin) or (h > newMax and newMax) or h + else + nh = newMin + (h - curMin) * scale + end + if nh ~= h then + SetHeightMap(ix, iz, nh) + end + end + end + end) + + -- Aircraft fly relative to the smooth mesh, and it is not regenerated by + -- SetHeightMapFunc, so without this they path into the new terrain. + Spring.RebuildSmoothMesh(0, 0, mapSizeX, mapSizeZ) + resnapAllFeatures() + + SendToUnsynced("TerraformBrushStacks", #undoStack, #redoStack) + Spring.Echo( + string.format( + "[Terraform Brush] Height range %s: %.1f..%.1f -> %.1f..%.1f", + clampOnly and "clamped" or "rescaled", + curMin, + curMax, + clampOnly and max(curMin, newMin) or newMin, + clampOnly and min(curMax, newMax) or newMax + ) + ) +end + local DUST_CEGS = { "dust_cloud", "dust_cloud_dirt_light", "dust_cloud_fast", "dust_cloud_dirt", "dirtpoof" } local DUST_COUNT_PER_100 = 12 -- puffs per 100 radius local RUMBLE_SOUNDS = @@ -585,21 +731,6 @@ local function rotatePoint(px, pz, angleDeg) return px * _rpCos - pz * _rpSin, px * _rpSin + pz * _rpCos end -local function isInsideCircle(dx, dz, radius) - return dx * dx + dz * dz <= radius * radius -end - -local function isInsideSquare(dx, dz, radius, angleDeg) - local lx, lz = rotatePoint(dx, dz, -angleDeg) - return abs(lx) <= radius and abs(lz) <= radius -end - -local function isInsideRing(dx, dz, radius) - local distSquared = dx * dx + dz * dz - local innerRadius = radius * ringInnerRatio - return distSquared <= radius * radius and distSquared >= innerRadius * innerRadius -end - local function regularPolygonFalloff(dx, dz, radius, angleDeg, numSides) local lx, lz = rotatePoint(dx, dz, -angleDeg) local dist = (lx * lx + lz * lz) ^ 0.5 @@ -703,26 +834,57 @@ end -- lengthScale → 0.05 step -- ringRatio → 0.02 step (only matters for "ring" shape) -- --- LRU eviction: keep at most FALLOFF_STAMP_LIMIT stamps. A radius-2000 stamp --- is ~400 k floats ≈ 16 MB; 4 such = 64 MB max worst-case. -local FALLOFF_STAMP_LIMIT = 4 +-- LRU eviction by cell budget: a stamp holds w*h table slots (a radius-2000 +-- stamp is ~500 k of them). Up to FALLOFF_STAMP_CELL_BUDGET slots stay +-- resident, so a FOLLOW STROKE drag with a small or mid brush keeps every +-- angle of its rotation cached instead of thrashing the old fixed 4-entry +-- list and rebuilding one O(w*h) stamp (sin/cos/pow per cell) per dab. +local FALLOFF_STAMP_CELL_BUDGET = 3000000 local FALLOFF_EPSILON = 1 / 255 -- below this, treat as zero (sub-quantisation) local falloffStampCache = {} local falloffStampGen = {} -- key → last-use generation (monotonic clock) -local falloffStampCount = 0 +local falloffStampSize = {} -- key → w*h, for the budget accounting +---@type number +local falloffStampCells = 0 -- data slots held by every cached stamp together local falloffStampClock = 0 -local function quantiseStampParams(radius, angleDeg, curve, lengthScale, ringRatio) +-- Cells one stamp of this radius / length scale occupies at grid step ss +-- (its bounding window; mirrors buildFalloffStamp's extent maths). +local function stampCellCount(radius, lengthScale, ss) + local halfCells = floor(radius * max(1, lengthScale) * 1.42 / ss) + local size = halfCells * 2 + 1 + return size * size +end + +local function quantiseStampParams(radius, shape, angleDeg, curve, lengthScale, ringRatio, ss) local rQ = floor(radius) - -- Wrap angle to [0,360) before quantising so 359° and -1° share a stamp. - local aN = angleDeg % 360 - local aQ = floor(aN / 2 + 0.5) * 2 - if aQ >= 360 then - aQ = aQ - 360 - end local cQ = floor(curve / 0.05 + 0.5) * 0.05 local lQ = floor(lengthScale / 0.05 + 0.5) * 0.05 local rrQ = floor(ringRatio / 0.02 + 0.5) * 0.02 + local aQ + if (shape == "circle" or shape == "ring") and lQ == 1 then + -- Rotation-invariant footprint: one stamp serves every angle. FOLLOW + -- STROKE with the default circle used to rebuild an identical stamp + -- every 2 degrees of tangent. + aQ = 0 + else + -- 2 deg steps while a full rotation of stamps fits the cache with room + -- to spare; coarser for huge footprints so a FOLLOW drag cannot rebuild + -- a 100 k-cell stamp per dab (capped at 30 deg). The widget quantises + -- its tangent to 2 deg too, so previews and stamps agree for anything + -- but the biggest brushes. + local aStep = 2 + local cells = stampCellCount(rQ, lQ, ss) + if cells > 40000 then + aStep = min(30, 2 * math.ceil(cells / 40000)) + end + -- Wrap angle to [0,360) before quantising so 359° and -1° share a stamp. + local aN = angleDeg % 360 + aQ = floor(aN / aStep + 0.5) * aStep + if aQ >= 360 then + aQ = aQ - 360 + end + end return rQ, aQ, cQ, lQ, rrQ end @@ -752,7 +914,7 @@ local function buildFalloffStamp(radius, shape, angleDeg, curve, lengthScale, ri end local function getFalloffStamp(radius, shape, angleDeg, curve, lengthScale, ringRatio, ss) - local rQ, aQ, cQ, lQ, rrQ = quantiseStampParams(radius, angleDeg, curve, lengthScale, ringRatio) + local rQ, aQ, cQ, lQ, rrQ = quantiseStampParams(radius, shape, angleDeg, curve, lengthScale, ringRatio, ss) local key = string.format("%s|%d|%d|%.2f|%.2f|%.2f|%d", shape, rQ, aQ, cQ, lQ, rrQ, ss) falloffStampClock = falloffStampClock + 1 local stamp = falloffStampCache[key] @@ -760,24 +922,225 @@ local function getFalloffStamp(radius, shape, angleDeg, curve, lengthScale, ring falloffStampGen[key] = falloffStampClock return stamp end - stamp = buildFalloffStamp(rQ, shape, aQ, cQ, lQ, rrQ, ss) - falloffStampCache[key] = stamp + local built = buildFalloffStamp(rQ, shape, aQ, cQ, lQ, rrQ, ss) + falloffStampCache[key] = built falloffStampGen[key] = falloffStampClock - falloffStampCount = falloffStampCount + 1 - if falloffStampCount > FALLOFF_STAMP_LIMIT then + local builtCells = built.w * built.h + falloffStampSize[key] = builtCells + falloffStampCells = falloffStampCells + builtCells + -- Evict least-recently-used stamps until the budget holds; the one just + -- built stays whatever its size. + while falloffStampCells > FALLOFF_STAMP_CELL_BUDGET do local oldKey, oldGen for k, g in pairs(falloffStampGen) do - if oldGen == nil or g < oldGen then + if k ~= key and (oldGen == nil or g < oldGen) then oldKey, oldGen = k, g end end + if not oldKey then + break + end + falloffStampCells = falloffStampCells - (falloffStampSize[oldKey] or 0) falloffStampCache[oldKey] = nil falloffStampGen[oldKey] = nil - falloffStampCount = falloffStampCount - 1 + falloffStampSize[oldKey] = nil + end + return built +end + +-- ─── PER-TICK BATCH ────────────────────────────────────────────────────────── +-- A STROKE message carries every dab of a widget tick (up to 48). They used to +-- commit one at a time: one SetHeightMapFunc per dab, so one engine RecalcArea +-- (mip heightmaps, face/vertex normals, slopes, pathing, LOS, the unsynced +-- normal + shading textures, ROAM patch dirtying) per dab, plus one undo entry +-- per dab, with a GetGroundHeight + GetGroundOrigHeight + SetHeightMap engine +-- call per cell per dab -- on footprints that overlap ~85 % at the 15 %-of- +-- radius dab spacing. That was the sculpt-drag frame cost artists reported. +-- +-- Now the dabs of one message apply in order against a working copy of the +-- cells they touch (read from the engine once, on first touch), and the tick +-- commits once: one SetHeightMapFunc over the touched cells and one undo entry +-- built straight from the pre-tick copy. Dab k still sees dab k-1's writes, +-- so the result is exactly what the sequential commits produced. +-- +-- Cells are keyed by a map-global index (zCell * batchCols + xCell + 1); the +-- tables are sparse and reused, cleared by walking the touch lists. +local SQUARE_SIZE = Game.squareSize +local batchCols = floor(Game.mapSizeX / SQUARE_SIZE) + 1 +---@type table +local batchNew = {} -- cellIdx -> height written this tick (working copy) +---@type table +local batchPre = {} -- cellIdx -> height read from the engine at first touch +---@type number[] +local batchWriteList = {} -- cellIdx per written cell, first-write order +local batchWriteN = 0 +---@type number[] +local batchReadList = {} -- cellIdx per cell fetched from the engine +local batchReadN = 0 +local batchOpen = false +-- Undo bbox of the tick, in cells; reset to +-huge by beginBatch. +local batchMinXc, batchMinZc, batchMaxXc, batchMaxZc = math.huge, math.huge, -math.huge, -math.huge + +-- Pre-stroke heights: what every cell measured before the current stroke +-- first wrote it, kept until STROKE_END. Clay planes are taken against these +-- so a stroke lays ONE layer over the surface it started on. The old plane +-- re-measured the live centre height, i.e. the disc the previous tick had +-- just raised, and every tick stacked another disc one layer up: that is +-- where the concentric rings on every clay stroke came from. +---@type table +local strokeOrig = {} +---@type number[] +local strokeOrigList = {} +local strokeOrigN = 0 +local STROKE_ORIG_LIMIT = 4000000 + +local function clearStrokeOrigin() + for i = 1, strokeOrigN do + strokeOrig[strokeOrigList[i]] = nil + end + strokeOrigN = 0 +end + +local function beginBatch() + batchOpen = true + batchWriteN = 0 + batchReadN = 0 + batchMinXc, batchMinZc = math.huge, math.huge + batchMaxXc, batchMaxZc = -math.huge, -math.huge + -- A stroke that never got its STROKE_END (widget reload mid-drag) must not + -- pin the whole map's pre-stroke heights forever. + if strokeOrigN > STROKE_ORIG_LIMIT then + clearStrokeOrigin() end - return stamp end +-- Height of a cell as this tick currently sees it: this tick's write if any, +-- else the engine value, cached in batchPre on first read. Callers clamp the +-- cell into the map. +local function batchRead(xCell, zCell) + local idx = zCell * batchCols + xCell + 1 + local v = batchNew[idx] + if v ~= nil then + return v + end + v = batchPre[idx] + if v == nil then + v = GetGroundHeight(xCell * SQUARE_SIZE, zCell * SQUARE_SIZE) + batchPre[idx] = v + batchReadN = batchReadN + 1 + batchReadList[batchReadN] = idx + end + return v +end + +-- SetHeightMapFunc wants a function argument; this one walks the write list. +local function batchCommitWorker() + for i = 1, batchWriteN do + local idx = batchWriteList[i] or 0 + local h = batchNew[idx] + if h == h then -- NaN check: NaN ~= NaN + local zc = floor((idx - 1) / batchCols) + SetHeightMap(((idx - 1) - zc * batchCols) * SQUARE_SIZE, zc * SQUARE_SIZE, h) + else + nanHeightSkipped = true + end + end +end + +-- Commit the tick: one heightmap write, one undo entry, then reset. +local function flushBatch() + batchOpen = false + if batchWriteN > 0 then + SetHeightMapFunc(batchCommitWorker) + if nanHeightSkipped then + Spring.Echo("[Terraform Brush] Warning: NaN height skipped — possible div0 in brush math") + nanHeightSkipped = false + end + -- Undo entry straight from the pre-tick heights (bbox-grid format, see + -- flatToBboxSnapshot): no flat intermediate, and one GetGroundOrigHeight + -- per touched cell per tick rather than per dab. + if batchWriteN <= MAX_SNAPSHOT_VERTICES then + local w = batchMaxXc - batchMinXc + 1 + local h = batchMaxZc - batchMinZc + 1 + local mask, hgrid = {}, {} + for i = 1, batchWriteN do + local idx = batchWriteList[i] or 0 + local zc = floor((idx - 1) / batchCols) + local xc = (idx - 1) - zc * batchCols + local sIdx = (zc - batchMinZc) * w + (xc - batchMinXc) + 1 + local pre = batchPre[idx] + if pre == GetGroundOrigHeight(xc * SQUARE_SIZE, zc * SQUARE_SIZE) then + mask[sIdx] = 2 + else + mask[sIdx] = 1 + hgrid[sIdx] = pre + end + end + pushBboxSnapshot({ + format = "bbox", + minX = batchMinXc * SQUARE_SIZE, + minZ = batchMinZc * SQUARE_SIZE, + w = w, + h = h, + ss = SQUARE_SIZE, + mask = mask, + hgrid = hgrid, + vertexCount = batchWriteN, + }) + end + for i = 1, batchWriteN do + batchNew[batchWriteList[i]] = nil + end + end + for i = 1, batchReadN do + batchPre[batchReadList[i]] = nil + end + batchWriteN = 0 + batchReadN = 0 +end + +-- Clay target plane for a dab. Measured on the pre-stroke surface (strokeOrig +-- where this stroke already wrote, the live ground elsewhere) as the mean of +-- the centre and four taps half a radius out, so the dabs of one stroke agree +-- on a plane instead of each re-measuring the disc the previous one left. +-- stack=true is the legacy per-tick build-up: the plane sits on the live +-- centre height, so a held or slow drag keeps piling layers (and rings). +local function clayPlaneFor(centerX, centerZ, radius, rise, stack) + if stack then + return GetGroundHeight(centerX, centerZ) + rise + end + local maxXc = floor(Game.mapSizeX / SQUARE_SIZE) + local maxZc = floor(Game.mapSizeZ / SQUARE_SIZE) + local cxc = floor(centerX / SQUARE_SIZE + 0.5) + local czc = floor(centerZ / SQUARE_SIZE + 0.5) + local r = max(1, floor(radius * 0.5 / SQUARE_SIZE)) + local sum = 0.0 + for t = 1, 5 do + local xc, zc = cxc, czc + if t == 2 then + xc = cxc - r + elseif t == 3 then + xc = cxc + r + elseif t == 4 then + zc = czc - r + elseif t == 5 then + zc = czc + r + end + xc = max(0, min(maxXc, xc)) + zc = max(0, min(maxZc, zc)) + local h = strokeOrig[zc * batchCols + xc + 1] + if h == nil then + h = GetGroundHeight(xc * SQUARE_SIZE, zc * SQUARE_SIZE) + end + sum = sum + h + end + return sum / 5 + rise +end + +-- clayMode: false/nil off, 1 = clay (one layer per stroke), 2 = clay with +-- per-tick build-up (legacy). Dabs inside a STROKE batch get clayPlaneIn from +-- handleStroke; a dab on its own (per-dab BRUSH message, sticky replay) opens +-- and commits a batch of one. local function applyTerraform( centerX, centerZ, @@ -794,28 +1157,29 @@ local function applyTerraform( opacity, flattenHeight, instant, - localBlur + localBlur, + localSmudge, + smudgeStart, + clayPlaneIn ) - local squareSize = Game.squareSize - local mapSizeX = Game.mapSizeX - local mapSizeZ = Game.mapSizeZ + local squareSize = SQUARE_SIZE + local maxXc = floor(Game.mapSizeX / squareSize) + local maxZc = floor(Game.mapSizeZ / squareSize) lengthScale = lengthScale or 1.0 - -- Clay mode: compute a target plane at center height + full brush displacement - local clayPlane - if clayMode and direction ~= 0 and direction ~= 2 then - local centerHeight = GetGroundHeight(centerX, centerZ) - clayPlane = centerHeight + direction * HEIGHT_STEP * intensity + local standalone = not batchOpen + if standalone then + beginBatch() + end + + -- Clay mode: target plane at the reference height + full brush displacement. + local clayPlane = clayPlaneIn + if not clayPlane and clayMode and direction ~= 0 and direction ~= 2 then + clayPlane = clayPlaneFor(centerX, centerZ, radius, direction * HEIGHT_STEP * intensity, clayMode == 2) end opacity = opacity or 0.3 local dirStep = direction * HEIGHT_STEP - local levelTarget - if direction == 0 and not localBlur then - -- Heights are only written after the loop, so this matches the - -- per-cell read it replaces. - levelTarget = flattenHeight or GetGroundHeight(centerX, centerZ) - end -- Falloff stamp: precomputed per-cell falloff field keyed by quantised -- (radius, shape, angle, curve, length, ringRatio). Skips per-cell sin/cos @@ -830,84 +1194,214 @@ local function applyTerraform( -- to squareSize/2 ≈ 4 world units; required so the cached stamp aligns). local centerCellX = floor(centerX / squareSize + 0.5) local centerCellZ = floor(centerZ / squareSize + 0.5) + local cols = batchCols + local bNew, bPre = batchNew, batchPre + + local levelTarget + if direction == 0 and not localBlur and not localSmudge then + -- Heights are only written after the loop, so this matches the + -- per-cell read it replaces. + levelTarget = flattenHeight or batchRead(max(0, min(maxXc, centerCellX)), max(0, min(maxZc, centerCellZ))) + end - -- Smooth mode (localBlur): each cell blends toward the mean of its OWN 3x3 + -- Smooth mode (localBlur): each cell blends toward the mean of its OWN -- neighborhood instead of one flat target for the whole stamp, so a cell at -- the falloff edge blends toward a value close to its own height (most of -- its neighbors are untouched terrain) -- no plateau-vs-untouched seam. - -- Neighbor spacing (blurStep, in grid cells) scales with intensity: at low + -- Box half-width (blurStep, in grid cells) scales with intensity: at low -- intensity it stays tight (fine-detail smoothing only, gentle), so at high -- intensity a single pass reaches wide enough to actually flatten broad -- bumps instead of forever only erasing single-cell noise. Capped at half -- the brush's own radius so small brushes don't sample past themselves. - -- Read the padded rect once; the main loop below reuses it for both the - -- cell's own height and all 8 neighbor samples. - local blurBuf, blurStride, blurStep + -- The mean must be DENSE over the (2*blurStep+1)^2 box, not 9 taps spaced + -- blurStep apart: sparse taps are phase-blind to ripples whose wavelength + -- divides the tap spacing, so those survive every pass while everything + -- else flattens -- visible as grid-aligned stripes. A summed-area table + -- over the padded rect gives the dense mean in 4 lookups per cell. + local blurBuf, blurStride, blurStep, blurSAT, satStride, blurInvArea if localBlur then local intensityT = max(0, min(1, math.log(intensity / 0.1) / math.log(100.0 / 0.1))) blurStep = floor(1 + intensityT * (MAX_BLUR_STEP - 1) + 0.5) blurStep = max(1, min(blurStep, floor(radius / squareSize / 2))) blurStride = sw + 2 * blurStep blurBuf = scratchBlurHeights - for pz = 0, sh - 1 + 2 * blurStep do + local padRows = sh + 2 * blurStep + for pz = 0, padRows - 1 do local zCell = centerCellZ + (pz - blurStep - sCz) - local bz = max(0, min(mapSizeZ, zCell * squareSize)) + if zCell < 0 then + zCell = 0 + elseif zCell > maxZc then + zCell = maxZc + end local rowBase = pz * blurStride - for px = 0, sw - 1 + 2 * blurStep do + for px = 0, blurStride - 1 do local xCell = centerCellX + (px - blurStep - sCx) - local bx = max(0, min(mapSizeX, xCell * squareSize)) - blurBuf[rowBase + px + 1] = GetGroundHeight(bx, bz) + if xCell < 0 then + xCell = 0 + elseif xCell > maxXc then + xCell = maxXc + end + blurBuf[rowBase + px + 1] = batchRead(xCell, zCell) + end + end + -- SAT[r][c] = sum of blurBuf rows < r, cols < c (zero first row/col). + blurSAT = scratchBlurSAT + satStride = blurStride + 1 + for c = 1, satStride do + blurSAT[c] = 0 + end + for r = 1, padRows do + local rowBase = r * satStride + local prevBase = rowBase - satStride + local bufBase = (r - 1) * blurStride + blurSAT[rowBase + 1] = 0 + local rowSum = 0 + for c = 1, blurStride do + rowSum = rowSum + blurBuf[bufBase + c] + blurSAT[rowBase + c + 1] = blurSAT[prevBase + c + 1] + rowSum + end + end + local boxSide = 2 * blurStep + 1 + blurInvArea = 1 / (boxSide * boxSide) + end + + -- Smudge mode (localSmudge): GIMP's smudge, for the heightfield. A + -- brush-space height grab is taken on the first dab of a stroke and carried + -- with the cursor; every later dab folds the terrain under the (moved) + -- brush into it (rate = how much of the carried relief survives), then the + -- main loop paints the buffer back into the ground. Because the buffer is + -- indexed in brush space, relief grabbed at the previous position lands at + -- the new one -- features drag along the stroke and taper off as the carry + -- decays. Chains are matched per dab by proximity so each symmetry copy + -- continues its own buffer without any copy id on the wire; a stroke-start + -- dab (widget-flagged) always grabs fresh. + local smudgeHeights + if localSmudge then + smudgeClock = smudgeClock + 1 + local buf + if not smudgeStart then + local bestD + for i = 1, #smudgeBuffers do + local b = smudgeBuffers[i] + if b.w == sw and b.h == sh then + local dx = centerCellX - b.cx + local dz = centerCellZ - b.cz + local d = dx * dx + dz * dz + if bestD == nil or d < bestD then + bestD, buf = d, b + end + end + end + -- A chain more than a brush diameter behind is another copy's (or a + -- stale one): grab fresh rather than teleport terrain across the map. + local maxCells = 2 * radius / squareSize + if buf and bestD > maxCells * maxCells then + buf = nil + end + end + local grab = false + if not buf then + -- Retire chains whose stroke ended long ago, and cap the pool. + for i = #smudgeBuffers, 1, -1 do + if smudgeClock - smudgeBuffers[i].clock > 512 then + table.remove(smudgeBuffers, i) + end + end + if #smudgeBuffers >= MAX_SMUDGE_BUFFERS then + local oldI = 1 + for i = 2, #smudgeBuffers do + if smudgeBuffers[i].clock < smudgeBuffers[oldI].clock then + oldI = i + end + end + table.remove(smudgeBuffers, oldI) + end + buf = { w = sw, h = sh, heights = {} } + smudgeBuffers[#smudgeBuffers + 1] = buf + grab = true + end + buf.cx = centerCellX + buf.cz = centerCellZ + buf.clock = smudgeClock + smudgeHeights = buf.heights + -- Rate: fraction of the carried relief surviving each dab, mapped from + -- the intensity slider on the same log scale as smooth's blur width. + -- Higher intensity = longer drag tails. + local intensityT = max(0, min(1, math.log(intensity / 0.1) / math.log(100.0 / 0.1))) + local rate = 0.5 + 0.47 * intensityT + for iz = 0, sh - 1 do + local rowBase = iz * sw + local zCell = centerCellZ + (iz - sCz) + if zCell < 0 then + zCell = 0 + elseif zCell > maxZc then + zCell = maxZc + end + for ix = 0, sw - 1 do + local xCell = centerCellX + (ix - sCx) + if xCell < 0 then + xCell = 0 + elseif xCell > maxXc then + xCell = maxXc + end + local cur = batchRead(xCell, zCell) + local idx = rowBase + ix + 1 + if grab then + smudgeHeights[idx] = cur + else + smudgeHeights[idx] = cur + (smudgeHeights[idx] - cur) * rate + end end end + if grab then + -- the first dab of a stroke only grabs; nothing to paint yet + if standalone then + flushBatch() + end + return + end end - -- Reuse scratch tables to reduce per-frame allocation - local heightData = scratchHeightData - local snapFlat = scratchSnapFlat - local hIdx = 0 - local sCount = 0 - for iz = 0, sh - 1 do local sBase = iz * sw local zCell = centerCellZ + (iz - sCz) - local z = zCell * squareSize - if z >= 0 and z <= mapSizeZ then + if zCell >= 0 and zCell <= maxZc then + local rowIdx = zCell * cols + 1 for ix = 0, sw - 1 do local falloff = sdata[sBase + ix + 1] if falloff then local xCell = centerCellX + (ix - sCx) - local x = xCell * squareSize - if x >= 0 and x <= mapSizeX then + if xCell >= 0 and xCell <= maxXc then + local idx = rowIdx + xCell local current local blurTarget if localBlur then - local rowN = iz * blurStride - local rowC = (iz + blurStep) * blurStride - local rowS = (iz + 2 * blurStep) * blurStride - local colW = ix + 1 - local colC = ix + blurStep + 1 - local colE = ix + 2 * blurStep + 1 - current = blurBuf[rowC + colC] + current = blurBuf[(iz + blurStep) * blurStride + ix + blurStep + 1] + -- Dense box mean over padded rows [iz, iz+2*blurStep], + -- cols [ix, ix+2*blurStep] via 4 SAT corner lookups. + local r0Base = iz * satStride + local r1Base = (iz + 2 * blurStep + 1) * satStride + local c0 = ix + 1 + local c1 = ix + 2 * blurStep + 2 blurTarget = ( - blurBuf[rowN + colW] - + blurBuf[rowN + colC] - + blurBuf[rowN + colE] - + blurBuf[rowC + colW] - + blurBuf[rowC + colE] - + blurBuf[rowS + colW] - + blurBuf[rowS + colC] - + blurBuf[rowS + colE] - + current - ) / 9 + blurSAT[r1Base + c1] + - blurSAT[r0Base + c1] + - blurSAT[r1Base + c0] + + blurSAT[r0Base + c0] + ) * blurInvArea else - current = GetGroundHeight(x, z) + -- Inline batchRead: this is the hot path. + current = bNew[idx] + if current == nil then + current = bPre[idx] + if current == nil then + current = GetGroundHeight(xCell * squareSize, zCell * squareSize) + bPre[idx] = current + batchReadN = batchReadN + 1 + batchReadList[batchReadN] = idx + end + end end - -- Write to flat scratch buffer (no sub-table allocation) - local base = sCount * 3 - snapFlat[base + 1] = x - snapFlat[base + 2] = z - snapFlat[base + 3] = current - sCount = sCount + 1 local newHeight @@ -918,7 +1412,9 @@ local function applyTerraform( elseif direction < 0 and heightMin then newHeight = current + (heightMin - current) * falloff elseif direction == 0 then - local target = localBlur and blurTarget or levelTarget + local target = localBlur and blurTarget + or (localSmudge and smudgeHeights[sBase + ix + 1]) + or levelTarget newHeight = current + (target - current) * falloff if heightMin then newHeight = max(heightMin, newHeight) @@ -941,7 +1437,9 @@ local function applyTerraform( local delta = (random() * 2 - 1) * HEIGHT_STEP * falloff * intensity * opacity newHeight = current + delta elseif direction == 0 then - local target = localBlur and blurTarget or levelTarget + local target = localBlur and blurTarget + or (localSmudge and smudgeHeights[sBase + ix + 1]) + or levelTarget local diff = target - current local blend = min(1.0, falloff * opacity * intensity) newHeight = current + diff * blend @@ -973,30 +1471,47 @@ local function applyTerraform( end end - hIdx = hIdx + 1 - local he = heightData[hIdx] - if he then - he[1] = x - he[2] = z - he[3] = newHeight - else - heightData[hIdx] = { x, z, newHeight } + -- First write of this cell in the tick: list it, grow the + -- undo bbox, and pin its pre-stroke height for the clay plane. + if bNew[idx] == nil then + batchWriteN = batchWriteN + 1 + batchWriteList[batchWriteN] = idx + if xCell < batchMinXc then + batchMinXc = xCell + end + if xCell > batchMaxXc then + batchMaxXc = xCell + end + if zCell < batchMinZc then + batchMinZc = zCell + end + if zCell > batchMaxZc then + batchMaxZc = zCell + end + -- The blur path read this cell through blurBuf, so it may + -- lack its pre entry: pin it now so the snapshot and the + -- clear loop see it. + local pre = bPre[idx] or current + if bPre[idx] == nil then + bPre[idx] = current + batchReadN = batchReadN + 1 + batchReadList[batchReadN] = idx + end + if strokeOrig[idx] == nil then + strokeOrig[idx] = pre + strokeOrigN = strokeOrigN + 1 + strokeOrigList[strokeOrigN] = idx + end end + bNew[idx] = newHeight end end end end end - -- Trim scratch heightData using tracked max (avoids # on reused table) - for i = hIdx + 1, scratchHeightDataMax do - heightData[i] = nil - end - scratchHeightDataMax = hIdx - - if hIdx > 0 then - applyHeightChanges(heightData, hIdx) - pushSnapshotFromFlat(snapFlat, sCount) + if standalone then + flushBatch() end end @@ -1707,6 +2222,243 @@ local function applyErode(centerX, centerZ, radius, shape, angleDeg, curve, inte end end +-- ───────────────────────────────────────────────────────────────────────────── +-- AUTORAMP — one-click cliff restyler +-- +-- The terrain math lives in common/autoramp_profile.lua, shared with the +-- widget's WYSIWYG hover preview so what the preview shows is exactly what +-- this synced apply produces (pure seeded math, deterministic across clients). +-- This side feeds it the real heightmap and routes the result through the +-- standard apply + undo-snapshot epilogue. +-- ───────────────────────────────────────────────────────────────────────────── +local AutorampProfile = VFS.Include("common/autoramp_profile.lua") + +local function applyAutoramp( + centerX, + centerZ, + radius, + angleDeg, + falloffK, + edgeNoiseK, + erosionK, + talusK, + seed, + startMode +) + local res, err = AutorampProfile.compute({ + centerX = centerX, + centerZ = centerZ, + radius = radius, + angleDeg = angleDeg, + falloffK = falloffK, + edgeNoiseK = edgeNoiseK, + erosionK = erosionK, + talusK = talusK, + seed = seed, + startMode = startMode, + cellSize = Game.squareSize, + mapSizeX = Game.mapSizeX, + mapSizeZ = Game.mapSizeZ, + getHeight = GetGroundHeight, + }) + if not res then + if err == "no_cliff" then + echoGate("[Terraform Brush] Autoramp: no cliff inside the brush circle (all ground is too gentle)") + elseif err == "no_span" then + echoGate("[Terraform Brush] Autoramp: no usable cliff height at the click point") + elseif err == "no_contour" then + echoGate( + "[Terraform Brush] Autoramp: cliff mid-line is outside the brush — enlarge the brush or click the face" + ) + end + return + end + + local n = res.n + local ox = res.ox + local oz = res.oz + local cs = res.cellSize + local orig = res.orig + local out = res.newH + local heightData = scratchHeightData + local snapFlat = scratchSnapFlat + local hIdx = 0 + local sCount = 0 + for iz = 0, n - 1 do + local rowBase = iz * n + local z = (oz + iz) * cs + for ix = 0, n - 1 do + local i = rowBase + ix + 1 + local o = orig[i] + if o then + local delta = out[i] - o + if delta > 0.05 or delta < -0.05 then + local x = (ox + ix) * cs + local base = sCount * 3 + snapFlat[base + 1] = x + snapFlat[base + 2] = z + snapFlat[base + 3] = o + sCount = sCount + 1 + hIdx = hIdx + 1 + local he = heightData[hIdx] + if he then + he[1] = x + he[2] = z + he[3] = o + delta + else + heightData[hIdx] = { x, z, o + delta } + end + end + end + end + end + + -- Trim scratch heightData using tracked max (avoids # on reused table) + for i = hIdx + 1, scratchHeightDataMax do + heightData[i] = nil + end + scratchHeightDataMax = hIdx + if hIdx > 0 then + applyHeightChanges(heightData, hIdx) + pushSnapshotFromFlat(snapFlat, sCount) + end +end + +-- Hoisted handler: one message carries a whole tick of brush dabs (the widget's +-- extraState.sendStrokeDabs builds it). Clay planes for every dab are derived +-- from the pre-tick heightmap BEFORE the first dab lands, so a stroke deposits +-- per distance travelled instead of per tick split by the dab count -- and still +-- cannot rise more than HEIGHT_STEP * intensity within one tick, because every +-- plane in the batch came from the same untouched heights. +local strokeDabX, strokeDabZ, strokeDabA, strokeClayPlane = {}, {}, {}, {} +local function handleStroke(payload) + local parts = parseParts(payload) + local direction = tonumber(parts[1]) + local radius = tonumber(parts[2]) + local shape = parts[3] or "circle" + local curve = tonumber(parts[4]) or 1.0 + local heightMin = tonumber(parts[5]) + local heightMax = tonumber(parts[6]) + local intensity = tonumber(parts[7]) or 1.0 + local lengthScale = tonumber(parts[8]) or 1.0 + -- Clay flag: "1" = clay (one layer per stroke), "2" = clay with per-tick + -- build-up (Settings > Stroke > Clay build-up), anything else = off. + local clayMode = (parts[9] == "1" and 1) or (parts[9] == "2" and 2) or false + local dustMode = parts[10] == "1" + local opacity = tonumber(parts[11]) or 0.3 + local instant = parts[12] == "1" + -- Same sentinels as the per-dab message: "smooth" and "smudge" + -- ride the flatten slot as non-numeric values. + local localBlur = parts[13] == "smooth" + local localSmudge = parts[13] ~= nil and parts[13]:sub(1, 6) == "smudge" + local smudgeStart = localSmudge and parts[13]:sub(7, 7) == "1" + local flattenHeight = tonumber(parts[13]) + if parts[14] then + ringInnerRatio = max(0.05, min(0.95, tonumber(parts[14]) or 0.6)) + end + local nDabs = tonumber(parts[15]) or 0 + if not direction or not radius or nDabs < 1 then + return + end + + radius = max(MIN_RADIUS, min(MAX_RADIUS, radius)) + curve = max(0.1, min(5.0, curve)) + intensity = max(0.1, min(100.0, intensity)) + lengthScale = max(0.2, min(5.0, lengthScale)) + opacity = max(0.01, min(1.0, opacity)) + + -- Copy the dabs out of the shared parse scratch before applying any of them. + local count = 0 + for i = 1, nDabs do + local b = 15 + (i - 1) * 3 + local x = tonumber(parts[b + 1]) + local z = tonumber(parts[b + 2]) + if not x or not z then + break + end + count = count + 1 + strokeDabX[count] = x + strokeDabZ[count] = z + strokeDabA[count] = tonumber(parts[b + 3]) or 0 + end + if count < 1 then + return + end + + -- Every plane of the tick is derived before any dab lands, so dabs in one + -- tick cannot compound on each other (see clayPlaneFor for the reference). + local doClay = clayMode and direction ~= 0 and direction ~= 2 + if doClay then + local rise = direction * HEIGHT_STEP * intensity + local stack = clayMode == 2 + for i = 1, count do + strokeClayPlane[i] = clayPlaneFor(strokeDabX[i], strokeDabZ[i], radius, rise, stack) + end + end + -- One batch for the tick: a single heightmap commit and a single undo entry + -- however many dabs the message carries. + beginBatch() + for i = 1, count do + applyTerraform( + strokeDabX[i], + strokeDabZ[i], + radius, + direction, + shape, + strokeDabA[i], + curve, + heightMin, + heightMax, + intensity, + lengthScale, + clayMode, + opacity, + flattenHeight, + instant, + localBlur, + localSmudge, + smudgeStart, + doClay and strokeClayPlane[i] or nil + ) + end + flushBatch() + -- One dust burst per tick rather than one per dab: up to 48 CEG spawns a tick + -- cost frames and looked no different. + if dustMode then + spawnDust(strokeDabX[count], strokeDabZ[count], radius, intensity) + end +end + +-- Hoisted handler: the RecvLuaMsg dispatcher sits near the 60-upvalue cap, so +-- the parse/clamp body lives here and the dispatcher only gains two upvalues. +local function handleAutoramp(payload) + local parts = parseParts(payload) + local centerX = tonumber(parts[1]) + local centerZ = tonumber(parts[2]) + local radius = tonumber(parts[3]) + local angleDeg = tonumber(parts[4]) or 60 + local falloffK = tonumber(parts[5]) or 0.5 + local edgeNoiseK = tonumber(parts[6]) or 0.35 + local erosionK = tonumber(parts[7]) or 0.35 + local talusK = tonumber(parts[8]) or 0.4 + local seed = tonumber(parts[9]) or 0 + local startMode = parts[10] + if not centerX or not centerZ or not radius then + return + end + radius = max(MIN_RADIUS, min(MAX_RADIUS, radius)) + angleDeg = max(10, min(85, angleDeg)) + falloffK = max(0, min(1, falloffK)) + edgeNoiseK = max(0, min(1, edgeNoiseK)) + erosionK = max(0, min(1, erosionK)) + talusK = max(0, min(1, talusK)) + seed = floor(max(0, min(9999, seed))) + if startMode ~= "extend" and startMode ~= "subtract" then + startMode = "average" + end + applyAutoramp(centerX, centerZ, radius, angleDeg, falloffK, edgeNoiseK, erosionK, talusK, seed, startMode) +end + -- ───────────────────────────────────────────────────────────────────────────── -- FILL BRUSH — Radial-ray rim detection + BFS basin + IDW curved fill -- @@ -2116,6 +2868,12 @@ local function applyFill(cx, cz) end function gadget:RecvLuaMsg(msg, playerID) + -- UPVALUE BUDGET: Recoil's Lua 5.1 caps a function at 60 upvalues and this + -- dispatcher sits near it (every header, helper, and state table it touches + -- counts once). Header lengths are therefore spelled #X_HEADER instead of + -- the X_HEADER_LENGTH locals -- same cost, no upvalue. When adding a + -- message branch, prefer hoisting its body into a local handler function. + -- -- Defensive: engine always passes a string, but a malformed caller or -- future API change could pass nil/non-string — avoid a traceback. if type(msg) ~= "string" or #msg == 0 then @@ -2123,9 +2881,9 @@ function gadget:RecvLuaMsg(msg, playerID) end -- Strip cheat-certification prefix embedded by the widget when cheat was on. -- Certified messages are trusted even when live cheat mode is false (e.g. in replays). - local certified = msg:sub(1, CHEAT_SIG_LEN) == CHEAT_SIG + local certified = msg:sub(1, #CHEAT_SIG) == CHEAT_SIG if certified then - msg = msg:sub(CHEAT_SIG_LEN + 1) + msg = msg:sub(#CHEAT_SIG + 1) end if msg == UNDO_HEADER then if not isTerraformAllowed(certified, playerID) then @@ -2271,16 +3029,17 @@ function gadget:RecvLuaMsg(msg, playerID) if msg == STROKE_END_HEADER then finalizeMerge() currentStrokeId = currentStrokeId + 1 + clearStrokeOrigin() return true end - if msg:sub(1, WARM_HEADER_LENGTH) == WARM_HEADER then + if msg:sub(1, #WARM_HEADER) == WARM_HEADER then -- Cache warm-up hint sent by the widget on tool/param change so the -- falloff stamp is built before the first apply of a stroke. Builds -- the same deterministic cache entry the apply would; never touches -- the heightmap or ringInnerRatio. Quiet gate: no echo spam. if mapDamageEnabled and isTerraformAllowed(certified, playerID) then - local parts = parseParts(msg:sub(WARM_HEADER_LENGTH + 1)) + local parts = parseParts(msg:sub(#WARM_HEADER + 1)) local radius = tonumber(parts[1]) if radius then local shape = parts[2] or "circle" @@ -2298,13 +3057,13 @@ function gadget:RecvLuaMsg(msg, playerID) return true end - if msg:sub(1, IMPORT_HEADER_LENGTH) == IMPORT_HEADER then + if msg:sub(1, #IMPORT_HEADER) == IMPORT_HEADER then if not isTerraformAllowed(certified, playerID) then echoGate("[Terraform Brush] Requires /cheat to be enabled (type /cheat or reactivate the tool)") return true end - local parts = parseParts(msg:sub(IMPORT_HEADER_LENGTH + 1)) + local parts = parseParts(msg:sub(#IMPORT_HEADER + 1)) local x = tonumber(parts[1]) if not x then return true @@ -2349,13 +3108,13 @@ function gadget:RecvLuaMsg(msg, playerID) return true end - if msg:sub(1, RESTORE_HEADER_LENGTH) == RESTORE_HEADER then + if msg:sub(1, #RESTORE_HEADER) == RESTORE_HEADER then if not isTerraformAllowed(certified, playerID) then echoGate("[Terraform Brush] Requires /cheat to be enabled (type /cheat or reactivate the tool)") return true end - local payload = msg:sub(RESTORE_HEADER_LENGTH + 1) + local payload = msg:sub(#RESTORE_HEADER + 1) local parts = parseParts(payload) local centerX = tonumber(parts[1]) @@ -2382,46 +3141,31 @@ function gadget:RecvLuaMsg(msg, playerID) return true end + if msg:sub(1, REMAP_HEADER_LENGTH) == REMAP_HEADER then + if not isTerraformAllowed(certified, playerID) then + echoGate("[Terraform Brush] Requires /cheat to be enabled (type /cheat or reactivate the tool)") + return true + end + local parts = parseParts(msg:sub(REMAP_HEADER_LENGTH + 1)) + local newMin = tonumber(parts[1]) + local newMax = tonumber(parts[2]) + if not (newMin and newMax) or newMax - newMin < 1 then + echoGate("[Terraform Brush] Height range needs a max at least 1 above the min.") + return true + end + remapMapHeights(newMin, newMax, parts[3] == "clamp") + return true + end + if msg == FULL_RESTORE_HEADER then if not isTerraformAllowed(certified, playerID) then echoGate("[Terraform Brush] Requires /cheat to be enabled (type /cheat or reactivate the tool)") return true end - finalizeMerge() local squareSize = Game.squareSize local mapSizeX = Game.mapSizeX local mapSizeZ = Game.mapSizeZ - -- Snapshot current heights into the scratch flat buffer for undo, then - -- convert to bbox-grid format. Full-map snapshots are the ideal case for - -- the orig-delta encoding: every cell that already matches its map - -- original gets mask=2 with no hgrid entry stored. - local snapFlat = scratchSnapFlat - local vCount = 0 - for iz = 0, mapSizeZ, squareSize do - for ix = 0, mapSizeX, squareSize do - local base = vCount * 3 - snapFlat[base + 1] = ix - snapFlat[base + 2] = iz - snapFlat[base + 3] = Spring.GetGroundHeight(ix, iz) - vCount = vCount + 1 - end - end - -- Clear redo, build bbox snapshot and push to undo (bypassing the per-call - -- vertex-cap check in pushSnapshotFromFlat — full-restore is intentional). - for i = 1, #redoStack do - totalVertexCount = totalVertexCount - (redoStack[i].vertexCount or 0) - end - redoStack = {} - local snapshot = flatToBboxSnapshot(snapFlat, vCount) - snapshot.strokeId = currentStrokeId - undoStack[#undoStack + 1] = snapshot - totalVertexCount = totalVertexCount + vCount - if #undoStack > MAX_UNDO then - local old = undoStack[1] - totalVertexCount = totalVertexCount - (old.vertexCount or 0) - table.remove(undoStack, 1) - end - evictOldSnapshots() + snapshotWholeMap() -- Apply original heights to all map points Spring.SetHeightMapFunc(function() for iz = 0, mapSizeZ, squareSize do @@ -2434,13 +3178,13 @@ function gadget:RecvLuaMsg(msg, playerID) return true end - if msg:sub(1, SPLINE_RAMP_HEADER_LENGTH) == SPLINE_RAMP_HEADER then + if msg:sub(1, #SPLINE_RAMP_HEADER) == SPLINE_RAMP_HEADER then if not isTerraformAllowed(certified, playerID) then echoGate("[Terraform Brush] Requires /cheat to be enabled (type /cheat or reactivate the tool)") return true end - local parts = parseParts(msg:sub(SPLINE_RAMP_HEADER_LENGTH + 1)) + local parts = parseParts(msg:sub(#SPLINE_RAMP_HEADER + 1)) local width = tonumber(parts[1]) local numPts = tonumber(parts[2]) @@ -2471,13 +3215,13 @@ function gadget:RecvLuaMsg(msg, playerID) return true end - if msg:sub(1, RAMP_HEADER_LENGTH) == RAMP_HEADER then + if msg:sub(1, #RAMP_HEADER) == RAMP_HEADER then if not isTerraformAllowed(certified, playerID) then echoGate("[Terraform Brush] Requires /cheat to be enabled (type /cheat or reactivate the tool)") return true end - local parts = parseParts(msg:sub(RAMP_HEADER_LENGTH + 1)) + local parts = parseParts(msg:sub(#RAMP_HEADER + 1)) local sX = tonumber(parts[1]) local sZ = tonumber(parts[2]) @@ -2504,13 +3248,13 @@ function gadget:RecvLuaMsg(msg, playerID) return true end - if msg:sub(1, NOISE_HEADER_LENGTH) == NOISE_HEADER then + if msg:sub(1, #NOISE_HEADER) == NOISE_HEADER then if not isTerraformAllowed(certified, playerID) then echoGate("[Terraform Brush] Requires /cheat to be enabled (type /cheat or reactivate the tool)") return true end - local payload = msg:sub(NOISE_HEADER_LENGTH + 1) + local payload = msg:sub(#NOISE_HEADER + 1) local parts = parseParts(payload) local centerX = tonumber(parts[1]) @@ -2565,13 +3309,13 @@ function gadget:RecvLuaMsg(msg, playerID) return true end - if msg:sub(1, ERODE_HEADER_LENGTH) == ERODE_HEADER then + if msg:sub(1, #ERODE_HEADER) == ERODE_HEADER then if not isTerraformAllowed(certified, playerID) then echoGate("[Terraform Brush] Requires /cheat to be enabled (type /cheat or reactivate the tool)") return true end - local payload = msg:sub(ERODE_HEADER_LENGTH + 1) + local payload = msg:sub(#ERODE_HEADER + 1) local parts = parseParts(payload) local centerX = tonumber(parts[1]) @@ -2600,12 +3344,12 @@ function gadget:RecvLuaMsg(msg, playerID) return true end - if msg:sub(1, FILL_HEADER_LENGTH) == FILL_HEADER then + if msg:sub(1, #FILL_HEADER) == FILL_HEADER then if not isTerraformAllowed(certified, playerID) then echoGate("[Terraform Brush] Requires /cheat to be enabled (type /cheat or reactivate the tool)") return true end - local parts = parseParts(msg:sub(FILL_HEADER_LENGTH + 1)) + local parts = parseParts(msg:sub(#FILL_HEADER + 1)) local fillX = tonumber(parts[1]) local fillZ = tonumber(parts[2]) if fillX and fillZ then @@ -2614,7 +3358,27 @@ function gadget:RecvLuaMsg(msg, playerID) return true end - if msg:sub(1, PACKET_HEADER_LENGTH) ~= PACKET_HEADER then + if msg:sub(1, #AUTORAMP_HEADER) == AUTORAMP_HEADER then + if not isTerraformAllowed(certified, playerID) then + echoGate("[Terraform Brush] Requires /cheat to be enabled (type /cheat or reactivate the tool)") + return true + end + handleAutoramp(msg:sub(#AUTORAMP_HEADER + 1)) + return true + end + + -- Header spelled inline, not via the STROKE_HEADER local: this dispatcher is + -- one upvalue under the Lua 5.1 cap of 60, and a string constant costs none. + if msg:sub(1, 18) == "$terraform_stroke$" then + if not isTerraformAllowed(certified, playerID) then + echoGate("[Terraform Brush] Requires /cheat to be enabled (type /cheat or reactivate the tool)") + return true + end + handleStroke(msg:sub(19)) + return true + end + + if msg:sub(1, #PACKET_HEADER) ~= PACKET_HEADER then return end @@ -2623,7 +3387,7 @@ function gadget:RecvLuaMsg(msg, playerID) return true end - local payload = msg:sub(PACKET_HEADER_LENGTH + 1) + local payload = msg:sub(#PACKET_HEADER + 1) local parts = parseParts(payload) local direction = tonumber(parts[1]) @@ -2637,13 +3401,17 @@ function gadget:RecvLuaMsg(msg, playerID) local heightMax = tonumber(parts[9]) local intensity = tonumber(parts[10]) or 1.0 local lengthScale = tonumber(parts[11]) or 1.0 - local clayMode = parts[12] == "1" + local clayMode = (parts[12] == "1" and 1) or (parts[12] == "2" and 2) or false local dustMode = parts[13] == "1" local opacity = tonumber(parts[14]) or 0.3 local instant = parts[15] == "1" -- "smooth" is a sentinel (not a number): smooth mode has no single flatten - -- target, the gadget computes one locally per cell instead. + -- target, the gadget computes one locally per cell instead. "smudge0"/ + -- "smudge1" ride the slot the same way; the digit marks a stroke-start dab + -- (the carried height buffer must re-grab there). local localBlur = parts[16] == "smooth" + local localSmudge = parts[16] ~= nil and parts[16]:sub(1, 6) == "smudge" + local smudgeStart = localSmudge and parts[16]:sub(7, 7) == "1" local flattenHeight = tonumber(parts[16]) if parts[17] then ringInnerRatio = max(0.05, min(0.95, tonumber(parts[17]) or 0.6)) @@ -2675,7 +3443,9 @@ function gadget:RecvLuaMsg(msg, playerID) opacity, flattenHeight, instant, - localBlur + localBlur, + localSmudge, + smudgeStart ) if dustMode then spawnDust(centerX, centerZ, radius, intensity) diff --git a/luarules/gadgets/cus_gl4.lua b/luarules/gadgets/cus_gl4.lua index 02e3aa5f8bc..192a373f544 100644 --- a/luarules/gadgets/cus_gl4.lua +++ b/luarules/gadgets/cus_gl4.lua @@ -226,6 +226,8 @@ local autoReload = { enabled = false, vssrc = "", fssrc = "", lastUpdate = Sprin -- Indicates whether the first round of getting units should grab all instead of delta local manualReload = autoReload.enabled or false +local printfPass = "forward" -- Chose which pass to print debug information for. Can be any of "forward", "shadow", "deferred", "reflection" +local printfMaterial = "unit" local debugmode = false local perfdebug = false @@ -495,10 +497,6 @@ local objectTypeAttribID = 6 -- this is the attribute index for instancedata in local initiated = false -local function Bit(p) - return 2 ^ (p - 1) -- 1-based indexing -end - -- Typical call: if hasbit(x, bit(3)) then ... local function HasBit(x, p) return x % (p + p) >= p @@ -509,14 +507,6 @@ local function HasAllBits(x, p) return math_bit_and(x, p) == p end -local function SetBit(x, p) - return HasBit(x, p) and x or x + p -end - -local function ClearBit(x, p) - return HasBit(x, p) and x - p or x -end - -- Precomputed bin membership for every possible drawFlag below 128 (the icon threshold). -- drawBinKeys and overrideDrawFlagsCombined are static after load, so which bins a drawFlag -- maps to can be looked up instead of redoing math.bit_and calls per object per bin key. @@ -693,8 +683,6 @@ end local LuaShader = gl.LuaShader -local engineUniformBufferDefs = LuaShader.GetEngineUniformBufferDefs() - local QUATERNIONDEFS = "" if Engine.FeatureSupport.transformsInGL4 then QUATERNIONDEFS = LuaShader.GetQuaternionDefs() @@ -844,19 +832,13 @@ local DEFAULT_VERSION = [[#version 430 core #extension GL_ARB_shading_language_420pack: require ]] -local function dumpShaderCodeToFile(defs, src, filename) -- no IO in unsynced gadgets :/ - local vsfile = io.open("cus_" .. filename .. ".glsl", "w+") - vsfile:write(defs .. src) - vsfile:close() -end - local function dumpShaderCodeToInfolog(defs, src, filename) -- no IO in unsynced gadgets :/ Spring.Echo(filename) Spring.Echo(defs) Spring.Echo(src) end -local function CompileLuaShader(shader, definitions, plugIns, addName, recompilation) +local function CompileLuaShader(shader, definitions, plugIns, addName, recompilation, stripPrintf) --Spring.Echo(" CompileLuaShader",shader, definitions, plugIns, addName) if definitions == nil or definitions == {} then Spring.Echo(addName, "nul definitions", definitions) @@ -875,9 +857,6 @@ local function CompileLuaShader(shader, definitions, plugIns, addName, recompila -- First the default default defs shader.definitions = table.concat(definitions, "\n") .. "\n" - -- Then the engineUniformBufferDefs (see LuaShader.lua) - shader.definitions = shader.definitions .. engineUniformBufferDefs - --// insert small pieces of code named `plugins` --// this way we can use a basic shader and add some simple vertex animations etc. do @@ -902,9 +881,22 @@ local function CompileLuaShader(shader, definitions, plugIns, addName, recompila end end - local luaShader = LuaShader(shader, "CUS_" .. addName) - local compilationResult = luaShader:Initialize() - if compilationResult ~= true then + local function CompleteSource(source) + return source and (shader.definitions .. source) + end + + local luaShader = LuaShader.CheckShaderUpdates({ + vsSrc = CompleteSource(shader.vertex), + fsSrc = CompleteSource(shader.fragment), + gsSrc = CompleteSource(shader.geometry), + shaderConfig = { stripPrintf = stripPrintf }, + shaderName = "CUS_" .. addName, + uniformInt = shader.uniformInt, + uniformFloat = shader.uniformFloat, + forceupdate = true, + silent = true, + }, 0) + if not luaShader then Spring.Echo("Custom Unit Shaders. " .. addName .. " shader compilation failed") --dumpShaderCodeToInfolog(shader.definitions, shader.vertex, "vs" .. addName) --dumpShaderCodeToInfolog(shader.definitions, shader.fragment, "fs" .. addName) @@ -914,9 +906,16 @@ local function CompileLuaShader(shader, definitions, plugIns, addName, recompila return nil end - return (compilationResult and luaShader) or nil + luaShader.ignoreUnkUniform = false + return luaShader end +-- {shaderName : {textureUnit : true}}: the texture units the shadow pass has to bind for a +-- material. The shadow shaders never sample anything except texture2 (alpha test, unit 1), +-- and only when HASALPHASHADOWS is defined, so every other gl.Texture call in that pass +-- (tex1, normal map, shadow map, reflection, info, BRDF LUT, noise) is wasted engine time. +local shadowPassTextureUnits = {} + local function compileMaterialShader(template, name, recompilation) --Spring.Echo("Compiling", template, name) local forwardShader = CompileLuaShader( @@ -924,28 +923,32 @@ local function compileMaterialShader(template, name, recompilation) template.shaderDefinitions, template.shaderPlugins, name .. "_forward", - recompilation + recompilation, + printfPass ~= "forward" or printfMaterial ~= name ) local shadowShader = CompileLuaShader( template.shadow, template.shadowDefinitions, template.shaderPlugins, name .. "_shadow", - recompilation + recompilation, + printfPass ~= "shadow" or printfMaterial ~= name ) local deferredShader = CompileLuaShader( template.deferred, template.deferredDefinitions, template.shaderPlugins, name .. "_deferred", - recompilation + recompilation, + printfPass ~= "deferred" or printfMaterial ~= name ) local reflectionShader = CompileLuaShader( template.reflection, template.reflectionDefinitions, template.shaderPlugins, name .. "_reflection", - recompilation + recompilation, + printfPass ~= "reflection" or printfMaterial ~= name ) if recompilation then if (not forwardShader) or not shadowShader or not deferredShader or not reflectionShader then @@ -961,6 +964,14 @@ local function compileMaterialShader(template, name, recompilation) shaders[0][name] = deferredShader shaders[5][name] = reflectionShader shaders[16][name] = shadowShader + + local shadowNeedsAlphaTex = false + for _, defline in ipairs(template.shadowDefinitions or {}) do + if type(defline) == "string" and defline:find("#define%s+HASALPHASHADOWS") then + shadowNeedsAlphaTex = true + end + end + shadowPassTextureUnits[name] = shadowNeedsAlphaTex and { [1] = true } or {} return true end @@ -1225,7 +1236,7 @@ local function initBinsAndTextures() or (lowercasenormaltex:find("leg_normal") and "unittextures/leg_wreck_normal.dds") or false - if unitDef.name:find("_scav", nil, true) then -- it better be a scavenger unit, or ill kill you + if unitDef.customParams.isscavenger then textureTable[3] = wreckTex1 textureTable[4] = wreckTex2 textureTable[5] = wreckNormalTex @@ -1236,7 +1247,7 @@ local function initBinsAndTextures() elseif factionBinTag == "leg" then objectDefToUniformBin[unitDefID] = "legscavenger" end - elseif unitDef.name:find("raptor", nil, true) or unitDef.name:find("raptor_hive", nil, true) then + elseif unitDef.customParams.israptor then textureTable[5] = wreckAtlases.raptor[1] objectDefToUniformBin[unitDefID] = "raptor" --Spring.Echo("Raptorwreck", textureTable[5]) @@ -2262,10 +2273,6 @@ local function ProcessUnits(units, drawFlags, reason) end end end -local spValidFeatureID = Spring.ValidFeatureID -local spSetFeatureEngineDrawMask = Spring.SetFeatureEngineDrawMask -local spSetFeatureNoDraw = Spring.SetFeatureNoDraw -local spSetFeatureFade = Spring.SetFeatureFade local function ProcessFeatures(features, drawFlags, reason) local numFeatures = #features @@ -2388,6 +2395,8 @@ local function ExecuteDrawPass(drawPass) tracy.ZoneEnd() local shaderTable = shaders[drawPass][shaderName] + -- shadow pass: bind only the units its shader samples (see shadowPassTextureUnits) + local wantedTextureUnits = (drawPass == 16) and shadowPassTextureUnits[shaderName] or nil if unitscountforthisshader > 0 then tracy.ZoneBeginN("G:CUS:ExecuteDrawPass:ShaderActivate") @@ -2448,7 +2457,7 @@ local function ExecuteDrawPass(drawPass) tracy.ZoneBeginN("G:CUS:ExecuteDrawPass:BindTextures") end for bindPosition, tex in pairs(texAndObj.textures) do - if lastBoundTextures[bindPosition] ~= tex then + if (wantedTextureUnits == nil or wantedTextureUnits[bindPosition]) and lastBoundTextures[bindPosition] ~= tex then gl.Texture(bindPosition, tex) lastBoundTextures[bindPosition] = tex end @@ -2885,28 +2894,6 @@ end local updateframe = 0 -local function countbintypes(flagarray) - local fwcnt = 0 - local defcnt = 0 - local reflcnt = 0 - local shadcnt = 0 - - for i = 1, #flagarray do - local flag = flagarray[i] - if HasBit(flag, 1) then - fwcnt = fwcnt + 1 - defcnt = defcnt + 1 - end - if HasBit(flag, 4) then - reflcnt = reflcnt + 1 - end - if HasBit(flag, 16) then - shadcnt = shadcnt + 1 - end - end - return fwcnt, defcnt, reflcnt, shadcnt -end - local destroyedUnitIDs = {} -- maps unitID to drawflag local destroyedUnitDrawFlags = {} local numdestroyedUnits = 0 @@ -3232,3 +3219,19 @@ function gadget:DrawShadowUnitsLua() local batches, units = ExecuteDrawPass(16) tracy.ZoneEnd() end + +if autoReload.enabled then + function gadget:DrawScreen() + --Spring.Echo("DrawScreen Called") + local yoffset = 0 + for drawflag, drawpass in pairs(shaders) do + for binname, shader in pairs(drawpass) do + --Spring.Echo("DrawScreen:", drawflag, binname, "has drawprintf", shader.DrawPrintf ~= nil) + if shader.DrawPrintf then + shader.DrawPrintf(0, yoffset) + yoffset = yoffset + 24 + end + end + end + end +end \ No newline at end of file diff --git a/luarules/gadgets/data_camera_broadcast.lua b/luarules/gadgets/data_camera_broadcast.lua index 2c9e2f4eeb4..07ec399bce7 100644 --- a/luarules/gadgets/data_camera_broadcast.lua +++ b/luarules/gadgets/data_camera_broadcast.lua @@ -22,7 +22,6 @@ local broadcastPeriodScalingEnd = 32 -- when reaches maxBroadcastPeriod local PACKET_HEADER = "=" if gadgetHandler:IsSyncedCode() then - local strSub = string.sub local validation = string.randomString(2) _G.validationCam = validation diff --git a/luarules/gadgets/dbg_gadget_profiler.lua b/luarules/gadgets/dbg_gadget_profiler.lua index 1f1273d61ff..a87f2145b71 100644 --- a/luarules/gadgets/dbg_gadget_profiler.lua +++ b/luarules/gadgets/dbg_gadget_profiler.lua @@ -390,9 +390,7 @@ else local columnReserve = 0 -- width reserved left of column 0 for the detail panel (0 when none) local detailColour = "\255\255\255\255" - local timersSynced = {} local startTickTimer - local memUsageSynced = {} local function SetDrawCallin(drawCallin) -- when the profiler isn't running, the profiler gadget should have *no* draw callin @@ -481,8 +479,6 @@ else selectedCallinAvgs = {} startTickTimer = nil - timersSynced = {} - memUsageSynced = {} ProfilerEcho("luarules profiler killed (player " .. pID .. ")") end @@ -520,7 +516,6 @@ else end end - local totalLoads = {} local allOverTimeSec = 0 -- currently unused -------------------------------------------------------------------------------- @@ -601,7 +596,6 @@ else end local function ProcessCallinStats(stats, timeLoadAvgs, spaceloadAvgs, redStr, deltaTime, isSynced) - totalLoads = {} local allOverTime = 0 local allOverSpace = 0 local n = 1 @@ -868,7 +862,6 @@ else -- Cache format strings local noDataColor = "\255\200\200\200" - local maxnameColor = "\255\200\200\200" local function DrawSortedList(list, name, isSynced) NewSection(name) diff --git a/luarules/gadgets/game_autocolors.lua b/luarules/gadgets/game_autocolors.lua index d1dd32390bb..63f6a1f2168 100644 --- a/luarules/gadgets/game_autocolors.lua +++ b/luarules/gadgets/game_autocolors.lua @@ -23,6 +23,7 @@ local survivalColorNum = 1 -- Starting from color #1 local survivalColorVariation = 0 -- Current color variation local allyTeamNum = 0 local teamSizes = {} +local dimmingCount = {} local myAllyTeamID, myTeamID if not gadgetHandler:IsSyncedCode() then @@ -386,13 +387,28 @@ local teamColors = { }, } -local r = math.random(1, 100000) -math.randomseed(1) -- make sure the next sequence of randoms can be reproduced +-- Per-team random offsets for the gradient color modes. Every client, and the synced copy of this +-- gadget (which feeds the replay site), has to end up with exactly the same values, so this must not +-- use math.random: in unsynced Lua that is the engine's unsynced RNG, whose stream is salted with a +-- memory address (ASLR), so math.randomseed(1) gives a different sequence on every client. In synced +-- Lua math.randomseed would reseed the game's RNG instead. A tiny fixed-seed generator avoids both. local teamRandoms = {} -for i = 1, #teamList do - teamRandoms[teamList[i]] = { math.random(), math.random(), math.random() } +do + local state = 65432 -- arbitrary fixed seed + -- Park-Miller minimal standard LCG; every intermediate stays below 2^53, so it is exact in doubles + local function nextRandom() + state = (state * 16807) % 2147483647 + return state / 2147483647 + end + for i = 1, #teamList do + teamRandoms[teamList[i]] = { nextRandom(), nextRandom(), nextRandom() } + end +end + +-- deterministic stand-in for math.random(-variation, variation), derived from the team's fixed randoms +local function teamColorVariation(teamID, channel, variation) + return math.floor(teamRandoms[teamID][channel] * (variation * 2 + 1)) - variation end -math.randomseed(r) local iconDevModeColors = { armblue = armBlueColor, @@ -551,11 +567,11 @@ local function setupTeamColor(teamID, allyTeamID, isAI, localRun) elseif isSurvival and survivalColors[(#Spring.GetTeamList()) - 2] then teamColorsTable[teamID] = { r = hex2RGB(survivalColors[survivalColorNum])[1] - + math.random(-survivalColorVariation, survivalColorVariation), + + teamColorVariation(teamID, 1, survivalColorVariation), g = hex2RGB(survivalColors[survivalColorNum])[2] - + math.random(-survivalColorVariation, survivalColorVariation), + + teamColorVariation(teamID, 2, survivalColorVariation), b = hex2RGB(survivalColors[survivalColorNum])[3] - + math.random(-survivalColorVariation, survivalColorVariation), + + teamColorVariation(teamID, 3, survivalColorVariation), } survivalColorNum = survivalColorNum + 1 -- Will start from the next color next time @@ -652,11 +668,11 @@ local function setupTeamColor(teamID, allyTeamID, isAI, localRun) -- Assigning R,G,B values with specified color variations teamColorsTable[teamID] = { r = hex2RGB(teamColors[allyTeamCount][teamSizes[allyTeamID][1]][teamSizes[allyTeamID][2]])[1] - + math.random(-teamSizes[allyTeamID][3], teamSizes[allyTeamID][3]), + + teamColorVariation(teamID, 1, teamSizes[allyTeamID][3]), g = hex2RGB(teamColors[allyTeamCount][teamSizes[allyTeamID][1]][teamSizes[allyTeamID][2]])[2] - + math.random(-teamSizes[allyTeamID][3], teamSizes[allyTeamID][3]), + + teamColorVariation(teamID, 2, teamSizes[allyTeamID][3]), b = hex2RGB(teamColors[allyTeamCount][teamSizes[allyTeamID][1]][teamSizes[allyTeamID][2]])[3] - + math.random(-teamSizes[allyTeamID][3], teamSizes[allyTeamID][3]), + + teamColorVariation(teamID, 3, teamSizes[allyTeamID][3]), } teamSizes[allyTeamID][2] = teamSizes[allyTeamID][2] + 1 -- Will start from the next color next time else diff --git a/luarules/gadgets/game_awards.lua b/luarules/gadgets/game_awards.lua index 5b42013cd5c..aa1d5385789 100644 --- a/luarules/gadgets/game_awards.lua +++ b/luarules/gadgets/game_awards.lua @@ -22,38 +22,28 @@ if gadgetHandler:IsSyncedCode() then local coopInfo = {} local present = {} - local isEcon = { - --land t1 - [UnitDefNames.armsolar.id] = true, - [UnitDefNames.corsolar.id] = true, - [UnitDefNames.armadvsol.id] = true, - [UnitDefNames.coradvsol.id] = true, - [UnitDefNames.armwin.id] = true, - [UnitDefNames.corwin.id] = true, - [UnitDefNames.armmakr.id] = true, - [UnitDefNames.cormakr.id] = true, - --sea t1 - [UnitDefNames.armtide.id] = true, - [UnitDefNames.cortide.id] = true, - [UnitDefNames.armfmkr.id] = true, - [UnitDefNames.corfmkr.id] = true, - --land t2 - [UnitDefNames.armmmkr.id] = true, - [UnitDefNames.cormmkr.id] = true, - [UnitDefNames.corfus.id] = true, - [UnitDefNames.armfus.id] = true, - [UnitDefNames.armafus.id] = true, - [UnitDefNames.corafus.id] = true, - --sea t2 - [UnitDefNames.armuwfus.id] = true, - [UnitDefNames.coruwfus.id] = true, - [UnitDefNames.armuwmmm.id] = true, - [UnitDefNames.coruwmmm.id] = true, - } + -- economy structures (energy generators, geothermals and converters), derived from def + -- properties; storage produces nothing and drops out naturally, lootboxes and scav + -- beacons are excluded via their paratrooper tag. + -- thresholds mirror the economy classification in snd_notifications.lua: every dedicated + -- generator makes at least 20 energy (the margin keeps future units with incidental + -- trickle production out) and must not be a net energy consumer at the same time + local MIN_GENERATOR_ENERGY_MAKE = 20 + local MAX_GENERATOR_ENERGY_UPKEEP = 10 + local isEcon = {} for udid, ud in pairs(UnitDefs) do - for id, v in pairs(isEcon) do - if string.find(ud.name, UnitDefs[id].name) then - isEcon[udid] = v + local cp = ud.customParams + if ud.speed == 0 and not cp.israptor and not cp.paratrooper then + local isGenerator = ud.energyMake >= MIN_GENERATOR_ENERGY_MAKE + and (not ud.energyUpkeep or ud.energyUpkeep < MAX_GENERATOR_ENERGY_UPKEEP) + if + isGenerator + or ud.windGenerator > 0 + or ud.tidalGenerator > 0 + or cp.solar + or (cp.energyconv_capacity and cp.energyconv_efficiency) + then + isEcon[udid] = true end end end diff --git a/luarules/gadgets/game_commander_builder.lua b/luarules/gadgets/game_commander_builder.lua index 8dc8bceabe3..213905e9463 100644 --- a/luarules/gadgets/game_commander_builder.lua +++ b/luarules/gadgets/game_commander_builder.lua @@ -7,7 +7,17 @@ then spawnpadSpawnEnabled = true end -if not UnitDefNames.armrespawn then +-- commanders carrying customparams.spawnpad_unit get a builder pad spawned next to them; +-- scav copies inherit the param but never spawn via this path, so they are excluded outright +local spawnpads = {} +for unitDefID, unitDef in pairs(UnitDefs) do + local pad = unitDef.customParams.spawnpad_unit + if pad and UnitDefNames[pad] and not unitDef.customParams.isscavenger then + spawnpads[unitDefID] = pad + end +end + +if not next(spawnpads) then spawnpadSpawnEnabled = false end @@ -29,22 +39,12 @@ function gadget:GetInfo() } end -local UDN = UnitDefNames - if not gadgetHandler:IsSyncedCode() then return false end local positionCheckLibrary = VFS.Include("luarules/utilities/damgam_lib/position_checks.lua") -local spawnpads = { - [UDN.armcom.id] = "armrespawn", - [UDN.corcom.id] = "correspawn", -} -if Spring.GetModOptions().experimentallegionfaction then - spawnpads[UDN.legcom.id] = "legnanotcbase" -end - local spawnFrame = Game.spawnWarpInFrame + Game.gameSpeed * 2 -- add time to deconflict initial build orders function SpawnAssistTurret(unitID, unitDefID, unitTeam) diff --git a/luarules/gadgets/game_critters.lua b/luarules/gadgets/game_critters.lua index 450c6a35cab..a7cb009ae3e 100644 --- a/luarules/gadgets/game_critters.lua +++ b/luarules/gadgets/game_critters.lua @@ -22,7 +22,7 @@ local isCommander = {} local isFlyingCritter = {} for unitDefID, unitDef in pairs(UnitDefs) do - if string.sub(unitDef.name, 1, 7) == "critter" then + if unitDef.customParams.iscritter then isCritter[unitDefID] = true if unitDef.canFly then isFlyingCritter[unitDefID] = true @@ -61,7 +61,6 @@ local GetUnitPosition = Spring.GetUnitPosition local GetUnitDefID = Spring.GetUnitDefID local GiveOrderToUnit = Spring.GiveOrderToUnit local CreateUnit = Spring.CreateUnit -local GetUnitTeam = Spring.GetUnitTeam local ValidUnitID = Spring.ValidUnitID local random = math.random @@ -81,8 +80,6 @@ local companionRadius = companionRadiusStart local processOrders = true local addedInitialCritters -local ownCritterDestroy = false - local function randomPatrolInBox(unitID, box, minWaterDepth) -- only define minWaterDepth if unit is a submarine local ux, _, uz = GetUnitPosition(unitID, true, true) local orders = 6 @@ -136,11 +133,6 @@ local function randomPatrolInBox(unitID, box, minWaterDepth) -- only define minW end end -local function in_circle(center_x, center_y, radius, x, y) - local square_dist = ((center_x - x) * (center_x - x)) + ((center_y - y) * (center_y - y)) - return square_dist <= radius * radius -end - -- doing multiple orders per unit gives errors, so doing 1 per gameframe is best local function processSceduledOrders() processOrders = false diff --git a/luarules/gadgets/game_dynamic_maxunits.lua b/luarules/gadgets/game_dynamic_maxunits.lua index 5ac07695b33..eb400dfd445 100644 --- a/luarules/gadgets/game_dynamic_maxunits.lua +++ b/luarules/gadgets/game_dynamic_maxunits.lua @@ -184,7 +184,6 @@ function gadget:Initialize() if allyID ~= gaiaAllyTeamID then local teams = Spring.GetTeamList(allyID) local aliveTeams = {} - local hasScavRaptor = false for _, teamID in ipairs(teams) do if teamID ~= gaiaTeamID then local _, _, isDead = Spring.GetTeamInfo(teamID, false) diff --git a/luarules/gadgets/game_end.lua b/luarules/gadgets/game_end.lua index 0c80916dec3..84f7830f8ea 100644 --- a/luarules/gadgets/game_end.lua +++ b/luarules/gadgets/game_end.lua @@ -240,7 +240,12 @@ if gadgetHandler:IsSyncedCode() then end function gadget:Initialize() - if Spring.GetModOptions().deathmode == "neverend" then + -- editor_sandbox=1 is the map editor's start-script flag (New Map / Open + -- Project): the session must never end, whatever deathmode it inherited. + if + Spring.GetModOptions().deathmode == "neverend" + or tostring(Spring.GetModOptions().editor_sandbox or "") == "1" + then gadgetHandler:RemoveGadget(self) return end @@ -365,7 +370,7 @@ if gadgetHandler:IsSyncedCode() then return false end - -- all the allyteams alive are bidirectionally allied against eachother, they are all winners + -- all the allyteams alive are bidirectionally allied against each other, they are all winners --local winnersCorrectFormat = {} local winnersCorrectFormatCount = 0 for winner in pairs(sharedWinnerScratch) do diff --git a/luarules/gadgets/game_initial_spawn.lua b/luarules/gadgets/game_initial_spawn.lua index 5d4d9b37754..d609c82a2a2 100644 --- a/luarules/gadgets/game_initial_spawn.lua +++ b/luarules/gadgets/game_initial_spawn.lua @@ -422,6 +422,18 @@ if gadgetHandler:IsSyncedCode() then ---------------------------------------------------------------- -- Startpoints ---------------------------------------------------------------- + local function hasBlockingFeature(x, z, unitDefID) + local halfFootprint = UnitDefs[unitDefID].xsize * Game.squareSize / 2 + local features = + Spring.GetFeaturesInRectangle(x - halfFootprint, z - halfFootprint, x + halfFootprint, z + halfFootprint) + for i = 1, #features do + if Spring.GetFeatureBlocking(features[i]) then + return true + end + end + return false + end + local _unitType = {} --- @return boolean untraversable if the unit can not traverse the passed in x/z position local function isFootingUntraversable(x, y, z, unitDefID) @@ -439,12 +451,12 @@ if gadgetHandler:IsSyncedCode() then if type == 2 then return not ( - Spring.TestMoveOrder(unitDefID, x, y, z) - and Spring.TestMoveOrder(unitDefID, x, y, z, 1, 0, 0) - and Spring.TestMoveOrder(unitDefID, x, y, z, 0, 0, 1) - and Spring.TestMoveOrder(unitDefID, x, y, z, -1, 0, 0) - and Spring.TestMoveOrder(unitDefID, x, y, z, 0, 0, -1) - ) + Spring.TestMoveOrder(unitDefID, x, y, z, 0, 0, 0, true, false) + and Spring.TestMoveOrder(unitDefID, x, y, z, 1, 0, 0, true, false) + and Spring.TestMoveOrder(unitDefID, x, y, z, 0, 0, 1, true, false) + and Spring.TestMoveOrder(unitDefID, x, y, z, -1, 0, 0, true, false) + and Spring.TestMoveOrder(unitDefID, x, y, z, 0, 0, -1, true, false) + ) or hasBlockingFeature(x, z, unitDefID) end return Spring.TestBuildOrder(unitDefID, x, y, z, "s") == 0 @@ -554,31 +566,6 @@ if gadgetHandler:IsSyncedCode() then return true end - local function setPermutedSpawns(nSpawns, idsToSpawn) - -- this function assumes that idsToSpawn is a hash table with nSpawns elements - -- returns a bijective random map from key values of idsToSpawn to [1,...,nSpawns] - - -- first, construct a random permutation of [1,...,nSpawns] using a Knuth shuffle - local perm = {} - for i = 1, nSpawns do - perm[i] = i - end - for i = 1, nSpawns - 1 do - local j = math.random(i, nSpawns) - local temp = perm[i] - perm[i] = perm[j] - perm[j] = temp - end - - local permutedSpawns = {} - local slot = 1 - for id, _ in pairs(idsToSpawn) do - permutedSpawns[id] = perm[slot] - slot = slot + 1 - end - return permutedSpawns - end - local startUnitList = {} local startUnitBlocking = {} -- Shared with gadget:GameFrame below, which gates the commander spawn-in @@ -606,6 +593,14 @@ if gadgetHandler:IsSyncedCode() then end end + -- Map editor sessions (New Map / Open Project) start with editor_sandbox=1 + -- in the start script: the map maker edits an empty canvas or a project's + -- own unit loadout, so no team gets a commander. Reuses the scenario + -- path so the spawn effects and warp-in skip as well. + if not scenarioSpawnsUnits and tostring(Spring.GetModOptions().editor_sandbox or "") == "1" then + scenarioSpawnsUnits = true + end + if not scenarioSpawnsUnits then if not (luaAI and (string.find(luaAI, "Scavengers") or luaAI == "RaptorsAI")) then local unitID = spCreateUnit(startUnit, x, y, z, 0, teamID) @@ -737,7 +732,6 @@ if gadgetHandler:IsSyncedCode() then end end - local lastGameFrame = 0 function gadget:GameFrame(n) if not scenarioSpawnsUnits then if n == spawnInitialFrame then @@ -767,17 +761,6 @@ if gadgetHandler:IsSyncedCode() then end end end - -- for debug purpose - -- if GG.SpawnEnvironmentalLightning then - -- if n > lastGameFrame then - -- lastGameFrame = n + 150 - -- for _, unitID in ipairs(Spring.GetAllUnits()) do - -- local x, y, z = Spring.GetUnitPosition(unitID) - -- GG.SpawnEnvironmentalLightning("commanderspawn", x, y, z) - -- Spring.SpawnCEG("commander-spawn", x, y, z, 0, 0, 0) - -- end - -- end - -- end if n > spawnWarpInFrame then gadgetHandler:RemoveGadget(self) end diff --git a/luarules/gadgets/game_no_rush_mode.lua b/luarules/gadgets/game_no_rush_mode.lua index 971c5b0ceb8..69f0da2f962 100644 --- a/luarules/gadgets/game_no_rush_mode.lua +++ b/luarules/gadgets/game_no_rush_mode.lua @@ -82,6 +82,16 @@ end if gadgetHandler:IsSyncedCode() then local rushTimerComplete = false + + -- Environmental damage is not an attack, so it is never negated inside the startbox: + -- lava (map_lava.lua), drowning and water fall damage (unit_water_depth_damage.lua) use the + -- engine water damage type, and ground/object collisions are fall damage. + local environmentalDamageTypes = { + [Game.envDamageTypes.Water] = true, + [Game.envDamageTypes.GroundCollision] = true, + [Game.envDamageTypes.ObjectCollision] = true, + } + function gadget:Initialize() gadgetHandler:RegisterAllowCommand(CMD.BUILD) @@ -176,7 +186,7 @@ if gadgetHandler:IsSyncedCode() then attackerDefID, attackerTeam ) - if not isNoRushRestricted() then + if not isNoRushRestricted() or environmentalDamageTypes[weaponID] then return end -- compare (damaged) unit location to allyTeam startboxes and negate damage if they are in their box diff --git a/luarules/gadgets/game_prevent_excessive_share.lua b/luarules/gadgets/game_prevent_excessive_share.lua index 23ff95c9242..a063f41d12f 100644 --- a/luarules/gadgets/game_prevent_excessive_share.lua +++ b/luarules/gadgets/game_prevent_excessive_share.lua @@ -27,7 +27,7 @@ local spGetTeamUnitCount = Spring.GetTeamUnitCount ---------------------------------------------------------------- function gadget:AllowResourceTransfer(senderTeamId, receiverTeamId, resourceType, amount) -- Spring uses 'm' and 'e' instead of the full names that we need, so we need to convert the resourceType - -- We also check for 'metal' or 'energy' incase Spring decides to use those in a later version + -- We also check for 'metal' or 'energy' in case Spring decides to use those in a later version local resourceName if (resourceType == "m") or (resourceType == "metal") then resourceName = "metal" diff --git a/luarules/gadgets/game_preventcombomb.lua b/luarules/gadgets/game_preventcombomb.lua index 895f00cb3ca..abdec4922bc 100644 --- a/luarules/gadgets/game_preventcombomb.lua +++ b/luarules/gadgets/game_preventcombomb.lua @@ -26,7 +26,6 @@ local MoveCtrlEnable = Spring.MoveCtrl.Enable local MoveCtrlDisable = Spring.MoveCtrl.Disable local MoveCtrlSetPosition = Spring.MoveCtrl.SetPosition local GetGameFrame = Spring.GetGameFrame -local DestroyUnit = Spring.DestroyUnit local GetUnitTeam = Spring.GetUnitTeam local math_random = math.random diff --git a/luarules/gadgets/game_quick_start.lua b/luarules/gadgets/game_quick_start.lua index fbbb82bcd09..22224b7d4fc 100644 --- a/luarules/gadgets/game_quick_start.lua +++ b/luarules/gadgets/game_quick_start.lua @@ -124,7 +124,6 @@ local spGetUnitIsDead = Spring.GetUnitIsDead local spGetUnitDefID = Spring.GetUnitDefID local spGetUnitTeam = Spring.GetUnitTeam local spGetUnitHealth = Spring.GetUnitHealth -local spTestMoveOrder = Spring.TestMoveOrder local random = math.random local ceil = math.ceil local max = math.max @@ -142,11 +141,19 @@ local config = VFS.Include("LuaRules/Configs/quick_start_build_defs.lua") local traversabilityGrid = VFS.Include("common/traversability_grid.lua") local overlapLines = VFS.Include("common/overlap_lines.lua") local commanderNonLabOptions = config.commanderNonLabOptions -local discountableFactories = config.discountableFactories local optionsToNodeType = config.optionsToNodeType local unitDefs = UnitDefs local unitDefNames = UnitDefNames +-- factories carrying customparams.quickstart_discountable earn the quick-start factory +-- discount; scav copies are excluded so their altered costs can't lower FACTORY_DISCOUNT +local discountableFactories = {} +for unitDefID, unitDef in pairs(unitDefs) do + if unitDef.isFactory and unitDef.customParams.quickstart_discountable and not unitDef.customParams.isscavenger then + discountableFactories[unitDefID] = true + end +end + local gameFrameTryCount = 0 local initialized = false local isGoodWind = false @@ -274,11 +281,9 @@ for unitDefID, unitDef in pairs(unitDefs) do boostableCommanders[unitDefID] = true end end -for name, _ in pairs(discountableFactories) do - if unitDefNames[name] then - local labBudget = defMetergies[unitDefNames[name].id] - FACTORY_DISCOUNT = min(FACTORY_DISCOUNT, customRound(labBudget * FACTORY_DISCOUNT_MULTIPLIER)) - end +for unitDefID, _ in pairs(discountableFactories) do + local labBudget = defMetergies[unitDefID] + FACTORY_DISCOUNT = min(FACTORY_DISCOUNT, customRound(labBudget * FACTORY_DISCOUNT_MULTIPLIER)) end for commanderName, nonLabOptions in pairs(commanderNonLabOptions) do if unitDefNames[commanderName] then @@ -375,9 +380,7 @@ local function getCommanderBuildQueue(commanderID) generateOverlapLines(commanderID) end - Spring.Echo(string.format("=== Validating Build Queue for Commander %d (Team %d) ===", commanderID, comData.teamID)) - - for i, cmd in ipairs(commands) do + for _, cmd in ipairs(commands) do if isBuildCommand(cmd.id) then local unitDefID = -cmd.id local spawnParams = { @@ -389,7 +392,6 @@ local function getCommanderBuildQueue(commanderID) cmdTag = cmd.tag, } local unitDef = unitDefs[unitDefID] - local unitDefName = unitDef and unitDef.name or "UNKNOWN" local distance = distance2d(comData.spawnX, comData.spawnZ, spawnParams.x, spawnParams.z) local isTraversable = traversabilityGrid.canMoveToPosition( commanderID, @@ -405,14 +407,6 @@ local function getCommanderBuildQueue(commanderID) comData.overlapLines ) - local validationResults = { - distanceCheck = distance <= INSTANT_BUILD_RANGE, - traversableCheck = isTraversable, - notPastLinesCheck = not isPastFriendlyLines, - distance = distance, - maxDistance = INSTANT_BUILD_RANGE, - } - if distance <= INSTANT_BUILD_RANGE and isTraversable and not isPastFriendlyLines then local budgetCost = defMetergies[unitDefID] or 0 @@ -432,19 +426,6 @@ local function getCommanderBuildQueue(commanderID) totalBudgetCost = totalBudgetCost + budgetCost if totalBudgetCost > comData.budget then - Spring.Echo( - string.format( - " [%d] %s at (%.1f, %.1f, %.1f) facing: %d - REJECTED (Budget exceeded: %.1f > %.1f)", - i, - unitDefName, - spawnParams.x, - spawnParams.y, - spawnParams.z, - spawnParams.facing, - totalBudgetCost, - comData.budget - ) - ) comData.commandsToRemove = commandsToRemove return spawnQueue end @@ -452,42 +433,10 @@ local function getCommanderBuildQueue(commanderID) if cmd.tag then table.insert(commandsToRemove, cmd.tag) end - else - local failReasons = {} - if not validationResults.distanceCheck then - table.insert( - failReasons, - string.format( - "OutOfRange(%.1f > %.1f)", - validationResults.distance, - validationResults.maxDistance - ) - ) - end - if not validationResults.traversableCheck then - table.insert(failReasons, "NotTraversable") - end - if not validationResults.notPastLinesCheck then - table.insert(failReasons, "PastFriendlyLines") - end - local failReasonsStr = table.concat(failReasons, ", ") - Spring.Echo( - string.format( - " [%d] %s at (%.1f, %.1f, %.1f) facing: %d - REJECTED (%s)", - i, - unitDefName, - spawnParams.x, - spawnParams.y, - spawnParams.z, - spawnParams.facing, - failReasonsStr - ) - ) end end end comData.commandsToRemove = commandsToRemove - Spring.Echo(string.format("=== Accepted %d/%d build queue items ===", #spawnQueue, #commands)) return spawnQueue end @@ -885,22 +834,11 @@ end local function tryToSpawnBuild(commanderID, unitDefID, buildX, buildY, buildZ, facing) local unitDef, comData = unitDefs[unitDefID], commanders[commanderID] - local unitDefName = unitDef and unitDef.name or "UNKNOWN" local discount = getFactoryDiscount(unitDef, commanderID) local cost = defMetergies[unitDefID] - discount local unitID = spCreateUnit(unitDef.name, buildX, buildY, buildZ, facing, comData.teamID) if not unitID then - Spring.Echo( - string.format( - " SPAWN FAILED: %s at (%.1f, %.1f, %.1f) facing: %d - CreateUnit returned nil (terrain/collision conflict)", - unitDefName, - buildX, - buildY, - buildZ, - facing - ) - ) return false, nil end @@ -908,7 +846,7 @@ local function tryToSpawnBuild(commanderID, unitDefID, buildX, buildY, buildZ, f local projectedBuildProgress = queueBuildForProgression(unitID, unitDef, affordableCost, cost) comData.budget = comData.budget - affordableCost - if unitDef.isFactory and discountableFactories[unitDef.name] and discount > 0 then + if discountableFactories[unitDefID] and discount > 0 then commanderFactoryDiscounts[commanderID] = true end @@ -954,19 +892,13 @@ function gadget:GameFrame(frame) break end local loop = modOptions.quick_start ~= "factory_discount_only" - if loop and gameFrameTryCount == 1 then - Spring.Echo("=== Beginning Quick Start Spawn Phase ===") - end while loop do loop = false for commanderID, comData in pairs(commanders) do if comData.spawnQueue then - for i, buildItem in ipairs(comData.spawnQueue) do + for _, buildItem in ipairs(comData.spawnQueue) do local buildType = optionDefIDToTypes[buildItem.id] - local unitDef = unitDefs[buildItem.id] - local unitDefName = unitDef and unitDef.name or "UNKNOWN" local buildX, buildY, buildZ = buildItem.x, buildItem.y, buildItem.z - local hadCoordinates = buildX and buildY and buildZ if not buildX or not buildZ or not buildY then buildX, buildY, buildZ = getBuildSpace(commanderID, buildType) end @@ -976,40 +908,6 @@ function gadget:GameFrame(frame) if success then loop = true end - else - local failReasons = {} - if not buildItem.id then - table.insert(failReasons, "NoUnitDefID") - end - if not buildX then - table.insert( - failReasons, - hadCoordinates and "InvalidCoordinates" or "NoBuildSpaceAvailable" - ) - end - if comData.budget <= 0 then - table.insert(failReasons, "NoBudget") - end - if #failReasons > 0 then - local failReasonsStr = table.concat(failReasons, ", ") - local coordsStr = buildItem.x - and string.format( - "(%.1f, %.1f, %.1f)", - buildItem.x, - buildItem.y or 0, - buildItem.z - ) - or "(no coords)" - Spring.Echo( - string.format( - " SPAWN SKIPPED: %s at %s facing: %d - %s", - unitDefName, - coordsStr, - facing, - failReasonsStr - ) - ) - end end end end diff --git a/luarules/gadgets/game_replace_afk_players.lua b/luarules/gadgets/game_replace_afk_players.lua index 9ef11645e02..9c8f73042a8 100644 --- a/luarules/gadgets/game_replace_afk_players.lua +++ b/luarules/gadgets/game_replace_afk_players.lua @@ -39,7 +39,6 @@ if gadgetHandler:IsSyncedCode() then local players = {} local absent = {} local replaced = false - local gameStarted = false local gaiaTeamID = Spring.GetGaiaTeamID() local SpGetPlayerList = Spring.GetPlayerList @@ -174,7 +173,6 @@ if gadgetHandler:IsSyncedCode() then end function gadget:GameStart() - gameStarted = true FindSubs(true) end diff --git a/luarules/gadgets/game_restart_with_state.lua b/luarules/gadgets/game_restart_with_state.lua index 235e95ae7f8..c611ee0b3d4 100644 --- a/luarules/gadgets/game_restart_with_state.lua +++ b/luarules/gadgets/game_restart_with_state.lua @@ -27,8 +27,6 @@ end 5. Best used in singleplayer. Reading raw-filesystem state in synced will desync multiplayer. ]] -local STATE_FILE = "LuaUI/Config/restart_state.lua" - -- minimal Lua-table serializer for the types we actually store: nil/number/boolean/string/table local function serialize(o, indent) indent = indent or "" diff --git a/luarules/gadgets/game_restrict_unit_sharing.lua b/luarules/gadgets/game_restrict_unit_sharing.lua index 4caf34eb052..ca0c8eadefc 100644 --- a/luarules/gadgets/game_restrict_unit_sharing.lua +++ b/luarules/gadgets/game_restrict_unit_sharing.lua @@ -56,7 +56,6 @@ function gadget:AllowUnitTransfer(unitID, unitDefID, fromTeamID, toTeamID, captu return false -- Sharing partly built nanoframes is not allowed because letting it decay bypasses taxation and letting it build runs out the debuff early. Also if you can't assist ally build the unit could get stuck in factory. end if builderUnits[unitDefID] then - local unitDef = UnitDefs[unitDefID] local startFrame = Spring.GetGameFrame() local expireFrame = startFrame + DEBUFF_FRAMES debuffedUnits[unitID] = { diff --git a/luarules/gadgets/game_startbox_config.lua b/luarules/gadgets/game_startbox_config.lua index 74a89c31973..da4170415f4 100644 --- a/luarules/gadgets/game_startbox_config.lua +++ b/luarules/gadgets/game_startbox_config.lua @@ -18,27 +18,30 @@ end local PolygonLib = VFS.Include("common/lib_polygon.lua") +local SPAWN_CHOOSE_IN_GAME = 2 + local startBoxConfig local configSource local isExplicitConfig = false function gadget:Initialize() local StartboxLib = VFS.Include("luarules/gadgets/include/startbox_utilities.lua") - local ParseBoxes = StartboxLib.ParseBoxes - local ok, config, source, isExplicit = pcall(ParseBoxes) - if ok then - startBoxConfig = config - configSource = source - isExplicitConfig = isExplicit - else - Spring.Log(gadget:GetInfo().name, LOG.WARNING, "Failed to parse startbox config: " .. tostring(config)) - end + startBoxConfig, configSource, isExplicitConfig = StartboxLib.GetConfig() - -- Expand the engine AABB for each active allyTeam to cover the polygon bounds. - -- Without this, the engine silently drops clicks outside its default AABB and never - -- calls AllowStartPosition, making polygons that extend beyond the lobby's - -- rectangle unreachable. - if isExplicitConfig and startBoxConfig then + -- Only choose-in-game places by start box. Other modes take the map's own start + -- positions, and the engine clamps every incoming position into the allyteam's rect + -- before Lua sees it (NETMSG_STARTPOS), so any rect at all drags valid positions onto + -- its edges. Widen to the whole map, which makes that clamp a no-op. + if Game.startPosType ~= SPAWN_CHOOSE_IN_GAME then + local allyTeamList = Spring.GetAllyTeamList() + for _, allyTeamID in ipairs(allyTeamList) do + Spring.SetAllyTeamStartBox(allyTeamID, 0, 0, Game.mapSizeX, Game.mapSizeZ) + end + elseif isExplicitConfig and startBoxConfig then + -- Expand the engine AABB for each active allyTeam to cover the polygon bounds. + -- Without this, the engine silently drops clicks outside its default AABB and never + -- calls AllowStartPosition, making polygons that extend beyond the lobby's + -- rectangle unreachable. local allyTeamList = Spring.GetAllyTeamList() for _, allyTeamID in ipairs(allyTeamList) do local entry = startBoxConfig[allyTeamID] diff --git a/luarules/gadgets/game_tax_resource_sharing.lua b/luarules/gadgets/game_tax_resource_sharing.lua index 6a3cafda4b3..e7bc9b311d6 100644 --- a/luarules/gadgets/game_tax_resource_sharing.lua +++ b/luarules/gadgets/game_tax_resource_sharing.lua @@ -47,18 +47,13 @@ if Spring.GetModOptions().easytax then sharingTax = 0.3 -- 30% tax for easytax modoption end -local function isAlliedUnit(teamID, unitID) - local unitTeam = Spring.GetUnitTeam(unitID) - return teamID and unitTeam and teamID ~= unitTeam and Spring.AreTeamsAllied(teamID, unitTeam) -end - ---------------------------------------------------------------- -- Callins ---------------------------------------------------------------- function gadget:AllowResourceTransfer(senderTeamId, receiverTeamId, resourceType, amount) -- Spring uses 'm' and 'e' instead of the full names that we need, so we need to convert the resourceType - -- We also check for 'metal' or 'energy' incase Spring decides to use those in a later version + -- We also check for 'metal' or 'energy' in case Spring decides to use those in a later version local resourceName if (resourceType == "m") or (resourceType == "metal") then resourceName = "metal" @@ -82,7 +77,6 @@ function gadget:AllowResourceTransfer(senderTeamId, receiverTeamId, resourceType local taxedAmount = math_min((1 - sharingTax) * amount, maxShare) local totalAmount = taxedAmount / (1 - sharingTax) - local transferTax = totalAmount * sharingTax spSetTeamResource(receiverTeamId, resourceName, rCur + taxedAmount) local sCur, _, _, _, _, _ = spGetTeamResources(senderTeamId, resourceName) diff --git a/luarules/gadgets/game_team_com_ends.lua b/luarules/gadgets/game_team_com_ends.lua index f0d307538f0..a648f96a62d 100644 --- a/luarules/gadgets/game_team_com_ends.lua +++ b/luarules/gadgets/game_team_com_ends.lua @@ -154,6 +154,11 @@ function gadget:Initialize() then gadgetHandler:RemoveGadget(self) end + -- Map editor sessions (editor_sandbox=1 in the start script) have no + -- commanders at all; commander counting has nothing to end. + if tostring(Spring.GetModOptions().editor_sandbox or "") == "1" then + gadgetHandler:RemoveGadget(self) + end local allyTeamList = spGetAllyTeamList() for i = 1, #allyTeamList do diff --git a/luarules/gadgets/game_team_power_watcher.lua b/luarules/gadgets/game_team_power_watcher.lua index 187ee8d5b05..dc595c6e875 100644 --- a/luarules/gadgets/game_team_power_watcher.lua +++ b/luarules/gadgets/game_team_power_watcher.lua @@ -48,7 +48,6 @@ local powerThresholds = { { techLevel = 4.5, threshold = 725000 }, } -local pveTeamID = scavengerTeam or raptorTeam for _, teamID in ipairs(teamList) do local allyID = select(6, Spring.GetTeamInfo(teamID)) if teamID ~= scavengerTeam and teamID ~= raptorTeam and select(4, Spring.GetTeamInfo(teamID, false)) then diff --git a/luarules/gadgets/game_team_resources.lua b/luarules/gadgets/game_team_resources.lua index c8b4a7855bc..cb938480bc4 100644 --- a/luarules/gadgets/game_team_resources.lua +++ b/luarules/gadgets/game_team_resources.lua @@ -19,6 +19,7 @@ end local minStorageMetal = 1000 local minStorageEnergy = 1000 local mathMax = math.max +local gaiaTeamID = Spring.GetGaiaTeamID() local function GetTeamPlayerCounts() local teamPlayerCounts = {} @@ -33,7 +34,7 @@ local function GetTeamPlayerCounts() return teamPlayerCounts end -local function setup(addResources) +local function setup(addResources, skipGaia) local startMetalStorage = Spring.GetModOptions().startmetalstorage local startEnergyStorage = Spring.GetModOptions().startenergystorage local startMetal = Spring.GetModOptions().startmetal @@ -49,6 +50,11 @@ local function setup(addResources) end local teamList = Spring.GetTeamList() + if skipGaia then + teamList = table.filterArray(teamList, function(teamID) + return teamID ~= gaiaTeamID + end) + end for i = 1, #teamList do local teamID = teamList[i] @@ -103,7 +109,7 @@ end function gadget:GameStart() -- reset because commander added additional storage as well - setup() + setup(false, true) end function gadget:TeamDied(teamID) diff --git a/luarules/gadgets/game_zombies.lua b/luarules/gadgets/game_zombies.lua new file mode 100644 index 00000000000..db5d506e04f --- /dev/null +++ b/luarules/gadgets/game_zombies.lua @@ -0,0 +1,1243 @@ +function gadget:GetInfo() + return { + name = "Zombies", + desc = "Resurrects corpses as Scavengers or hostile Gaia Zombies", + author = "SethDGamre, code snippets/inspiration from Rafal", + date = "March 2024", + license = "GNU GPL, v2 or later", + layer = 2, -- after game_team_resources.lua (to override resources) and ai_ruins.lua (to overwrite gaia unit cap) + enabled = true, + } +end + +-- To customize zombie respawn time, use customParams.zombie_respawn_time (seconds): +-- < 0 never respawn as a zombie +-- 0 respawn instantly +-- > 0 custom respawn delay in seconds +-- this overrides default timing based on unit power, difficulty, and gamestate. + +if not gadgetHandler:IsSyncedCode() then + return false +end + +local spring = Spring +local modOptions = spring.GetModOptions() +local modOptionEnabled = modOptions.zombies ~= "disabled" +local isIdleMode = GG.Zombies and GG.Zombies.IdleMode == true or false +if not modOptionEnabled and not isIdleMode then + return false +end + +local WARNING_TIME = Game.gameSpeed * 15 -- Frames to start warning before reanimation +local TIMER_NEAR_MAX_THRESHOLD = Game.gameSpeed * 5 -- skip the tamper sparkle if the spawn timer is still near its maximum +local ZOMBIE_UNIT_CAP_FLOOR = 2000 +local ZOMBIE_REZ_FRAME_PARAM = "zombie_rez_frame" +local WAS_ZOMBIE_PARAM = "wasZombie" +local PUBLIC_RULES_PARAM_ACCESS = { public = true } +local WAS_ZOMBIE_TIMEOUT_FRAMES = Game.gameSpeed * 3 +local MIN_CAPTURE_DISTANCE_BOOST = 300 +local MIN_ZOMBIE_XP = 0.25 +local ZOMBIE_MAX_XP = 1.5 + +local standardTechToRezPowerSpeeds = { + [0.5] = 1, + [1] = 1, + [1.5] = 3, + [2] = 8, + [2.5] = 25, + [3] = 42, + [3.5] = 63, + [4] = 83, + [4.5] = 104, +} + +local harderTechToRezPowerSpeeds = { + [0.5] = 1, + [1] = 2, + [1.5] = 5, + [2] = 12, + [2.5] = 38, + [3] = 64, + [3.5] = 86, + [4] = 108, + [4.5] = 130, +} + +---One of the zombie difficulty presets, matching the keys of `zombieModeConfigs`. +---@alias ZombieMode "normal"|"hard"|"nightmare"|"akumu" + +local zombieModeConfigs = { + normal = { + techToRezPowerSpeeds = standardTechToRezPowerSpeeds, + rezMin = 90, + rezMax = 180, + countMin = 1, + countMax = 1, + zombieCorpses = false, + }, + hard = { + techToRezPowerSpeeds = harderTechToRezPowerSpeeds, + rezMin = 60, + rezMax = 180, + countMin = 1, + countMax = 1, + zombieCorpses = false, + }, + nightmare = { + techToRezPowerSpeeds = harderTechToRezPowerSpeeds, + rezMin = 60, + rezMax = 120, + countMin = 2, + countMax = 6, + zombieCorpses = false, + }, + akumu = { + techToRezPowerSpeeds = harderTechToRezPowerSpeeds, + rezMin = 60, + rezMax = 120, + countMin = 2, + countMax = 8, + zombieCorpses = true, + }, +} + +---@type ZombieMode +local currentZombieMode = "normal" +local currentZombieConfig = zombieModeConfigs.normal + +local ZOMBIE_CHECK_INTERVAL = Game.gameSpeed -- How often (in frames) everything else is checked +local REZ_SPEED_UPDATE_INTERVAL = Game.gameSpeed * 60 +local WATER_DAMAGE_DEF_ID = Game.envDamageTypes.Water +local CORPSE_RESET_CEG = "selfrepair-sparks-purple" +local CORPSE_RESET_CEG_HEIGHT = 15 +local UNAUTHORIZED_TEXT = "You are not authorized to use zombie commands" --i18n library doesn't exist in gadget space. +local spValidUnitID = spring.ValidUnitID +local spGetGroundHeight = spring.GetGroundHeight +local spGetUnitPosition = spring.GetUnitPosition +local spGetFeaturePosition = spring.GetFeaturePosition +local spGetFeatureResurrect = spring.GetFeatureResurrect +local spGetUnitDefID = spring.GetUnitDefID +local spGetUnitHealth = spring.GetUnitHealth +local spGetUnitRulesParam = spring.GetUnitRulesParam +local spSpawnCEG = spring.SpawnCEG +local random = math.random +local floor = math.floor +local clamp = math.clamp +local ceil = math.ceil + +local teams = spring.GetTeamList() +local scavTeamID +local gaiaTeamID = spring.GetGaiaTeamID() +for _, teamID in ipairs(teams) do + local teamLuaAI = spring.GetTeamLuaAI(teamID) + if teamLuaAI and string.find(teamLuaAI, "ScavengersAI") then + scavTeamID = teamID + end +end + +local gameFrame = 0 +local adjustedRezPowerSpeed = currentZombieConfig.techToRezPowerSpeeds[1] +local currentTechLevel = nil +local autoSpawningEnabled = true + +local zombiesBeingBuilt = {} +local zombieCorpseDefs = {} +local corpseCheckFrames = {} +local corpsesData = {} +local wereZombies = {} +local pendingUnitXp = {} +local pendingZombieCaptures = {} +local heapingZombies = {} +local zombieHeapDefs = {} +local unitDefs = UnitDefs +local unitDefNames = UnitDefNames +local featureDefNames = FeatureDefNames +local featureDefs = FeatureDefs + +local warningEffects = { + "scavmist", + "scavradiation-lightning", +} +local spawnEffects = { + "xploelc2", + "xploelc3", +} + +for unitDefID, unitDef in pairs(unitDefs) do + local corpseDefName = unitDef.corpse + if featureDefNames[corpseDefName] then + local corpseDefID = featureDefNames[corpseDefName].id + local corpseFeatureDef = featureDefs[corpseDefID] + if corpseFeatureDef.resurrectable ~= 0 then + local corpseDefData = { unitDefID = unitDefID } + local customRespawnTime = tonumber(unitDef.customParams and unitDef.customParams.zombie_respawn_time) + if customRespawnTime then + if customRespawnTime < 0 then + corpseDefData.neverRespawn = true + else + corpseDefData.customRespawnTime = customRespawnTime + end + end + zombieCorpseDefs[corpseDefID] = corpseDefData + end + + local zombieDefData = {} + local deathExplosionName = unitDef.deathExplosion + local explosionDefID = WeaponDefNames[deathExplosionName].id + zombieDefData.explosionDefID = explosionDefID + + local heapDefName = corpseFeatureDef.deathFeatureID + if heapDefName then + zombieDefData.heapDefID = heapDefName + end + + zombieHeapDefs[unitDefID] = zombieDefData + end + +end + +local function isZombie(unitID) + return spGetUnitRulesParam(unitID, "zombie") == 1 +end + +local function setGaiaStorage() + local metalStorageToSet = 1000000 + local energyStorageToSet = 1000000 + + local _, currentMetalStorage = spring.GetTeamResources(gaiaTeamID, "metal") + if currentMetalStorage and currentMetalStorage < metalStorageToSet then + spring.SetTeamResource(gaiaTeamID, "ms", metalStorageToSet) + end + + local _, currentEnergyStorage = spring.GetTeamResources(gaiaTeamID, "energy") + if currentEnergyStorage and currentEnergyStorage < energyStorageToSet then + spring.SetTeamResource(gaiaTeamID, "es", energyStorageToSet) + end +end + +local function getUnitRezPower(unitDef) + return math.max(1, unitDef.power or 1) +end + +local function calculateSpawnDelayFrames(unitPower) + local spawnSeconds = floor(unitPower / adjustedRezPowerSpeed) + spawnSeconds = clamp(spawnSeconds, currentZombieConfig.rezMin, currentZombieConfig.rezMax) + return spawnSeconds * Game.gameSpeed +end + +local function getRezPowerSpeedForTechLevel(config, techLevel) + local speeds = config.techToRezPowerSpeeds + if speeds[techLevel] then + return speeds[techLevel] + end + return speeds[1] +end + +local function rebuildZombieCorpseSpawnDelays() + for _, corpseDefData in pairs(zombieCorpseDefs) do + if corpseDefData.neverRespawn then + corpseDefData.spawnDelayFrames = nil + elseif corpseDefData.customRespawnTime then + corpseDefData.spawnDelayFrames = floor(corpseDefData.customRespawnTime * Game.gameSpeed) + else + local unitDef = unitDefs[corpseDefData.unitDefID] + corpseDefData.spawnDelayFrames = calculateSpawnDelayFrames(getUnitRezPower(unitDef)) + end + end +end + +local function updateAdjustedRezPowerSpeed() + local techLevel = 1 + adjustedRezPowerSpeed = getRezPowerSpeedForTechLevel(currentZombieConfig, techLevel) + if GG.PowerLib and GG.PowerLib.HighestPlayerTeamPower and GG.PowerLib.TechGuesstimate then + local highestPowerData = GG.PowerLib.HighestPlayerTeamPower() + techLevel = GG.PowerLib.TechGuesstimate(highestPowerData.power) + adjustedRezPowerSpeed = getRezPowerSpeedForTechLevel(currentZombieConfig, techLevel) + end + + currentTechLevel = techLevel +end + +local function updateRezSpeed() + updateAdjustedRezPowerSpeed() + rebuildZombieCorpseSpawnDelays() +end + +---Applies a preset's tuning to the live zombie config, falling back to `normal` +---for an unknown mode. +---@param mode ZombieMode +local function applyZombieModeSettings(mode) + local config = zombieModeConfigs[mode] + ---@diagnostic disable-next-line: unnecessary-if + if not config then + config = zombieModeConfigs.normal + end + + currentZombieMode = mode + currentZombieConfig = config + + updateRezSpeed() +end + +local function calculateHealthRatio(featureID) + local partialReclaimRatio = 1 + local damagedReductionRatio = 1 + local currentMetal, maxMetal = spring.GetFeatureResources(featureID) + if currentMetal and maxMetal and currentMetal ~= 0 and maxMetal ~= 0 then + partialReclaimRatio = currentMetal / maxMetal + end + local health, maxHealth = spring.GetFeatureHealth(featureID) + if health and maxHealth and health ~= 0 and maxHealth ~= 0 then + damagedReductionRatio = health / maxHealth + end + local healthRatio = (partialReclaimRatio + damagedReductionRatio) * 0.5 --average the two ratios to skew the result towards maximum health + return healthRatio +end + +local function warningCEG(featureID, x, y, z) + local radius = spring.GetFeatureRadius(featureID) + + local selectedEffect = warningEffects[random(#warningEffects)] + if selectedEffect == "scavradiation-lightning" and GG.SpawnEnvironmentalLightning then + GG.SpawnEnvironmentalLightning("scavradiation", x, y, z) + else + spSpawnCEG(selectedEffect, x, y, z, 0, 0, 0, radius * 0.25) + end + spSpawnCEG("scaspawn-trail", x, y, z, 0, 0, 0, radius) +end + +local function playSpawnSound(x, y, z) + local selectedEffect = spawnEffects[random(#spawnEffects)] + spring.PlaySoundFile(selectedEffect, 0.5, x, y, z, 0) +end + +local function setCorpseRezRulesParam(featureID, spawnFrame) + spring.SetFeatureRulesParam(featureID, ZOMBIE_REZ_FRAME_PARAM, spawnFrame, PUBLIC_RULES_PARAM_ACCESS) +end + +local function clearCorpseRezRulesParam(featureID) + spring.SetFeatureRulesParam(featureID, ZOMBIE_REZ_FRAME_PARAM, nil, PUBLIC_RULES_PARAM_ACCESS) +end + +local function wasZombieCorpse(featureID, corpseData) + if corpseData and corpseData.wasZombie then + return true + end + local wasZombieParam = spring.GetFeatureRulesParam(featureID, WAS_ZOMBIE_PARAM) + return wasZombieParam == 1 +end + +local function resetSpawn(featureID, featureData, featureX, featureZ) + local newFrame = featureData.tamperedFrame + featureData.spawnDelayFrames + featureData.spawnFrame = newFrame + featureData.creationFrame = featureData.tamperedFrame + featureData.tamperedFrame = nil -- reclaim/rez progress restarts the spawn timer from this frame + setCorpseRezRulesParam(featureID, newFrame) + corpseCheckFrames[newFrame] = corpseCheckFrames[newFrame] or {} + corpseCheckFrames[newFrame][#corpseCheckFrames[newFrame] + 1] = featureID + spSpawnCEG( + CORPSE_RESET_CEG, + featureX, + spGetGroundHeight(featureX, featureZ) + CORPSE_RESET_CEG_HEIGHT, + featureZ, + 0, + 0, + 0 + ) +end + +local function getScavVariantUnitDefID(unitDefID) + local unitDef = unitDefs[unitDefID] + if string.find(unitDef.name, "_scav") then + return unitDefID + end + + local scavUnitDefName = unitDef.name .. "_scav" + local scavUnitDef = unitDefNames[scavUnitDefName] + return scavUnitDef and scavUnitDef.id or unitDefID +end + +local function initializeZombieAI(unitID, unitDefID) + if GG.ZombieAI then + GG.ZombieAI.InitializeZombie(unitID, unitDefID) + end +end + +local function applyZombieBuildRangeBonus(unitID, unitDefID) + local unitDef = unitDefs[unitDefID] + local originalBuildDistance = unitDef and unitDef.buildDistance + if not originalBuildDistance or originalBuildDistance <= 0 then + return + end + local losRadius = unitDef.losRadius or unitDef.sightDistance or 0 + local boostedBuildDistance = math.max(originalBuildDistance, MIN_CAPTURE_DISTANCE_BOOST) + spring.SetUnitBuildParams(unitID, "buildDistance", boostedBuildDistance) +end + +local function restoreOriginalBuildRange(unitID, unitDefID) + local unitDef = unitDefs[unitDefID] + local originalBuildDistance = unitDef and unitDef.buildDistance + if not originalBuildDistance or originalBuildDistance <= 0 then + return + end + spring.SetUnitBuildParams(unitID, "buildDistance", originalBuildDistance) +end + +local function rollSpawnCount() + return random(currentZombieConfig.countMin, currentZombieConfig.countMax) +end + +local function calculateSpawnCount(unitDefID) + local countMin = currentZombieConfig.countMin + local countMax = currentZombieConfig.countMax + if countMin == countMax then + return countMin + end + + local unitDef = unitDefs[unitDefID] + local rezTimeSeconds = calculateSpawnDelayFrames(getUnitRezPower(unitDef)) / Game.gameSpeed + local rezMin = currentZombieConfig.rezMin + local rezMax = currentZombieConfig.rezMax + + if currentTechLevel <= 1 then + return math.min(rollSpawnCount(), rollSpawnCount(), rollSpawnCount()) -- extra min() rolls skew the count down except for cheap, fast-rez units + end + + if rezTimeSeconds == rezMin then + return rollSpawnCount() + end + if rezTimeSeconds == rezMax then + return math.min(rollSpawnCount(), rollSpawnCount(), rollSpawnCount()) + end + return math.min(rollSpawnCount(), rollSpawnCount()) +end + +local function spawnZombies(featureID, unitDefID, healthReductionRatio, x, y, z, wasZombie, pastXp) + local unitDef = unitDefs[unitDefID] + local spawnCount = 1 -- dead zombies never multiply, so they can't snowball + if not wasZombie and unitDef.speed > 0 then + spawnCount = calculateSpawnCount(unitDefID) + end + local size = unitDef.xsize + local unitDefToCreate = getScavVariantUnitDefID(unitDefID) + local sizeCategory = ceil((unitDef.xsize / 2 + unitDef.zsize / 2) / 2) + local sizeName = "small" + if sizeCategory > 4.5 then + sizeName = "huge" + elseif sizeCategory > 3.5 then + sizeName = "large" + elseif sizeCategory > 2.5 then + sizeName = "medium" + elseif sizeCategory > 1.5 then + sizeName = "small" + else + sizeName = "tiny" + end + + if pastXp == nil then + local corpseData = featureID and corpsesData[featureID] + if corpseData then + pastXp = corpseData.pastXp + elseif featureID then + pastXp = spring.GetFeatureRulesParam(featureID, "previous_xp") or 0 + else + pastXp = 0 + end + end + + if featureID then + spring.DestroyFeature(featureID) + corpsesData[featureID] = nil + end + playSpawnSound(x, y, z) + + for i = 1, spawnCount do + local randomX = x + random(-size * spawnCount, size * spawnCount) + local randomZ = z + random(-size * spawnCount, size * spawnCount) + local adjustedY = spGetGroundHeight(randomX, randomZ) + + local unitID = spring.CreateUnit(unitDefToCreate, randomX, adjustedY, randomZ, 0, gaiaTeamID) + if unitID then + spSpawnCEG("scav-spawnexplo-" .. sizeName, randomX, adjustedY, randomZ, 0, 0, 0) + local generatedXp = 0 + if modOptions.zombies ~= "normal" then + generatedXp = math.max(MIN_ZOMBIE_XP, math.min(random() * ZOMBIE_MAX_XP, random() * ZOMBIE_MAX_XP, random() * ZOMBIE_MAX_XP)) -- triple-roll min keeps most extra XP low + end + spring.SetUnitExperience(unitID, math.max(pastXp, generatedXp)) + local unitHealth = spGetUnitHealth(unitID) + spring.SetUnitHealth(unitID, unitHealth * healthReductionRatio) + spring.SetUnitRulesParam(unitID, "zombie", 1) + if scavTeamID then + spring.TransferUnit(unitID, scavTeamID) + else + initializeZombieAI(unitID, unitDefToCreate) + applyZombieBuildRangeBonus(unitID, unitDefToCreate) + end + end + end +end + +---Turns a unit into a zombie, swapping it for its `_scav` variant where one exists. +---@param unitID UnitID +local function setZombie(unitID) + local unitDefID = spGetUnitDefID(unitID) + if not unitDefID then + return + end + + local scavUnitDefID = getScavVariantUnitDefID(unitDefID) + + -- If we need to convert to _scav variant + if scavUnitDefID ~= unitDefID then + local x, y, z = spGetUnitPosition(unitID) + local facing = spring.GetUnitDirection(unitID) + local teamID = spring.GetUnitTeam(unitID) + local newUnitID = spring.CreateUnit(scavUnitDefID, x, y, z, facing, teamID) + if newUnitID then + local health, maxHealth = spGetUnitHealth(unitID) + local originalHealthRatio = health / maxHealth + spring.SetUnitHealth(newUnitID, originalHealthRatio * maxHealth) + local experience = spring.GetUnitExperience(unitID) + spring.SetUnitExperience(newUnitID, experience) + + spring.DestroyUnit(unitID, false, true) + + unitID = newUnitID + unitDefID = scavUnitDefID + end + end + + spring.SetUnitRulesParam(unitID, "zombie", 1) + initializeZombieAI(unitID, unitDefID) + if spring.GetUnitTeam(unitID) == gaiaTeamID then + applyZombieBuildRangeBonus(unitID, unitDefID) + end +end + +function gadget:FeatureBuildStepPost(featureID) + local featureData = corpsesData[featureID] + if featureData then + if not featureData.tamperedFrame then + local remainingFrames = featureData.spawnFrame - gameFrame + if remainingFrames < featureData.spawnDelayFrames - TIMER_NEAR_MAX_THRESHOLD then + local featureX, featureY, featureZ = spGetFeaturePosition(featureID) + if featureX then + spSpawnCEG("scaspawn-trail", featureX, featureY + 15, featureZ, 0, 0, 0) + end + end + end + featureData.tamperedFrame = gameFrame + end +end + +function gadget:GameFrame(frame) + gameFrame = frame + + if frame % REZ_SPEED_UPDATE_INTERVAL == 0 then + updateRezSpeed() + end + + local corpsesToCheck = corpseCheckFrames[frame] + if corpsesToCheck then + for i = 1, #corpsesToCheck do + local featureID = corpsesToCheck[i] + local corpseData = corpsesData[featureID] + local featureX, featureY, featureZ + if corpseData then + featureX, featureY, featureZ = spGetFeaturePosition(featureID) + end + if not featureX then --feature is gone + corpsesData[featureID] = nil + else --feature is still there + local featureDefData = zombieCorpseDefs[corpseData.featureDefID] + if corpseData.tamperedFrame then + resetSpawn(featureID, corpseData, featureX, featureZ) + else + local healthReductionRatio = calculateHealthRatio(featureID) + spawnZombies( + featureID, + featureDefData.unitDefID, + healthReductionRatio, + featureX, + featureY, + featureZ, + corpseData.wasZombie, + corpseData.pastXp + ) + end + end + end + corpseCheckFrames[frame] = nil + end + + if frame % ZOMBIE_CHECK_INTERVAL == 0 then + spring.AddTeamResource(gaiaTeamID, "metal", 1000000) + spring.AddTeamResource(gaiaTeamID, "energy", 1000000) + for unitID, timeoutFrame in pairs(wereZombies) do + if timeoutFrame < frame then + wereZombies[unitID] = nil + end + end + for unitID, xpData in pairs(pendingUnitXp) do + if xpData.timeout < frame then + pendingUnitXp[unitID] = nil + end + end + for featureID, featureData in pairs(corpsesData) do + if featureData.spawnFrame - frame < WARNING_TIME then + local featureX, featureY, featureZ = spGetFeaturePosition(featureID) + if not featureX then --doesn't exist anymore + corpsesData[featureID] = nil + elseif not featureData.tamperedFrame then + warningCEG(featureID, featureX, featureY, featureZ) + end + end + end + end +end + +local function isCorpseResurrectable(featureID) + local resurrectUnitName = spGetFeatureResurrect(featureID) + return resurrectUnitName ~= nil and resurrectUnitName ~= "" +end + +local function queueCorpseForSpawning(featureID, override, wasZombie, pastXp) + if not override and not autoSpawningEnabled then + return + end + + local featureDefID = spring.GetFeatureDefID(featureID) + local corpseDefData = zombieCorpseDefs[featureDefID] + if not corpseDefData or corpseDefData.neverRespawn or not isCorpseResurrectable(featureID) then + return + end + + wasZombie = wasZombie or wasZombieCorpse(featureID) + if pastXp == nil then + local existingCorpseData = corpsesData[featureID] + if existingCorpseData then + pastXp = existingCorpseData.pastXp + else + pastXp = spring.GetFeatureRulesParam(featureID, "previous_xp") or 0 + end + end + + local spawnDelayFrames = corpseDefData.spawnDelayFrames + if spawnDelayFrames == 0 then + local featureX, featureY, featureZ = spGetFeaturePosition(featureID) + if featureX then + local healthReductionRatio = calculateHealthRatio(featureID) + spawnZombies( + featureID, + corpseDefData.unitDefID, + healthReductionRatio, + featureX, + featureY, + featureZ, + wasZombie, + pastXp + ) + end + return + end + + local spawnFrame = gameFrame + spawnDelayFrames + corpsesData[featureID] = { + featureDefID = featureDefID, + spawnDelayFrames = spawnDelayFrames, + creationFrame = gameFrame, + spawnFrame = spawnFrame, + wasZombie = wasZombie, + pastXp = pastXp, + } + setCorpseRezRulesParam(featureID, spawnFrame) + corpseCheckFrames[spawnFrame] = corpseCheckFrames[spawnFrame] or {} + corpseCheckFrames[spawnFrame][#corpseCheckFrames[spawnFrame] + 1] = featureID +end + +function gadget:FeatureCreated(featureID, allyTeam, sourceID) + local wasZombie = false + local pastXp = 0 + if sourceID and wereZombies[sourceID] then + wasZombie = true + wereZombies[sourceID] = nil + spring.SetFeatureRulesParam(featureID, WAS_ZOMBIE_PARAM, 1, PUBLIC_RULES_PARAM_ACCESS) + end + if sourceID and pendingUnitXp[sourceID] then + pastXp = pendingUnitXp[sourceID].xp + pendingUnitXp[sourceID] = nil + else + pastXp = spring.GetFeatureRulesParam(featureID, "previous_xp") or 0 + end + queueCorpseForSpawning(featureID, false, wasZombie, pastXp) +end + +function gadget:FeatureDestroyed(featureID, allyTeam) + clearCorpseRezRulesParam(featureID) + corpsesData[featureID] = nil +end + +function gadget:UnitCreated(unitID, unitDefID, unitTeam, builderID) + if unitTeam == gaiaTeamID and builderID and isZombie(builderID) then + zombiesBeingBuilt[unitID] = true + spring.SetUnitRulesParam(unitID, "resurrected", 0, { inlos = true }) + end +end + +function gadget:UnitFinished(unitID, unitDefID, unitTeam) + if unitTeam == gaiaTeamID and zombiesBeingBuilt[unitID] then + zombiesBeingBuilt[unitID] = nil + setZombie(unitID) + end +end + +function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) + if zombieHeapDefs[unitDefID] then + pendingUnitXp[unitID] = + { xp = spring.GetUnitExperience(unitID) or 0, timeout = gameFrame + WAS_ZOMBIE_TIMEOUT_FRAMES } + end + if isZombie(unitID) and currentZombieConfig.zombieCorpses and not heapingZombies[unitID] then + wereZombies[unitID] = gameFrame + WAS_ZOMBIE_TIMEOUT_FRAMES -- FeatureCreated may land later, so stash zombie-ness for a few seconds + end + heapingZombies[unitID] = nil + pendingZombieCaptures[unitID] = nil + zombiesBeingBuilt[unitID] = nil +end + +function gadget:AllowUnitCaptureStep(builderID, builderTeam, unitID, unitDefID, part) + if isZombie(builderID) then + pendingZombieCaptures[unitID] = true + end + return true +end + +function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) + if oldTeam == gaiaTeamID and newTeam ~= gaiaTeamID and isZombie(unitID) then + restoreOriginalBuildRange(unitID, unitDefID) + end + if pendingZombieCaptures[unitID] then + pendingZombieCaptures[unitID] = nil + if not isZombie(unitID) then + local unitX, unitY, unitZ = spGetUnitPosition(unitID) + local health, maxHealth = spGetUnitHealth(unitID) + local pastXp = spring.GetUnitExperience(unitID) or 0 + local healthReductionRatio = 1 + if health and maxHealth and maxHealth ~= 0 then + healthReductionRatio = health / maxHealth + end + spring.DestroyUnit(unitID, false, true) + if unitX then + spawnZombies(nil, unitDefID, healthReductionRatio, unitX, unitY, unitZ, false, pastXp) + end + end + end +end + +local function isUnitInLava(unitID) + local _, unitY = spring.GetUnitBasePosition(unitID) + if not unitY then + return false + end + + local lavaLevel = spring.GetGameRulesParam("lavaLevel") + if lavaLevel ~= nil and unitY < lavaLevel then + return true + end + + local waterTypeOverlay = GG.WaterTypeOverlay + if waterTypeOverlay and waterTypeOverlay.isActive() and waterTypeOverlay.getActiveType() == "lava" then + local overlayLevel = waterTypeOverlay.getLevel() + if overlayLevel and unitY < overlayLevel then + return true + end + end + + return false +end + +local function shouldAlwaysLeaveHeap(unitID, weaponDefID, attackerID) -- water/lava deaths always heap so they can't rez from the fluid + if weaponDefID == WATER_DAMAGE_DEF_ID then + return true + end + if not isUnitInLava(unitID) then + return false + end + if not weaponDefID or weaponDefID < 0 then + return true + end + if not attackerID or attackerID < 0 or not spValidUnitID(attackerID) then + return true + end + return false +end + +local function leaveZombieHeap(unitID, unitDefID, attackerID) + local unitX, unitY, unitZ = spGetUnitPosition(unitID) + if not unitX then + return + end + local defData = zombieHeapDefs[unitDefID] + if not defData then + return + end + heapingZombies[unitID] = true -- eat the killing blow and leave a heap instead of a rez-able wreck + spring.DestroyUnit(unitID, false, true, attackerID) + spring.SpawnExplosion(unitX, unitY, unitZ, 0, 0, 0, { weaponDef = defData.explosionDefID, owner = unitID }) + if defData.heapDefID then + spring.CreateFeature(defData.heapDefID, unitX, unitY, unitZ) + end +end + +function gadget:UnitPreDamaged(unitID, unitDefID, unitTeam, damage, paralyzer, weaponDefID, projectileID, attackerID) + if not isZombie(unitID) then + return + end + local leaveHeap = not currentZombieConfig.zombieCorpses or shouldAlwaysLeaveHeap(unitID, weaponDefID, attackerID) + if not leaveHeap then + return + end + local health = spGetUnitHealth(unitID) + if damage >= health then + leaveZombieHeap(unitID, unitDefID, attackerID) + end +end + +---Immediately raises zombies from a corpse feature. Only acts while in idle mode. +---@param featureID FeatureID +---@return boolean spawned `false` when not in idle mode, or the feature is not a zombie corpse. +local function createZombieFromFeature(featureID) + if isIdleMode then + local featureDefID = spring.GetFeatureDefID(featureID) + local featureDefData = zombieCorpseDefs[featureDefID] + if featureDefData and not featureDefData.neverRespawn and isCorpseResurrectable(featureID) then + local featureX, featureY, featureZ = spGetFeaturePosition(featureID) + if featureX then + local healthReductionRatio = calculateHealthRatio(featureID) + local corpseData = corpsesData[featureID] + local wasZombie = wasZombieCorpse(featureID, corpseData) + local pastXp = corpseData and corpseData.pastXp + spawnZombies( + featureID, + featureDefData.unitDefID, + healthReductionRatio, + featureX, + featureY, + featureZ, + wasZombie, + pastXp + ) + return true + end + end + end + return false +end + +---Queues every corpse currently on the map to raise zombies. +local function queueAllCorpsesForSpawning() + local features = spring.GetAllFeatures() + for _, featureID in ipairs(features) do + queueCorpseForSpawning(featureID, true) + end +end + +---Switches all zombies between return-fire with no auto-orders and normal aggression. +---@param enabled boolean `true` to pacify, `false` to restore normal behavior. +local function pacifyZombies(enabled) + if GG.ZombieAI then + GG.ZombieAI.PacifyZombies(enabled) + end +end + +---Stops or resumes the automatic orders given to zombies, without changing fire state. +---@param enabled boolean `true` to suspend auto-orders, `false` to resume them. +local function suspendAutoOrders(enabled) + if GG.ZombieAI then + GG.ZombieAI.SuspendAutoOrders(enabled) + end +end + +local function aggroTeamID(teamID) + if GG.ZombieAI then + return GG.ZombieAI.AggroTeamID(teamID) + end + return false +end + +local function aggroAllyID(allyID) + if GG.ZombieAI then + return GG.ZombieAI.AggroAllyID(allyID) + end + return false +end + +local function killAllZombies() + if GG.ZombieAI then + GG.ZombieAI.KillAllZombies() + end +end + +local function clearAllOrders() + if GG.ZombieAI then + GG.ZombieAI.ClearAllOrders() + end +end + +---Enables or disables raising zombies from corpses automatically. +---Enabling also queues every corpse already on the map. +---@param enabled boolean +local function setAutoSpawning(enabled) + autoSpawningEnabled = enabled + if enabled then + queueAllCorpsesForSpawning() + end +end + +---Drops every queued corpse spawn without affecting zombies already raised. +local function clearAllZombieSpawns() + for featureID in pairs(corpsesData) do + clearCorpseRezRulesParam(featureID) + end + corpsesData = {} + corpseCheckFrames = {} +end + +local function isAuthorized(playerID) + if spring.IsCheatingEnabled() then + return true + end + local playername = spring.GetPlayerInfo(playerID) + local accountID = BAR.Utilities.GetAccountID(playerID) + if + ( + _G.permissions.devhelpers + and (_G.permissions.devhelpers[accountID] or (playername and _G.permissions.devhelpers[playername])) + ) + or ( + SYNCED + and SYNCED.permissions.devhelpers + and (SYNCED.permissions.devhelpers[accountID] or (playername and SYNCED.permissions.devhelpers[playername])) + ) + then + return true + end + return false +end + +---Turns each of the given units into a zombie. +---@param unitIDs UnitID[]? +---@return integer converted Number of units that were valid and converted. +local function convertUnitsToZombies(unitIDs) + if not unitIDs or #unitIDs == 0 then + return 0 + end + + local convertedCount = 0 + for _, unitID in ipairs(unitIDs) do + if spValidUnitID(unitID) then + setZombie(unitID) + convertedCount = convertedCount + 1 + end + end + + return convertedCount +end + +---Turns every Gaia-owned unit that is not already a zombie into one. +---@return integer converted +local function setAllGaiaToZombies() + local allUnits = spring.GetAllUnits() + local convertedCount = 0 + + for _, unitID in ipairs(allUnits) do + local unitTeam = spring.GetUnitTeam(unitID) + if unitTeam == gaiaTeamID and not isZombie(unitID) then + setZombie(unitID) + convertedCount = convertedCount + 1 + end + end + + return convertedCount +end + +local function commandSetAllGaiaToZombies(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + local convertedCount = setAllGaiaToZombies() + spring.SendMessageToPlayer(playerID, "Set " .. convertedCount .. " Gaia units as zombies") +end + +local function commandQueueAllCorpsesForReanimation(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + queueAllCorpsesForSpawning() + spring.SendMessageToPlayer(playerID, "Queued all corpses for spawning") +end + +local function commandToggleAutoReanimation(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + if #words == 0 then + spring.SendMessageToPlayer(playerID, "Usage: /luarules zombieautospawn 0|1") + return + end + + local enabled = tonumber(words[1]) + if enabled == nil or (enabled ~= 0 and enabled ~= 1) then + spring.SendMessageToPlayer(playerID, "Invalid value. Use 0 to disable or 1 to enable") + return + end + + setAutoSpawning(enabled == 1) + spring.SendMessageToPlayer(playerID, "Auto spawning " .. (enabled == 1 and "enabled" or "disabled")) +end + +local function commandPacifyZombies(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + if #words == 0 then + spring.SendMessageToPlayer(playerID, "Usage: /luarules zombiepacify 0|1") + return + end + + local enabled = tonumber(words[1]) + if enabled == nil or (enabled ~= 0 and enabled ~= 1) then + spring.SendMessageToPlayer(playerID, "Invalid value. Use 0 to disable or 1 to enable") + return + end + + pacifyZombies(enabled == 1) + spring.SendMessageToPlayer(playerID, "Zombies " .. (enabled == 1 and "pacified" or "unpacified")) +end + +local function commandSuspendAutoOrders(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + if #words == 0 then + spring.SendMessageToPlayer(playerID, "Usage: /luarules zombiesuspendorders 0|1") + return + end + + local enabled = tonumber(words[1]) + if enabled == nil or (enabled ~= 0 and enabled ~= 1) then + spring.SendMessageToPlayer(playerID, "Invalid value. Use 0 to disable or 1 to enable") + return + end + + suspendAutoOrders(enabled == 1) + spring.SendMessageToPlayer(playerID, "Zombie auto-orders " .. (enabled == 1 and "suspended" or "resumed")) +end + +local function commandAggroZombiesToTeam(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + if #words == 0 then + spring.SendMessageToPlayer(playerID, "Usage: /luarules zombieaggroteam ") + return + end + + local targetTeamID = tonumber(words[1]) + if not targetTeamID or targetTeamID < 0 then + spring.SendMessageToPlayer(playerID, "Invalid team ID") + return + end + + local success = aggroTeamID(targetTeamID) + if success then + spring.SendMessageToPlayer(playerID, "Zombies aggroed to team " .. targetTeamID) + else + spring.SendMessageToPlayer(playerID, "Team " .. targetTeamID .. " not found or has no units") + end +end + +local function commandAggroZombiesToAlly(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + if #words == 0 then + spring.SendMessageToPlayer(playerID, "Usage: /luarules zombieaggroally ") + return + end + + local targetAllyID = tonumber(words[1]) + if not targetAllyID or targetAllyID < 0 then + spring.SendMessageToPlayer(playerID, "Invalid ally ID") + return + end + + local success = aggroAllyID(targetAllyID) + if success then + spring.SendMessageToPlayer(playerID, "Zombies aggroed to ally team " .. targetAllyID) + else + spring.SendMessageToPlayer(playerID, "Ally team " .. targetAllyID .. " not found or has no units") + end +end + +local function commandKillAllZombies(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + killAllZombies() + spring.SendMessageToPlayer(playerID, "Killed all zombies") +end + +local function commandClearAllZombieOrders(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + clearAllOrders() + spring.SendMessageToPlayer(playerID, "Cleared zombie orders") +end + +local function commandClearZombieSpawns(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + clearAllZombieSpawns() + spring.SendMessageToPlayer(playerID, "Cleared all queued zombie spawns") +end + +---Switches the zombie difficulty preset. +---@param mode ZombieMode +---@return boolean applied `false` when `mode` is not a known preset. +local function setZombieMode(mode) + ---@diagnostic disable-next-line: unnecessary-if + if mode ~= "normal" and mode ~= "hard" and mode ~= "nightmare" and mode ~= "akumu" then + return false + end + + currentZombieMode = mode + applyZombieModeSettings(mode) + return true +end + +local function getZombieMode() + return currentZombieMode +end + +local function commandSetZombieMode(_, line, words, playerID) + if not isAuthorized(playerID) then + spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) + return + end + + if #words == 0 then + spring.SendMessageToPlayer(playerID, "Usage: /luarules zombiemode normal|hard|nightmare|akumu") + return + end + + local mode = string.lower(words[1]) + if mode ~= "normal" and mode ~= "hard" and mode ~= "nightmare" and mode ~= "akumu" then + spring.SendMessageToPlayer(playerID, "Invalid mode. Use: normal, hard, nightmare, or akumu") + return + end + + setZombieMode(mode) + spring.SendMessageToPlayer(playerID, "Zombie mode set to " .. mode) +end + +function gadget:Initialize() + local initialMode = modOptions.zombies or "normal" + applyZombieModeSettings(initialMode) + + autoSpawningEnabled = modOptionEnabled and not isIdleMode + + gameFrame = spring.GetGameFrame() + + local units = spring.GetAllUnits() + for _, unitID in ipairs(units) do + if isZombie(unitID) then + setZombie(unitID) + end + end + + if not isIdleMode then + local features = spring.GetAllFeatures() + for _, featureID in ipairs(features) do + gadget:FeatureCreated(featureID, gaiaTeamID) + end + end + + GG.Zombies = { IdleMode = isIdleMode } + GG.Zombies.SetZombie = setZombie + GG.Zombies.ConvertUnitsToZombies = convertUnitsToZombies + GG.Zombies.SetAllGaiaToZombies = setAllGaiaToZombies + GG.Zombies.CreateZombieFromFeature = createZombieFromFeature + GG.Zombies.QueueAllCorpsesForSpawning = queueAllCorpsesForSpawning + GG.Zombies.SetAutoSpawning = setAutoSpawning + GG.Zombies.ClearAllZombieSpawns = clearAllZombieSpawns + GG.Zombies.PacifyZombies = pacifyZombies + GG.Zombies.SuspendAutoOrders = suspendAutoOrders + GG.Zombies.AggroTeamID = aggroTeamID + GG.Zombies.AggroAllyID = aggroAllyID + GG.Zombies.KillAllZombies = killAllZombies + GG.Zombies.ClearAllOrders = clearAllOrders + GG.Zombies.SetZombieMode = setZombieMode + GG.Zombies.GetZombieMode = getZombieMode + + gadgetHandler:AddChatAction("zombiesetallgaia", commandSetAllGaiaToZombies, "Set all Gaia units as zombies") + gadgetHandler:AddChatAction( + "zombiequeueallcorpses", + commandQueueAllCorpsesForReanimation, + "Queue all corpses for spawning" + ) + gadgetHandler:AddChatAction("zombieautospawn", commandToggleAutoReanimation, "Enable/disable auto spawning") + gadgetHandler:AddChatAction("zombieclearspawns", commandClearZombieSpawns, "Clear all queued zombie spawns") + gadgetHandler:AddChatAction("zombiepacify", commandPacifyZombies, "Pacify/unpacify zombies") + gadgetHandler:AddChatAction("zombiesuspendorders", commandSuspendAutoOrders, "Suspend/resume zombie auto-orders") + gadgetHandler:AddChatAction("zombieaggroteam", commandAggroZombiesToTeam, "Make zombies aggro to specific team") + gadgetHandler:AddChatAction("zombieaggroally", commandAggroZombiesToAlly, "Make zombies aggro to entire ally team") + gadgetHandler:AddChatAction("zombiekillall", commandKillAllZombies, "Kill all zombies") + gadgetHandler:AddChatAction("zombieclearallorders", commandClearAllZombieOrders, "Clear allzombie orders") + gadgetHandler:AddChatAction("zombiemode", commandSetZombieMode, "Set zombie mode (normal/hard/nightmare/akumu)") +end + +function gadget:Shutdown() + gadgetHandler:RemoveChatAction("zombiesetallgaia") + gadgetHandler:RemoveChatAction("zombiequeueallcorpses") + gadgetHandler:RemoveChatAction("zombieautospawn") + gadgetHandler:RemoveChatAction("zombieclearspawns") + gadgetHandler:RemoveChatAction("zombiepacify") + gadgetHandler:RemoveChatAction("zombiesuspendorders") + gadgetHandler:RemoveChatAction("zombieaggroteam") + gadgetHandler:RemoveChatAction("zombieaggroally") + gadgetHandler:RemoveChatAction("zombiekillall") + gadgetHandler:RemoveChatAction("zombieclearallorders") + gadgetHandler:RemoveChatAction("zombiemode") +end + +function gadget:GamePreload() + local currentUnitCap = spring.GetTeamMaxUnits(gaiaTeamID) + local newUnitCap = math.max(ZOMBIE_UNIT_CAP_FLOOR, currentUnitCap) + spring.SetTeamMaxUnits(gaiaTeamID, newUnitCap) +end + +function gadget:GameStart() + setGaiaStorage() +end diff --git a/luarules/gadgets/gfx_beam_laser_gl4.lua b/luarules/gadgets/gfx_beam_laser_gl4.lua index f1a5554a762..192c3fd9956 100644 --- a/luarules/gadgets/gfx_beam_laser_gl4.lua +++ b/luarules/gadgets/gfx_beam_laser_gl4.lua @@ -24,7 +24,6 @@ end -------------------------------------------------------------------------------- -- Localized functions -------------------------------------------------------------------------------- -local spEcho = Spring.Echo local spGetProjectilePosition = Spring.GetProjectilePosition local spGetProjectileVelocity = Spring.GetProjectileVelocity local spGetProjectileDefID = Spring.GetProjectileDefID @@ -305,7 +304,6 @@ end -- Max value = 65535 * 4096^3 + 4095 * 4096^2 + 4095 * 4096 + 4095 ≈ 4.5e15, -- well under Lua's 2^53 ≈ 9e15 safe-integer ceiling for doubles. local BEAM_KEY_AXIS_OFFSET = 2048 -local BEAM_KEY_BZ_MUL = 1 local BEAM_KEY_BY_MUL = 4096 local BEAM_KEY_BX_MUL = 4096 * 4096 local BEAM_KEY_WDEFID_MUL = 4096 * 4096 * 4096 @@ -1295,9 +1293,7 @@ end local FADE_OUT_START_CACHED = shaderConfig.FADE_OUT_START local ONE_MINUS_FADE_OUT = 1.0 - FADE_OUT_START_CACHED --- Pre-computed constants for live beams (lifeFrac is fixed at BEAM_SUSTAIN_LIFEFRAC) local LIVE_LIFEFRAC = BEAM_SUSTAIN_LIFEFRAC -local LIVE_FLARE_PULSE = 1.0 - LIVE_LIFEFRAC * FLARE_LIFE_DIM local mapSizeX = Game.mapSizeX local mapSizeZ = Game.mapSizeZ diff --git a/luarules/gadgets/gfx_emp_lightning.lua b/luarules/gadgets/gfx_emp_lightning.lua index 076b82ab198..23481d0a2e8 100644 --- a/luarules/gadgets/gfx_emp_lightning.lua +++ b/luarules/gadgets/gfx_emp_lightning.lua @@ -96,10 +96,6 @@ local mathCos = math.cos local mathPi = math.pi local mathRandom = math.random -local function clamp(v, lo, hi) - return v < lo and lo or (v > hi and hi or v) -end - -------------------------------------------------------------------------------- -- State -------------------------------------------------------------------------------- diff --git a/luarules/gadgets/gfx_environmental_lightning_gl4.lua b/luarules/gadgets/gfx_environmental_lightning_gl4.lua index 9a85c2aa69c..7bd3168f462 100644 --- a/luarules/gadgets/gfx_environmental_lightning_gl4.lua +++ b/luarules/gadgets/gfx_environmental_lightning_gl4.lua @@ -1551,7 +1551,6 @@ local function updateBolts() local w = 0 for r = 1, nActive do local burst = active[r] - local cfg = burst.cfg local age = frame - burst.birthFrame local life = age / burst.lifeFrames if life < 1.0 then diff --git a/luarules/gadgets/gfx_lightning_cannon_gl4.lua b/luarules/gadgets/gfx_lightning_cannon_gl4.lua index 08d2a87973a..2a1ecc95393 100644 --- a/luarules/gadgets/gfx_lightning_cannon_gl4.lua +++ b/luarules/gadgets/gfx_lightning_cannon_gl4.lua @@ -1011,6 +1011,9 @@ end -------------------------------------------------------------------------------- -- Push one segment-instance into beamData +-- This is a reference implementation. The functionality is duplicated in the segment +-- loops below to avoid a function call per segment. Keep all three copies in sync. +--[[ local function pushSegment( beamData, offset, @@ -1057,6 +1060,7 @@ local function pushSegment( beamData[offset + 23] = glowMult beamData[offset + 24] = impactSize end +]] local INSTANCE_STRIDE = 24 @@ -1080,7 +1084,8 @@ local function emitBolt(beamData, offset, beamCount, cfg, t, lifeFrac) local tEx, tEy, tEz = t.ex, t.ey, t.ez local tSeed = t.seed - -- Main bolt: segCount segment-instances (inlined pushSegment for hot path) + -- Main bolt: segCount segment-instances + -- Inlined from commented pushSegment above. Keep all three copies in sync. local segs = cfg.segments for s = 0, segs - 1 do beamData[offset + 1] = tPx @@ -1226,7 +1231,7 @@ local function emitBolt(beamData, offset, beamCount, cfg, t, lifeFrac) local bez = az + br.dirZ * blen local branchSeed = br.branchSeed for s = 0, bsegs - 1 do - -- Inlined pushSegment for hot path (eliminates closure call per segment) + -- Inlined from commented pushSegment above. Keep all three copies in sync. beamData[offset + 1] = ax beamData[offset + 2] = ay beamData[offset + 3] = az diff --git a/luarules/gadgets/gfx_missile_smoke.lua b/luarules/gadgets/gfx_missile_smoke.lua index 0974aa54156..b637133a2a9 100644 --- a/luarules/gadgets/gfx_missile_smoke.lua +++ b/luarules/gadgets/gfx_missile_smoke.lua @@ -6,7 +6,7 @@ local gadget = gadget ---@type Gadget function gadget:GetInfo() return { name = "Missile smoke", - desc = "addes smoke ceg after missile flighttime is over", + desc = "adds smoke ceg after missile flighttime is over", version = "tart", author = "Floris", date = "October 2017", diff --git a/luarules/gadgets/gfx_nano_particles_gl4.lua b/luarules/gadgets/gfx_nano_particles_gl4.lua index 7e5b9083002..21fe1bb1388 100644 --- a/luarules/gadgets/gfx_nano_particles_gl4.lua +++ b/luarules/gadgets/gfx_nano_particles_gl4.lua @@ -81,7 +81,6 @@ local spGetUnitCurrentBuildPower = Spring.GetUnitCurrentBuildPower local spGetUnitWorkerTask = Spring.GetUnitWorkerTask local spGetUnitHealth = Spring.GetUnitHealth local spGetUnitMoveTypeData = Spring.GetUnitMoveTypeData -local spIsUnitVisible = Spring.IsUnitVisible local spGetUnitCollisionVolumeData = Spring.GetUnitCollisionVolumeData local spGetGroundHeight = Spring.GetGroundHeight @@ -96,7 +95,6 @@ local GL_SRC_ALPHA = GL.SRC_ALPHA local LuaShader = gl.LuaShader local InstanceVBOTable = gl.InstanceVBOTable -local pushElementInstance = InstanceVBOTable.pushElementInstance local popElementInstance = InstanceVBOTable.popElementInstance local uploadElementRange = InstanceVBOTable.uploadElementRange @@ -135,7 +133,6 @@ local function refreshMaxParticles() MAX_PARTICLES = computeMaxParticles() end -local NANO_TEXTURE = "bitmaps/projectiletextures/nanopart.tga" local LOS_FILTER = true -- drop emissions outside our LOS -- Render mode: "shape" (3D polyhedra via geometry shader; specific shape in @@ -339,24 +336,45 @@ local DISTANT_EMIT_FAR_SQ = DISTANT_EMIT_RANGE * DISTANT_EMIT_RANGE local DISTANT_EMIT_BAND_INV = 1.0 / (DISTANT_EMIT_FAR_SQ - DISTANT_EMIT_NEAR_SQ) local DISTANT_EMIT_DROP = 1.0 - DISTANT_EMIT_KEEP --- Dynamic scan stride: builders are scanned 1/stride per sim frame. Per-builder --- emit count is multiplied by stride so total rate is preserved. Grows with --- pool saturation (the gate would drop most emissions at high fill anyway). -local MIN_SCAN_STRIDE = 1 +-- Visit stride and saturation throttle (see the scanBuilders header): +-- MIN_SCAN_STRIDE -- how often a builder is visited: every +-- MIN_SCAN_STRIDE * runEvery sim frames. Each visit emits +-- the frames it accounts for, with per-particle spawn +-- frames spread over the next MIN_SCAN_STRIDE frames (see +-- emitNano) so the stream stays continuous. Every visit +-- carries the full per-visit cost (engine queries, target +-- resolution, emitter setup) for about one particle, so a +-- larger stride is almost a proportional saving. The price +-- grows with it: up to (stride - 1) frames of emission +-- latency, that many frames of nozzle offset on moving +-- nozzles (factory arms, walking constructors), and the +-- queued not-yet-due particles occupy pool slots while +-- invisible (about emission-per-frame * stride / 2 slots). +-- 2-4 is the sensible range. +-- MAX_SCAN_STRIDE -- NOT a visit stride: the throttle's stride divisor at +-- full saturation (throttleStride ramps 1 -> MAX with pool +-- fill). With MAX_SCAN_RUN_EVERY it sets the per-frame +-- emission divisor when the pool is full (2 * 3 = rate/6), +-- i.e. the pool-fill curve. Emission per visit is +-- compensated by MIN_SCAN_STRIDE / throttleStride, so +-- changing MIN_SCAN_STRIDE does not change that curve; +-- raising MAX_SCAN_STRIDE makes the saturated pool sparser. +local MIN_SCAN_STRIDE = 5 local MAX_SCAN_STRIDE = 2 +-- Emitter geometry cache for static single-nozzle builders (see emitNano): +-- refresh cadence, and the settle window after a target change during which +-- the turret head may still be turning toward it. +local EMIT_CTX_FRAMES = 12 +local EMIT_CTX_SETTLE_FRAMES = 45 + -- Engine constants (rts/Sim/Projectiles/ProjectileHandler.cpp) local NANO_SPEED = 4.0 -- engine default: 4.0 (recently updated) --- Anti-clump: half-width (elmos) of the symmetric stagger window around the --- nanopiece. Particles in a batch are spread along their velocity in --- [-MAX_SPREAD_AHEAD_ELMOS, +MAX_SPREAD_AHEAD_ELMOS], so a few sit slightly --- behind the emit point (partially occluded by the builder model) and the --- rest just ahead. Just enough to break up the visible "blob" without making --- particles appear detached from the source. Direction jitter already --- provides lateral spread; this only fixes the on-axis pile-up. +-- Padding (elmos) added to the homing view-sphere radius around a stream, so +-- particles that were nudged a little away from the nozzle still count as +-- inside the sphere for the frustum skip. local MAX_SPREAD_AHEAD_ELMOS = 6 -local MAX_SPREAD_AHEAD_FRAMES = MAX_SPREAD_AHEAD_ELMOS / NANO_SPEED -- Shape selector for cube-mode geometry shader. The GS branches on this and -- emits the corresponding polyhedron's faces. All shapes use the same per-face @@ -374,10 +392,6 @@ local SHAPE_IDS = { cube = 0, octahedron = 1 } -- sign = negative iff inverse (reclaim) -- sizeMult expected in [0, 4); fadeFrames integer in [0, ~120]. Magnitude is -- always > 0 since spawnParticle uses sizeMult ~1, so the sign bit is free. -local function packSizeFade(sizeMult, fadeFrames, inverse) - local v = mathFloor(sizeMult * 256 + 0.5) + (fadeFrames or 0) * 1024 - return inverse and -v or v -end -- The engine API takes rotVel in deg/sec and rotAcc in deg/sec^2 and internally -- divides by GAME_SPEED to convert to per-frame units. We integrate per-frame in @@ -392,21 +406,34 @@ local GAME_SPEED = Game.gameSpeed or 30 -- particle reads still hit a local rather than a table key. local U = {} -U.NANO_PARTICLES_HOMING = Spring.GetConfigInt("NanoParticlesHoming", 0) ~= 0 -U.NANO_PARTICLES_RECLAIM_BURST = Spring.GetConfigInt("NanoParticlesReclaimBurst", 0) ~= 0 +U.NANO_PARTICLES_HOMING = Spring.GetConfigInt("NanoParticlesHoming", 1) ~= 0 +U.NANO_PARTICLES_RECLAIM_BURST = Spring.GetConfigInt("NanoParticlesReclaimBurst", 1) ~= 0 -- Optional terrain clamp for particle paths. Disabled by default because it -- adds extra ground-height queries in hot paths. -U.GROUND_CLAMP_ENABLED = Spring.GetConfigInt("NanoParticlesGroundClamp", 0) ~= 0 +U.GROUND_CLAMP_ENABLED = Spring.GetConfigInt("NanoParticlesGroundClamp", 1) ~= 0 U.GROUND_CLAMP_MARGIN = 11.0 -- In-flight correction cadence. Enabled mode can periodically reproject active -- particles above terrain to prevent straight-line tunneling through cliffs. -- 0 means "all active particles each pass". -U.GROUND_CLAMP_RUN_EVERY = 6 +U.GROUND_CLAMP_RUN_EVERY = 1 +-- With RUN_EVERY 1 and MAX_PER_STEP 0, each frame examines a 1/SPREAD_FRAMES +-- slice of the clamp entries (cursor rotates), so every entry is still seen +-- every SPREAD_FRAMES frames but the pass never lands on one frame. +U.GROUND_CLAMP_SPREAD_FRAMES = 6 +-- Homing passes: true = every frame on a 1/HOMING_RUN_EVERY coset of the +-- lists (no one-frame spike); false = the whole pass every HOMING_RUN_EVERY. +U.HOMING_SPREAD = true U.GROUND_CLAMP_MAX_PER_STEP = 0 U.GROUND_CLAMP_RECHECK_HIT = 6 U.GROUND_CLAMP_RECHECK_MISS = 12 U.GROUND_CLAMP_USE_WAYPOINT = true +-- When a waypoint leg ends, a particle is re-aimed at its landing point at +-- whatever speed reaches it by its death frame. A particle with only a few +-- frames left (death pulled in by a fade, or the leg examined late in the +-- slice rotation) would need many times the nano speed; above this multiple +-- of NANO_SPEED it keeps its current heading and dissolves instead. +U.GROUND_CLAMP_WAYPOINT_MAX_SPEED_MULT = 3.0 -- Smart gate: only enable clamp for builders/targets in rough terrain. U.GROUND_CLAMP_SMART = true U.GROUND_CLAMP_SMART_DELTA = 4.0 @@ -415,7 +442,7 @@ U.GROUND_CLAMP_SMART_CACHE_FRAMES = 45 U.GROUND_CACHE_INV_CELL = 1 / 16 U.GROUND_CACHE_STRIDE = mathFloor(((Game.mapSizeZ or 65536) * U.GROUND_CACHE_INV_CELL) + 0.5) + 1024 -U.GROUND_CACHE_SLOTS = 4096 +U.GROUND_CACHE_SLOTS = 32768 U._groundYCache = {} U._groundYStamp = {} U._groundYKey = {} @@ -441,6 +468,32 @@ do U._jitterTableLast = idx - 3 end +-- Uniform [0,1) sample table for the per-particle variation inside emitNano +-- (speed, stagger, catch-up age, rotation, size, alpha). One math.random per +-- batch picks the start cursor so consecutive batches never replay the same +-- run; the seven per-particle draws are then array reads instead of C calls. +U._randTable = {} +U._RAND_TABLE_SIZE = 4096 +do + local rt = U._randTable + for i = 1, U._RAND_TABLE_SIZE do + rt[i] = mathRandom() + end +end + +-- Deferred-light spawn batching. emitNano appends 21-value records here and +-- the batch is handed to the lights widget in ONE Script.LuaUI call per sim +-- frame (one cross-VM CopyData + one VBO upload on the widget side) instead +-- of one call and one upload per sampled particle. +U._lightBatch = {} +U._lightBatchN = 0 -- values used in U._lightBatch +U._lightBatchCount = 0 -- records in U._lightBatch +U._LIGHT_RECORD_SIZE = 19 + +-- Cap on cold-stream catch-up particles materialized per draw frame (see +-- materializeVisibleVirtualStreams). +U.VIRTUAL_PARTICLE_BUDGET_PER_DRAW = 256 + -- Clamp-focused debug stream (lightweight; independent from full DEBUG timers) local CLAMP_DEBUG = false local clampDbg = { @@ -453,35 +506,57 @@ local clampDbg = { maxSubset = 0, } --- Ground-height cache for clamp hot paths. Quantized keys trade tiny spatial --- precision for far fewer Spring.GetGroundHeight calls in dense sprays. -local function getGroundYMargin(x, z, frame) - if frame then - local qx = mathFloor(x * U.GROUND_CACHE_INV_CELL + 0.5) - local qz = mathFloor(z * U.GROUND_CACHE_INV_CELL + 0.5) - local key = qx * U.GROUND_CACHE_STRIDE + qz - local slot = (key % U.GROUND_CACHE_SLOTS) + 1 - if U._groundYStamp[slot] == frame and U._groundYKey[slot] == key then - return U._groundYCache[slot] +-- Ground-height cache for clamp hot paths. Quantized keys (16-elmo cells) +-- trade tiny spatial precision for far fewer Spring.GetGroundHeight calls. +-- Entries stay valid for GROUND_CACHE_FRAMES sim frames: terrain only changes +-- through slow deformation, and a one-second-stale height is irrelevant for a +-- particle clamp. The long validity is what makes the maintenance passes +-- cheap: the particles of one stream follow the same path, so the leading +-- particle paves the cells and the followers -- examined on later frames -- +-- hit the cache. The slot count is sized for that working set (every live +-- stream's path cells plus builder path samples and endpoints). +U.GROUND_CACHE_FRAMES = 32 +-- Hot path (about a thousand calls per frame from the maintenance passes): +-- the constants and cache arrays are captured as upvalues inside a block so +-- the lookup costs no string-key table reads and no main-chunk locals. +local getGroundYMargin, clampYAboveGround +do + local invCell = U.GROUND_CACHE_INV_CELL + local cacheStride = U.GROUND_CACHE_STRIDE + local cacheSlots = U.GROUND_CACHE_SLOTS + local cacheFrames = U.GROUND_CACHE_FRAMES + local margin = U.GROUND_CLAMP_MARGIN + local yStamp, yKey, yCache = U._groundYStamp, U._groundYKey, U._groundYCache + + getGroundYMargin = function(x, z, frame) + if frame then + local stamp = frame - (frame % cacheFrames) + local qx = mathFloor(x * invCell + 0.5) + local qz = mathFloor(z * invCell + 0.5) + local key = qx * cacheStride + qz + local slot = (key % cacheSlots) + 1 + if yStamp[slot] == stamp and yKey[slot] == key then + return yCache[slot] + end + local gy = spGetGroundHeight(x, z) + margin + yStamp[slot] = stamp + yKey[slot] = key + yCache[slot] = gy + return gy end - local gy = spGetGroundHeight(x, z) + U.GROUND_CLAMP_MARGIN - U._groundYStamp[slot] = frame - U._groundYKey[slot] = key - U._groundYCache[slot] = gy - return gy + return spGetGroundHeight(x, z) + margin end - return spGetGroundHeight(x, z) + U.GROUND_CLAMP_MARGIN -end -local function clampYAboveGround(x, y, z, frame) - if not U.GROUND_CLAMP_ENABLED then + clampYAboveGround = function(x, y, z, frame) + if not U.GROUND_CLAMP_ENABLED then + return y + end + local gy = getGroundYMargin(x, z, frame) + if y < gy then + return gy + end return y end - local gy = getGroundYMargin(x, z, frame) - if y < gy then - return gy - end - return y end local function shouldClampEmit(builderID, sx, sy, sz, ex, ey, ez, frame) @@ -573,7 +648,12 @@ local groundClampCursor = 1 local groundClampParticles = {} local groundClampFree = {} -local function registerGroundClampParticle(id, death, wp, fx, fy, fz, targetID) +-- One entry per emitNano batch (particle IDs [f, f + n)), see the tracking +-- record layout above emitNano. wp: frame at which the batch's waypoint leg +-- ends (nil for plain recheck mode). ex/ey/ez + jc/js: batch endpoint and +-- jitter basis so the waypoint redirect can rebuild each particle's own +-- landing point. +local function registerGroundClampRecord(firstID, count, death, wp, ex, ey, ez, targetID, jc, js) local nFree = #groundClampFree local entry = groundClampFree[nFree] if entry then @@ -581,17 +661,20 @@ local function registerGroundClampParticle(id, death, wp, fx, fy, fz, targetID) else entry = {} end - entry.id = id + entry.f = firstID + entry.n = count entry.death = death entry.wp = wp - entry.fx = fx - entry.fy = fy - entry.fz = fz + entry.ex = ex + entry.ey = ey + entry.ez = ez entry.targetID = targetID + entry.jc = jc + entry.js = js entry.next = wp or 0 groundClampParticles[#groundClampParticles + 1] = entry if CLAMP_DEBUG then - clampDbg.registered = clampDbg.registered + 1 + clampDbg.registered = clampDbg.registered + count end end @@ -775,9 +858,174 @@ function U.releaseDeathBucket(bucket) free[#free + 1] = bucket end --- Shared scratch table reused for every pushElementInstance call -- avoids --- allocating a fresh 16-element array per spawn (thousands per second). -local instanceScratch = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } +-- Hand this frame's sampled light spawns to the lights widget. One +-- Script.LuaUI call with the flat record array when the widget offers the +-- batched entry point, else the legacy one-call-per-light path. +function U.flushLightBatch() + local count = U._lightBatchCount + if count == 0 then + return + end + local batch = U._lightBatch + local n = U._lightBatchN + U._lightBatchCount = 0 + U._lightBatchN = 0 + local nl = deathBuckets.__nanoLight + if not (nl and nl.bridgeReady) then + return + end + -- Drop stale values left over from a bigger earlier batch so the cross-VM + -- copy only carries this frame's records. + for i = #batch, n + 1, -1 do + batch[i] = nil + end + if nl.batchReady then + Script.LuaUI.EnvNanoBallisticLightSpawnBatch(batch, count) + return + end + local spawn = Script.LuaUI.EnvNanoBallisticLightSpawn + for i = 0, count - 1 do + local o = i * U._LIGHT_RECORD_SIZE + spawn( + batch[o + 1], + batch[o + 2], + batch[o + 3], + batch[o + 4], + batch[o + 5], + batch[o + 6], + batch[o + 7], + batch[o + 8], + batch[o + 9], + batch[o + 10], + batch[o + 11], + batch[o + 12], + batch[o + 13], + batch[o + 14], + batch[o + 15], + batch[o + 16], + batch[o + 17], + batch[o + 18], + batch[o + 19] + ) + end +end + +-- Same idea for the homing passes' light corrections (8 values per record, +-- same argument order as EnvNanoBallisticLightCorrect) and for the light +-- removals issued by cullDead: one cross-VM call per pass instead of one per +-- light. +U._lightCorrBatch = {} +U._lightCorrN = 0 +U._lightCorrCount = 0 +U._lightRemoveBatch = {} +function U.queueLightCorrection(lightID, x, y, z, vx, vy, vz, frame) + local batch = U._lightCorrBatch + local n = U._lightCorrN + batch[n + 1] = lightID + batch[n + 2] = x + batch[n + 3] = y + batch[n + 4] = z + batch[n + 5] = vx + batch[n + 6] = vy + batch[n + 7] = vz + batch[n + 8] = frame + U._lightCorrN = n + 8 + U._lightCorrCount = U._lightCorrCount + 1 +end + +function U.flushLightCorrections() + local count = U._lightCorrCount + if count == 0 then + return + end + local batch = U._lightCorrBatch + local n = U._lightCorrN + U._lightCorrCount = 0 + U._lightCorrN = 0 + local nl = deathBuckets.__nanoLight + if not (nl and nl.bridgeReady) then + return + end + for i = #batch, n + 1, -1 do + batch[i] = nil + end + if nl.correctBatchReady then + Script.LuaUI.EnvNanoBallisticLightCorrectBatch(batch, count) + return + end + local correct = Script.LuaUI.EnvNanoBallisticLightCorrect + for i = 0, count - 1 do + local o = i * 8 + correct( + batch[o + 1], + batch[o + 2], + batch[o + 3], + batch[o + 4], + batch[o + 5], + batch[o + 6], + batch[o + 7], + batch[o + 8] + ) + end +end + +-- Rewritten-slot tracking for the maintenance passes and death fades. Every +-- in-place VBO-mirror rewrite appends its slot here; the flush uploads them +-- one element at a time when they are scattered (the normal case: a range +-- upload spanning the whole pool marshals every element in between, which +-- costs about as much per element as a dedicated upload) and falls back to +-- one range upload when the touched slots are dense. +U._dirtySlots = {} +U._dirtySlotN = 0 +U.DIRTY_SCATTER_RATIO = 2 +function U.flushDirtySlots() + local dn = U._dirtySlotN + U._dirtySlotN = 0 + local vbo = nanoVBO + if dn == 0 or not vbo then + return + end + local slots = U._dirtySlots + local minSlot, maxSlot = slots[1], slots[1] + for i = 2, dn do + local s = slots[i] + if s < minSlot then + minSlot = s + elseif s > maxSlot then + maxSlot = s + end + end + if dn * U.DIRTY_SCATTER_RATIO < (maxSlot - minSlot + 1) then + local data = vbo.instanceData + local step = vbo.instanceStep + local gpu = vbo.instanceVBO + for i = 1, dn do + local base = (slots[i] - 1) * step + gpu:Upload(data, nil, slots[i] - 1, base + 1, base + step) + end + else + uploadElementRange(vbo, minSlot - 1, maxSlot) + end +end + +function U.flushLightRemovals(count) + local batch = U._lightRemoveBatch + for i = #batch, count + 1, -1 do + batch[i] = nil + end + local nl = deathBuckets.__nanoLight + if not (nl and nl.bridgeReady) then + return + end + if nl.removeBatchReady then + Script.LuaUI.EnvNanoBallisticLightRemoveBatch(batch, count) + return + end + local remove = Script.LuaUI.EnvNanoBallisticLightRemove + for i = 1, count do + remove(batch[i]) + end +end -- Per-builder cache: avoids re-fetching nano pieces / team color every frame. -- builderID -> info table, or false sentinel for non-builders / teamless units. @@ -822,7 +1070,6 @@ end -- Refreshed from Update on a 1s sim-frame cadence (cheap, one Spring.GetGameSpeed call). local GAMESPEED_THROTTLE_START = 1.5 -- below this, no extra throttle local GAMESPEED_THROTTLE_FULL = 5.0 -- at or above this, full throttle -local GAMESPEED_EMIT_CUT = 0.66 -- emitProb cut at full throttle (0..1) local GAMESPEED_MAX_CUT = 0.85 -- effective-max cut at full throttle (0..1) local speedThrottle = 0.0 -- 0 = none, 1 = max (set from Update) @@ -886,7 +1133,9 @@ void main() { float spawnFrame = velAndSpawnFrame.w; float deathFrame = rotData.w; - if (currentFrame >= deathFrame) { + // Dead, or not yet due: stride-staggered batches carry spawn frames a + // little ahead of the frame they were written in. + if (currentFrame >= deathFrame || currentFrame < spawnFrame) { v_dead = 1.0; gl_Position = vec4(2.0, 2.0, 2.0, 1.0); v_worldPos = vec3(0.0); @@ -1629,29 +1878,23 @@ local piecePosEpoch = 0 -- by GetUnitWorkerTask (so feature IDs naturally don't collide with units). local emitTargetPosCache = {} --- Factory-completion grace markers: target unit ID -> completion frame. While --- present, terrain-waypoint particles keep their final factory-pad endpoint --- instead of chasing the completed unit as it rolls out. Expired markers are --- removed in scanBuilders so factory production cannot grow this table forever. -local recentFactoryBuildTargetCache = {} - -- Reclaim/homing particles: in-flight inverse particles (those travelling back -- toward a builder) re-aim each frame to follow the builder's CURRENT piece -- position. Particle position formula is `pos = spawn + vel * (frame - spawnFrame)`, -- so we rewrite spawn = current pos, vel = (newTarget - currentPos)/remaining, -- spawnFrame = current frame. Death stays the same. --- homingByBuilder[builderID] = { {id=, pieceIdx=, death=}, ... } +-- homingByBuilder[builderID] = { record, ... } (see appendTrackRecord) local homingByBuilder = {} -local HOMING_MAX_PER_BUILDER = 96 -- safety cap; oldest entries drop off +local HOMING_MAX_PER_BUILDER = 128 -- safety cap in records (one per emit batch); oldest drop off -- Forward homing: outbound particles aimed at a UNIT target (repair, capture) -- bend toward the target's CURRENT mid-position. Same rewrite trick as inverse. -- Keyed by target so each target's position resolves once per frame. --- homingFwdByTarget[targetUnitID] = { {id=, death=}, ... } +-- homingFwdByTarget[targetUnitID] = { record, ... } (see appendTrackRecord) local homingFwdByTarget = {} local targetPosCache = {} -- unitID -> [epoch, x, y, z] -local targetIncompleteCache = {} -- unitID -> [epoch, isBeingBuilt] -local HOMING_FWD_MAX_PER_TARGET = 384 -- safety cap per repaired/captured unit +local targetIncompleteCache = {} -- unitID -> [epoch, isBeingBuilt, lastIncompleteFrame] +local HOMING_FWD_MAX_PER_TARGET = 512 -- safety cap in records per repaired/captured/assisted unit -- Reclaim-completion burst tracking. While a tracked unit is being reclaimed -- by one or more of our builders, we record the builder set so that on @@ -1671,9 +1914,11 @@ local reclaimTargetBuildProgress = {} -- Forward emissions aimed at an UNFINISHED unit are NOT registered in -- homingFwdByTarget (HOMING_SKIP_INCOMPLETE early-returns) so they don't curve -- toward a moving factory exit. We still track them here in a fade-only list --- (same {id, death} layout) for the per-particle death fade if that unit dies --- or is cancelled mid-build. Cleared on UnitFinished and UnitDestroyed (both --- also fade so trailing spray dissolves cleanly). +-- (same record layout) for the per-particle death fade if that unit dies +-- or is cancelled mid-build. Cleared on UnitFinished and UnitDestroyed. +-- UnitDestroyed always fades them; UnitFinished fades them for a mobile unit +-- (it is rolling off a factory pad, so the spray has nothing left to land +-- on) and lets them land on a finished structure. local fadeFwdByTarget = {} local FADE_FWD_MAX_PER_TARGET = HOMING_FWD_MAX_PER_TARGET @@ -1692,6 +1937,54 @@ function U.recycleTrackList(list) end end +-- Tracking records describe one emitNano batch: particle IDs [f, f + n) were +-- handed out consecutively, share a nano piece (pi), clamp flag (gc), light +-- flag (lc) and jitter basis (jc/js), and the last of them dies at frame d. +-- Homing and fade passes walk the ID range and read per-particle state +-- straight from the VBO mirror, so emission never allocates or fills a +-- per-particle Lua table. Records are pooled in U._trackEntryFree; emitNano +-- files them inline and only calls this when a list has hit its cap. +-- +-- Append a record to a tracking list, keeping the list under `cap` records: +-- expired records are compacted away first, and only if the list is still +-- full do the oldest records stop being tracked. +local function appendTrackRecord(list, rec, cap, frame) + local n = #list + if n >= cap then + local free = U._trackEntryFree + local w = 0 + for i = 1, n do + local r = list[i] + if frame >= r.d then + free[#free + 1] = r + else + w = w + 1 + list[w] = r + end + end + for i = n, w + 1, -1 do + list[i] = nil + end + n = w + if n >= cap then + -- Still full of live records: retire the oldest half in one pass, + -- so a busy target costs O(1) amortized per append instead of O(cap). + local drop = mathFloor(n / 2) + for i = 1, drop do + free[#free + 1] = list[i] + end + for i = 1, n - drop do + list[i] = list[i + drop] + end + for i = n - drop + 1, n do + list[i] = nil + end + n = n - drop + end + end + list[n + 1] = rec +end + local function getBuilderInfo(builderID) local cached = builderCache[builderID] if cached then @@ -1794,86 +2087,23 @@ end -- Emission -------------------------------------------------------------------------------- -local function spawnParticle(px, py, pz, vx, vy, vz, lifetime, r, g, b, frame, fadeFrames, inverse) - if not nanoVBO then - return - end - - local death = frame + lifetime - - local rotVal = U.ROT_VAL_BASE + U.ROT_VAL_RANGE * (mathRandom() * 2 - 1) - local rotVel = U.ROT_VEL_BASE + U.ROT_VEL_RANGE * (mathRandom() * 2 - 1) - - -- Per-particle size and alpha jitter. Size is packed into w alongside - -- fadeFrames; alpha replaces the flat NANO_ALPHA in the color attribute. - local sizeVar = U.SIZE_VAR - local nanoAlpha = U.NANO_ALPHA - local alphaVar = U.ALPHA_VAR - local sizeMult = (sizeVar > 0) and (1.0 + sizeVar * (mathRandom() * 2 - 1)) or 1.0 - local alpha = (alphaVar > 0) and (nanoAlpha * (1.0 + alphaVar * (mathRandom() * 2 - 1))) or nanoAlpha - if alpha < 0 then - alpha = 0 - end - - local id = nextID - nextID = nextID + 1 - - local s = instanceScratch - s[1] = px - s[2] = py - s[3] = pz - s[4] = packSizeFade(sizeMult, fadeFrames, inverse) - s[5] = vx - s[6] = vy - s[7] = vz - s[8] = frame - s[9] = r - s[10] = g - s[11] = b - s[12] = alpha - s[13] = rotVal - s[14] = rotVel - s[15] = frame - s[16] = death - - if pushElementInstance(nanoVBO, s, id, false, true, nil) then - local bucket = deathBuckets[death] - if bucket then - bucket[#bucket + 1] = id - else - bucket = U.acquireDeathBucket() - bucket[1] = id - deathBuckets[death] = bucket - end - local oldest = deathBuckets.__oldestFrame - if not oldest or death < oldest then - deathBuckets.__oldestFrame = death - end - local latest = deathBuckets.__latestFrame - if not latest or death > latest then - deathBuckets.__latestFrame = death - end - U.expandParticleBounds(death, px, py, pz, px + vx * lifetime, py + vy * lifetime, pz + vz * lifetime) - liveCount = liveCount + 1 - return id - end -end --- Multi-emit form: count >= 1 spawns that many particles in a single call, --- amortising piece-pos lookup, sqrt, range gate, normalize, jitter scale, LOS --- gate selection and colour/fade lookup across the batch. Per-particle work --- is only jitter rejection sampling, optional speed variance, spawnParticle --- and homing register. Used by scanBuilders' single-piece path and by the --- multi-piece round-robin (one batched call per piece). +-- Multi-emit form: count >= 1 spawns that many particles in a single call. +-- Everything constant across the batch -- piece position, range gate, +-- direction, jitter scale, colour, fade window, LOS decision, forward-homing +-- classification -- is resolved once; the per-particle body is a few table +-- reads, some multiplies and sixteen VBO-mirror writes. Bookkeeping that used +-- to be per particle (pool bounds, oldest/latest death frame, homing / fade / +-- clamp tracking) happens once per batch: particle IDs are consecutive, so a +-- single {firstID, count, ...} record addresses every particle. Used by +-- scanBuilders' single-piece path, the multi-piece round-robin (one batched +-- call per piece), virtual-stream catch-up and the reclaim burst. -- --- spreadFrames: when > 0, stagger the batch's spawn times across this many --- frames so a high-stride visit doesn't dump a clumped blob at the --- nanopiece. Each particle is advanced along its velocity by a per-particle --- time offset, simulating continuous emission since the last visit. The --- caller bounds this to ~one steady-state visit interval (stride * runEvery) --- so particles only get nudged a small distance ahead of the source -- they --- must NOT be placed partway to the target. Defaults to 0 (legacy --- simultaneous spawn) when omitted. +-- spreadFrames: number of sim frames this batch accounts for (the scan's +-- stride compensation). Particles get spawn frames spread over +-- [frame, frame + spreadFrames) and the shader hides each until its own +-- spawn frame, so the batch streams out of the nozzle one particle per frame +-- instead of appearing as a clump. 0 or 1 spawns everything at `frame`. local function emitNano( builderID, info, @@ -1889,6 +2119,10 @@ local function emitNano( spreadFrames, catchupAgeFrames ) + local vbo = nanoVBO + if not vbo then + return + end count = count or 1 spreadFrames = spreadFrames or 0 catchupAgeFrames = catchupAgeFrames or 0 @@ -1897,76 +2131,128 @@ local function emitNano( local clampGuideY local clampPeakT - -- Spring.GetUnitPiecePosDir is the hot Spring->C call here. Cached by - -- (unitID, pieceIdx) for the duration of one scan frame, invalidated by - -- per-frame epoch bump. Same builder/piece often emits multiple particles - -- per frame (resurrect dual-emit, multi-piece cycling). - local key = builderID * 256 + pieceIdx - local entry = piecePosCache[key] - local sx, sy, sz - if entry and entry[1] == piecePosEpoch then - sx, sy, sz = entry[2], entry[3], entry[4] + local sx, sy, sz, len, invLen, ndx, ndy, ndz, jitterScale, fadeBandKeep + -- Emitter geometry cache. A static single-nozzle builder (nano turret) + -- working a static endpoint produces the same piece position, clamp gate, + -- length, direction, jitter scale and range gate on every visit, so they + -- are kept on `info` and only refreshed every EMIT_CTX_FRAMES -- or every + -- 2 frames while the head may still be turning toward a fresh target. + local ctxUntil = info.ctxUntil + if + ctxUntil + and frame < ctxUntil + and info.ctxPiece == pieceIdx + and info.ctxEndX == endX + and info.ctxEndY == endY + and info.ctxEndZ == endZ + then + sx, sy, sz = info.ctxSx, info.ctxSy, info.ctxSz + clampThisEmit, clampGuideY, clampPeakT = info.ctxClamp, info.ctxGuideY, info.ctxPeakT + endY = info.ctxEndYc + len, invLen = info.ctxLen, info.ctxInvLen + ndx, ndy, ndz = info.ctxNdx, info.ctxNdy, info.ctxNdz + jitterScale = info.ctxJitterScale + fadeBandKeep = info.ctxFadeBand else - sx, sy, sz = spGetUnitPiecePosDir(builderID, pieceIdx) - if not sx then - return - end - if entry then - entry[1] = piecePosEpoch - entry[2] = sx - entry[3] = sy - entry[4] = sz + local rawEndY = endY + -- Spring.GetUnitPiecePosDir is the hot Spring->C call here. Cached by + -- (unitID, pieceIdx) for the duration of one scan frame, invalidated by + -- per-frame epoch bump. Same builder/piece often emits multiple particles + -- per frame (resurrect dual-emit, multi-piece cycling). + local key = builderID * 256 + pieceIdx + local entry = piecePosCache[key] + if entry and entry[1] == piecePosEpoch then + sx, sy, sz = entry[2], entry[3], entry[4] else - piecePosCache[key] = { piecePosEpoch, sx, sy, sz } + sx, sy, sz = spGetUnitPiecePosDir(builderID, pieceIdx) + if not sx then + return + end + if entry then + entry[1] = piecePosEpoch + entry[2] = sx + entry[3] = sy + entry[4] = sz + else + piecePosCache[key] = { piecePosEpoch, sx, sy, sz } + end end - end - if U.GROUND_CLAMP_ENABLED and not info.isFactory then - clampThisEmit, clampGuideY, clampPeakT = shouldClampEmit(builderID, sx, sy, sz, endX, endY, endZ, frame) - if clampThisEmit then - endY = clampYAboveGround(endX, endY, endZ, frame) + if U.GROUND_CLAMP_ENABLED and not info.isFactory then + clampThisEmit, clampGuideY, clampPeakT = + shouldClampEmit(builderID, sx, sy, sz, endX, endY, endZ, frame) + if clampThisEmit then + endY = clampYAboveGround(endX, endY, endZ, frame) + end end - end - local dx, dy, dz = endX - sx, endY - sy, endZ - sz - local lenSq = dx * dx + dy * dy + dz * dz - if lenSq < 1.0 then - return - end - local len = mathSqrt(lenSq) - - -- Range gate for moving-unit targets. Engine reach is (buildDistance + - -- target radius); buildDistance alone under-counts heavily for large - -- buildees. effectiveBD precomputed on meta at resolveTarget time. Hard - -- cull is one compare; fade-band keep is rolled per-particle inside the loop. - -- IMPORTANT: engine buildDistance is a horizontal cylinder (XZ only), not a - -- sphere. A builder on a cliff has extra vertical distance but the same XZ - -- reach, so we must compare effectiveBD against horizontal distance only. - local fadeBandKeep - if targetUnitID then - local effectiveBD = info.targetMeta and info.targetMeta.effectiveBD - if effectiveBD then - local horzLen = mathSqrt(dx * dx + dz * dz) - local maxLen = effectiveBD * BUILD_RANGE_MAX_EXTENSION - if horzLen > maxLen then - return - end - if horzLen > effectiveBD then - fadeBandKeep = (maxLen - horzLen) / (effectiveBD * (BUILD_RANGE_MAX_EXTENSION - 1.0)) + local dx, dy, dz = endX - sx, endY - sy, endZ - sz + local lenSq = dx * dx + dy * dy + dz * dz + if lenSq < 1.0 then + return + end + len = mathSqrt(lenSq) + + -- Range gate for moving-unit targets. Engine reach is (buildDistance + + -- target radius); buildDistance alone under-counts heavily for large + -- buildees. effectiveBD precomputed on meta at resolveTarget time. Hard + -- cull is one compare; fade-band keep is rolled per-particle inside the loop. + -- IMPORTANT: engine buildDistance is a horizontal cylinder (XZ only), not a + -- sphere. A builder on a cliff has extra vertical distance but the same XZ + -- reach, so we must compare effectiveBD against horizontal distance only. + if targetUnitID then + local effectiveBD = info.targetMeta and info.targetMeta.effectiveBD + if effectiveBD then + local horzLen = mathSqrt(dx * dx + dz * dz) + local maxLen = effectiveBD * BUILD_RANGE_MAX_EXTENSION + if horzLen > maxLen then + return + end + if horzLen > effectiveBD then + fadeBandKeep = (maxLen - horzLen) / (effectiveBD * (BUILD_RANGE_MAX_EXTENSION - 1.0)) + end end end - end - - local invLen = 1.0 / len - local ndx, ndy, ndz = dx * invLen, dy * invLen, dz * invLen - -- Engine: dif += guRNG.NextVector() * jitterScale, where NextVector() is - -- rejection-sampled inside the unit sphere (GlobalRNG.h). Builders pass a - -- per-task radius (jitterScale = radius/len); factories use a fixed 0.15. - local jitterScale = jitterRadius and (jitterRadius * invLen) or U.DIR_JITTER + invLen = 1.0 / len + ndx, ndy, ndz = dx * invLen, dy * invLen, dz * invLen + + -- Engine: dif += guRNG.NextVector() * jitterScale, where NextVector() is + -- rejection-sampled inside the unit sphere (GlobalRNG.h). Builders pass a + -- per-task radius (jitterScale = radius/len); factories use a fixed 0.15. + jitterScale = jitterRadius and (jitterRadius * invLen) or U.DIR_JITTER + + if (not info.isMobile) and (not info.isFactory) and info.nPieces == 1 then + local meta = info.targetMeta + local since = meta and meta.since + local settling = (not since) or (frame - since) < EMIT_CTX_SETTLE_FRAMES + info.ctxUntil = frame + (settling and 2 or EMIT_CTX_FRAMES) + info.ctxPiece = pieceIdx + info.ctxEndX = endX + info.ctxEndY = rawEndY + info.ctxEndZ = endZ + info.ctxEndYc = endY + info.ctxSx = sx + info.ctxSy = sy + info.ctxSz = sz + info.ctxClamp = clampThisEmit + info.ctxGuideY = clampGuideY + info.ctxPeakT = clampPeakT + info.ctxLen = len + info.ctxInvLen = invLen + info.ctxNdx = ndx + info.ctxNdy = ndy + info.ctxNdz = ndz + info.ctxJitterScale = jitterScale + info.ctxFadeBand = fadeBandKeep + end + end local r, g, b = info.r, info.g, info.b - local fadeFrames = inverse and FADE_FRAMES_RECLAIM or FADE_FRAMES_REPAIR + -- spawnPosAndSize.w packs sizeMult (low 1024) + fadeFrames * 1024 and is + -- negated for inverse (reclaim) particles; fadeTrackedRecords has the + -- matching unpack. The fade part is constant across the batch. + local fadePacked = (inverse and FADE_FRAMES_RECLAIM or FADE_FRAMES_REPAIR) * 1024 local infoIsMobile = info.isMobile local speedVar = U.SPEED_VAR local useWaypoint = clampThisEmit and U.GROUND_CLAMP_USE_WAYPOINT and clampGuideY and not inverse @@ -1982,7 +2268,6 @@ local function emitNano( fwdMaxRangeSq = maxRange * maxRange end end - local trackEntryFree = U._trackEntryFree -- LOS check needed at all? Per-particle result is still cached on info -- because position varies for inverse emissions, but most visits skip the @@ -2015,39 +2300,116 @@ local function emitNano( end end - -- Stagger denominator: divide the symmetric spread window - -- [-spreadFrames, +spreadFrames] across `count` slots so particles are - -- evenly spaced in time (with sub-slot RNG jitter to avoid visible banding - -- when count is small). spreadInv == 0 disables staggering. - local spreadInv = (spreadFrames > 0 and count > 0) and (2 * spreadFrames / count) or 0 - local spreadBase = -spreadFrames + -- Forward-homing classification for the whole batch (the answer depends + -- only on the target): 0 = no tracking, 1 = fade-only tracking because the + -- target is still under construction (HOMING_SKIP_INCOMPLETE), 2 = full + -- forward homing. + local fwdMode = 0 + if (not inverse) and targetUnitID then + if HOMING_SKIP_INCOMPLETE then + local ient = targetIncompleteCache[targetUnitID] + local beingBuilt + -- A cached `true` is trusted until UnitFinished / UnitDestroyed + -- rewrite or drop the entry: an unfinished unit cannot become + -- finished without one of those callins, so no re-query per frame. + if ient and (ient[1] == piecePosEpoch or ient[2] == true) then + beingBuilt = ient[2] + else + beingBuilt = spGetUnitIsBeingBuilt(targetUnitID) and true or false + if ient then + ient[1] = piecePosEpoch + ient[2] = beingBuilt + if beingBuilt then + ient[3] = frame + end + else + ient = { piecePosEpoch, beingBuilt, beingBuilt and frame or -1 } + targetIncompleteCache[targetUnitID] = ient + end + end + if beingBuilt then + -- Track for death-fade: if the unfinished target dies or the + -- build is cancelled, UnitDestroyed fades these so the trailing + -- spray dissolves instead of popping. + fwdMode = 1 + else + -- Grace window: still skip for a short time after completion so + -- the last particles don't chase the unit out. + local lastIncompleteFrame = ient[3] or -1 + if lastIncompleteFrame >= 0 and (frame - lastIncompleteFrame) < HOMING_SKIP_GRACE_FRAMES then + fwdMode = 0 + elseif U.NANO_PARTICLES_HOMING then + fwdMode = 2 + end + end + elseif U.NANO_PARTICLES_HOMING then + fwdMode = 2 + end + end + + -- Spawn-frame stagger: the batch's particles get spawn frames spread over + -- [frame, frame + spreadFrames) -- `count` evenly spaced slots with + -- sub-slot RNG jitter -- and the shader hides each until its own spawn + -- frame, so a visit that accounts for several frames streams out one + -- particle per frame instead of a clump. spreadInv == 0 disables it. + local spreadInv = (spreadFrames > 1 and count > 0) and (spreadFrames / count) or 0 local jitterTable = U._jitterTable local jitterCursor = U._jitterCursor local jitterTableLast = U._jitterTableLast + local randTable = U._randTable + local randLast = U._RAND_TABLE_SIZE - 8 + local rc = mathFloor(mathRandom() * randLast) + 1 + local rotValBase, rotValRange = U.ROT_VAL_BASE, U.ROT_VAL_RANGE + local rotVelBase, rotVelRange = U.ROT_VEL_BASE, U.ROT_VEL_RANGE + local sizeVar, nanoAlpha, alphaVar = U.SIZE_VAR, U.NANO_ALPHA, U.ALPHA_VAR + + -- Direct VBO-mirror writes. Particle IDs are handed out consecutively, so + -- the batch is fully described by (firstID, spawned) for the tracking + -- records filed below. Slots only ever append here; cull swaps the tail + -- into freed slots, which is why lookups always go through idToIndex. + local data = vbo.instanceData + local idToIndex = vbo.instanceIDtoIndex + local indexToID = vbo.indextoInstanceID + local step = vbo.instanceStep + local used = vbo.usedElements + local maxUsed = vbo.maxElements - 1 + local id = nextID + local firstID = id + local firstJitter = jitterCursor + local spawned = 0 + local minDeath, maxDeath = 1e18, -1 + local bMinX, bMinY, bMinZ = 1e18, 1e18, 1e18 + local bMaxX, bMaxY, bMaxZ = -1e18, -1e18, -1e18 + local wpLatest + local lightBatch, lightN, lightCount + if lightBridge then + lightBatch = U._lightBatch + lightN = U._lightBatchN + lightCount = U._lightBatchCount + if nl.spawnFrame ~= frame then + nl.spawnFrame = frame + nl.spawnCount = 0 + end + end -- `repeat ... until true` with `break` is Lua 5.1's idiom for `continue`: - -- skip individual particles (fade-band drop, LOS hidden, lifetime - -- underflow, HOMING_SKIP_INCOMPLETE branches) without aborting the batch. + -- skip individual particles (fade-band drop, lifetime underflow, LOS + -- hidden) without aborting the batch. Everything that can skip a particle + -- runs BEFORE the jitter cursor advances, so the k-th spawned particle of + -- the batch always used jitter entry firstJitter + 3k: forward homing + -- rebuilds each particle's landing offset from that instead of storing it. for i = 1, count do repeat if fadeBandKeep and mathRandom() > fadeBandKeep then break end - - local jx = jitterTable[jitterCursor] - local jy = jitterTable[jitterCursor + 1] - local jz = jitterTable[jitterCursor + 2] - jitterCursor = jitterCursor + 3 - if jitterCursor > jitterTableLast then - jitterCursor = 1 + if rc > randLast then + rc = 1 end - local fdx = ndx + jx * jitterScale - local fdy = ndy + jy * jitterScale - local fdz = ndz + jz * jitterScale local speed = NANO_SPEED if speedVar > 0 then - speed = NANO_SPEED * (1.0 + speedVar * (mathRandom() * 2 - 1)) + speed = NANO_SPEED * (1.0 + speedVar * (randTable[rc] * 2 - 1)) if speed < 0.1 then speed = 0.1 end @@ -2056,6 +2418,33 @@ local function emitNano( if lifetime < 1 then break end + + -- This particle's own spawn frame within the batch window. + local spawnF = frame + if spreadInv > 0 then + spawnF = frame + mathFloor(((i - 1) + randTable[rc + 1]) * spreadInv) + end + local shift = 0 + + -- Cold-offscreen catch-up: materialize a virtual particle already + -- partway along its path when the camera first sees this endpoint. + if catchupAgeFrames > 0 and lifetime > 1 then + local age = mathFloor(randTable[rc + 2] * catchupAgeFrames) + if age >= lifetime then + age = lifetime - 1 + end + if age > 0 then + shift = shift + age + lifetime = lifetime - age + end + end + + local jx = jitterTable[jitterCursor] + local jy = jitterTable[jitterCursor + 1] + local jz = jitterTable[jitterCursor + 2] + local fdx = ndx + jx * jitterScale + local fdy = ndy + jy * jitterScale + local fdz = ndz + jz * jitterScale local vx, vy, vz = fdx * speed, fdy * speed, fdz * speed -- Engine (ProjectileHandler::AddNanoParticle, inverse branch): @@ -2070,45 +2459,16 @@ local function emitNano( py = sy + fdy * len pz = sz + fdz * len vx, vy, vz = -vx, -vy, -vz + if clampThisEmit then + py = clampYAboveGround(px, py, pz, frame) + end else px, py, pz = sx, sy, sz end - if clampThisEmit and inverse then - py = clampYAboveGround(px, py, pz, frame) - end - - -- Stagger this particle's spawn along its velocity by `tOff` frames, - -- where tOff is in [-spreadFrames, +spreadFrames]. Negative offsets - -- place the particle slightly behind the nanopiece (partially - -- occluded by the builder model); positive offsets place it - -- slightly ahead. Lifetime is adjusted so total travel time to the - -- target remains constant. If the bounded window would somehow - -- exhaust the lifetime (very-close target), skip the particle. - if spreadInv > 0 then - local tOff = spreadBase + ((i - 1) + mathRandom()) * spreadInv - local newLifetime = lifetime - mathFloor(tOff) - if newLifetime < 1 then - break - end - px = px + vx * tOff - py = py + vy * tOff - pz = pz + vz * tOff - lifetime = newLifetime - end - - -- Cold-offscreen catch-up: materialize a virtual particle already - -- partway along its path when the camera first sees this endpoint. - if catchupAgeFrames > 0 and lifetime > 1 then - local age = mathFloor(mathRandom() * catchupAgeFrames) - if age >= lifetime then - age = lifetime - 1 - end - if age > 0 then - px = px + vx * age - py = py + vy * age - pz = pz + vz * age - lifetime = lifetime - age - end + if shift ~= 0 then + px = px + vx * shift + py = py + vy * shift + pz = pz + vz * shift end -- LOS filter: enemy emissions hidden when not in regular LOS / not @@ -2128,12 +2488,21 @@ local function emitNano( break end end + if used >= maxUsed then + -- VBO physically full; only reachable when the soft cap equals + -- the VBO size. The saturation gate handles the rest. + break + end + jitterCursor = jitterCursor + 3 + if jitterCursor > jitterTableLast then + jitterCursor = 1 + end - local wpFrame, finalX, finalY, finalZ + local wpFrame if useWaypoint then - finalX = px + vx * lifetime - finalY = py + vy * lifetime - finalZ = pz + vz * lifetime + local finalX = px + vx * lifetime + local finalY = py + vy * lifetime + local finalZ = pz + vz * lifetime local peak = clampPeakT or 0.5 if peak < 0.15 then peak = 0.15 @@ -2155,254 +2524,300 @@ local function emitNano( vx = (wpX - px) * invLeg1 vy = (wpY - py) * invLeg1 vz = (wpZ - pz) * invLeg1 - wpFrame = frame + leg1 + wpFrame = spawnF + leg1 + if not wpLatest or wpFrame > wpLatest then + wpLatest = wpFrame + end end end - local pid = spawnParticle(px, py, pz, vx, vy, vz, lifetime, r, g, b, frame, fadeFrames, inverse) - if pid and clampThisEmit then - if useWaypoint then - if wpFrame then - registerGroundClampParticle( - pid, - frame + lifetime, - wpFrame, - finalX, - finalY, - finalZ, - targetUnitID - ) - end - else - registerGroundClampParticle(pid, frame + lifetime) - end + local death = spawnF + lifetime + local rotVal = rotValBase + rotValRange * (randTable[rc + 3] * 2 - 1) + local rotVel = rotVelBase + rotVelRange * (randTable[rc + 4] * 2 - 1) + local sizeMult = 1.0 + if sizeVar > 0 then + sizeMult = 1.0 + sizeVar * (randTable[rc + 5] * 2 - 1) end - if pid then - if lightBridge then - if nl.spawnFrame ~= frame then - nl.spawnFrame = frame - nl.spawnCount = 0 - end - local spawnCount = nl.spawnCount or 0 - if spawnCount < (nl.maxSpawnsPerFrame or 48) and nl.activeCount < (nl.maxActive or 2048) then - local sampleAccum = (nl.sampleAccum or 1.0) + (nl.sampleRate or 0.25) - if sampleAccum >= 1.0 then - nl.sampleAccum = sampleAccum - 1.0 - nl.spawnCount = spawnCount + 1 - local lightLifetime = mathFloor(lifetime * (nl.lifeMult or 2.2) + 0.5) - local minLifetime = nl.minLifetime or 14 - local maxLifetime = nl.maxLifetime or 96 - if lightLifetime < minLifetime then - lightLifetime = minLifetime - end - if lightLifetime > maxLifetime then - lightLifetime = maxLifetime - end - if lightLifetime > 1 then - local sustain = mathFloor(lightLifetime * (nl.sustainFrac or 0.7) + 0.5) - if sustain < 1 then - sustain = 1 - end - if sustain > lightLifetime then - sustain = lightLifetime - end - local lightID = "NANOP_" .. pid - Script.LuaUI.EnvNanoBallisticLightSpawn( - lightID, - px, - py, - pz, - vx, - vy, - vz, - nl.spawnRadius or 25, - info.lr or r, - info.lg or g, - info.lb or b, - nl.alpha, - lightLifetime, - sustain, - 0.35, - 0.15, - 0.20, - 0.0, - frame - ) - nl.active[pid] = frame - nl.ids[pid] = lightID - nl.activeCount = nl.activeCount + 1 - end - else - nl.sampleAccum = sampleAccum - end - end + local alpha = nanoAlpha + if alphaVar > 0 then + alpha = nanoAlpha * (1.0 + alphaVar * (randTable[rc + 6] * 2 - 1)) + if alpha < 0 then + alpha = 0 end end + rc = rc + 7 + local packed = mathFloor(sizeMult * 256 + 0.5) + fadePacked + if inverse then + packed = -packed + end - -- Inverse particles converge on the builder. If the builder moves - -- before the particle dies, the original straight-line trajectory - -- ends at a stale location. Track so applyHoming() can re-aim. - if inverse and pid and U.NANO_PARTICLES_HOMING then - local list = homingByBuilder[builderID] - if not list then - list = {} - homingByBuilder[builderID] = list - end - local viewRadius = len * (1 + jitterScale) + MAX_SPREAD_AHEAD_ELMOS - if not list._viewRadius or viewRadius > list._viewRadius then - list._viewRadius = viewRadius - end - local deathFrame = frame + lifetime - if not list._latestDeath or deathFrame > list._latestDeath then - list._latestDeath = deathFrame - end - local nL = #list - local p - if nL >= HOMING_MAX_PER_BUILDER then - p = list[1] - for i = 1, nL - 1 do - list[i] = list[i + 1] - end - list[nL] = p - else - local nFree = #trackEntryFree - p = trackEntryFree[nFree] - if p then - trackEntryFree[nFree] = nil - else - p = {} - end - list[nL + 1] = p - end - p.id = pid - p.pieceIdx = pieceIdx - p.death = frame + lifetime - p.gc = clampThisEmit - p.lc = infoIsMobile - elseif (not inverse) and pid and targetUnitID then - -- Forward emission aimed at a moving unit (repair/capture). - -- Track so applyForwardHoming() can curve the particle toward - -- the target's new position when it moves. - if HOMING_SKIP_INCOMPLETE then - local ient = targetIncompleteCache[targetUnitID] - local beingBuilt - if ient and ient[1] == piecePosEpoch then - beingBuilt = ient[2] - else - beingBuilt = spGetUnitIsBeingBuilt(targetUnitID) and true or false - if ient then - ient[1] = piecePosEpoch - ient[2] = beingBuilt - if beingBuilt then - ient[3] = frame - end - else - targetIncompleteCache[targetUnitID] = - { piecePosEpoch, beingBuilt, beingBuilt and frame or -1 } + local base = used * step + used = used + 1 + data[base + 1] = px + data[base + 2] = py + data[base + 3] = pz + data[base + 4] = packed + data[base + 5] = vx + data[base + 6] = vy + data[base + 7] = vz + data[base + 8] = spawnF + data[base + 9] = r + data[base + 10] = g + data[base + 11] = b + data[base + 12] = alpha + data[base + 13] = rotVal + data[base + 14] = rotVel + data[base + 15] = spawnF + data[base + 16] = death + idToIndex[id] = used + indexToID[used] = id + + local bucket = deathBuckets[death] + if bucket then + bucket[#bucket + 1] = id + else + bucket = U.acquireDeathBucket() + bucket[1] = id + deathBuckets[death] = bucket + end + if death < minDeath then + minDeath = death + end + if death > maxDeath then + maxDeath = death + end + + -- Batch trajectory bounds: spawn point and straight-line landing point. + local lx, ly, lz = px + vx * lifetime, py + vy * lifetime, pz + vz * lifetime + if px < bMinX then + bMinX = px + end + if px > bMaxX then + bMaxX = px + end + if lx < bMinX then + bMinX = lx + end + if lx > bMaxX then + bMaxX = lx + end + if py < bMinY then + bMinY = py + end + if py > bMaxY then + bMaxY = py + end + if ly < bMinY then + bMinY = ly + end + if ly > bMaxY then + bMaxY = ly + end + if pz < bMinZ then + bMinZ = pz + end + if pz > bMaxZ then + bMaxZ = pz + end + if lz < bMinZ then + bMinZ = lz + end + if lz > bMaxZ then + bMaxZ = lz + end + + if lightBridge then + local spawnCount = nl.spawnCount or 0 + if spawnCount < (nl.maxSpawnsPerFrame or 48) and nl.activeCount < (nl.maxActive or 2048) then + local sampleAccum = (nl.sampleAccum or 1.0) + (nl.sampleRate or 0.25) + if sampleAccum >= 1.0 then + nl.sampleAccum = sampleAccum - 1.0 + nl.spawnCount = spawnCount + 1 + local lightLifetime = mathFloor(lifetime * (nl.lifeMult or 2.2) + 0.5) + local minLifetime = nl.minLifetime or 14 + local maxLifetime = nl.maxLifetime or 96 + if lightLifetime < minLifetime then + lightLifetime = minLifetime end - end - if beingBuilt then - -- Track for death-fade: if the unfinished target dies or - -- the build is cancelled, UnitDestroyed fades these so the - -- trailing spray dissolves instead of popping. - local flist = fadeFwdByTarget[targetUnitID] - if not flist then - flist = {} - fadeFwdByTarget[targetUnitID] = flist + if lightLifetime > maxLifetime then + lightLifetime = maxLifetime end - local fn = #flist - local p - if fn >= FADE_FWD_MAX_PER_TARGET then - p = flist[1] - for i = 1, fn - 1 do - flist[i] = flist[i + 1] + if lightLifetime > 1 then + local sustain = mathFloor(lightLifetime * (nl.sustainFrac or 0.7) + 0.5) + if sustain < 1 then + sustain = 1 end - flist[fn] = p - else - local nFree = #trackEntryFree - p = trackEntryFree[nFree] - if p then - trackEntryFree[nFree] = nil - else - p = {} + if sustain > lightLifetime then + sustain = lightLifetime end - flist[fn + 1] = p + local lightID = "NANOP_" .. id + -- Same argument order as EnvNanoBallisticLightSpawn; + -- U.flushLightBatch hands the whole frame over at once. + lightBatch[lightN + 1] = lightID + lightBatch[lightN + 2] = px + lightBatch[lightN + 3] = py + lightBatch[lightN + 4] = pz + lightBatch[lightN + 5] = vx + lightBatch[lightN + 6] = vy + lightBatch[lightN + 7] = vz + lightBatch[lightN + 8] = nl.spawnRadius or 25 + lightBatch[lightN + 9] = info.lr or r + lightBatch[lightN + 10] = info.lg or g + lightBatch[lightN + 11] = info.lb or b + lightBatch[lightN + 12] = nl.alpha + lightBatch[lightN + 13] = lightLifetime + lightBatch[lightN + 14] = sustain + lightBatch[lightN + 15] = 0.35 + lightBatch[lightN + 16] = 0.15 + lightBatch[lightN + 17] = 0.20 + lightBatch[lightN + 18] = 0.0 + lightBatch[lightN + 19] = spawnF + lightN = lightN + 19 + lightCount = lightCount + 1 + nl.active[id] = frame + nl.ids[id] = lightID + nl.activeCount = nl.activeCount + 1 end - p.id = pid - p.death = frame + lifetime - p.gc = clampThisEmit - break - end - -- Grace window: still skip for a short time after - -- completion so the last particles don't chase the unit out. - local lastIncompleteFrame = ient and ient[3] or -1 - if lastIncompleteFrame >= 0 and (frame - lastIncompleteFrame) < HOMING_SKIP_GRACE_FRAMES then - break - end - end - if not U.NANO_PARTICLES_HOMING then - break - end - -- Per-particle landing offset (jitter encodes a unique end-point) - -- so spray spread is preserved at the destination as the target moves. - local landingX = sx + fdx * len - local landingY = sy + fdy * len - local landingZ = sz + fdz * len - local offX = landingX - endX - local offY = landingY - endY - local offZ = landingZ - endZ - local list = homingFwdByTarget[targetUnitID] - if not list then - list = {} - homingFwdByTarget[targetUnitID] = list - end - local viewRadius = len * (1 + jitterScale) + MAX_SPREAD_AHEAD_ELMOS - if not list._viewRadius or viewRadius > list._viewRadius then - list._viewRadius = viewRadius - end - local deathFrame = frame + lifetime - if not list._latestDeath or deathFrame > list._latestDeath then - list._latestDeath = deathFrame - end - local nL = #list - local p - if nL >= HOMING_FWD_MAX_PER_TARGET then - p = list[1] - for i = 1, nL - 1 do - list[i] = list[i + 1] - end - list[nL] = p - else - local nFree = #trackEntryFree - p = trackEntryFree[nFree] - if p then - trackEntryFree[nFree] = nil else - p = {} + nl.sampleAccum = sampleAccum end - list[nL + 1] = p end - p.id = pid - p.death = frame + lifetime - p.ox = offX - p.oy = offY - p.oz = offZ - p.gc = clampThisEmit - p.builderID = builderID - p.pieceIdx = pieceIdx - p.maxRangeSq = fwdMaxRangeSq - p.lc = targetIsMobileUnit end + + id = id + 1 + spawned = spawned + 1 until true + if used >= maxUsed then + break + end end U._jitterCursor = jitterCursor -end + if lightBridge then + U._lightBatchN = lightN + U._lightBatchCount = lightCount + end + if spawned == 0 then + return + end -local function emitNanoBatch( - unitID, - info, - ex, - ey, + nextID = id + vbo.usedElements = used + liveCount = liveCount + spawned + local oldest = deathBuckets.__oldestFrame + if not oldest or minDeath < oldest then + deathBuckets.__oldestFrame = minDeath + end + local latest = deathBuckets.__latestFrame + if not latest or maxDeath > latest then + deathBuckets.__latestFrame = maxDeath + end + -- One bounds expansion per batch, filed under the batch's last death frame + -- so it stays in the culling union for as long as any of its particles live. + U.expandParticleBounds(maxDeath, bMinX, bMinY, bMinZ, bMaxX, bMaxY, bMaxZ) + + -- The waypoint redirect follows the target's current position only for a + -- forward-homing batch (fwdMode 2), the one case where the particles are + -- meant to curve after a moving unit. Build spray at a factory pad (fwdMode + -- 1) and the post-completion grace batches (fwdMode 0) keep their spawn-time + -- endpoint, so a unit rolling off the pad is not chased by particles whose + -- waypoint leg ends after it left. + if clampThisEmit and (wpLatest or not useWaypoint) then + registerGroundClampRecord( + firstID, + spawned, + maxDeath, + wpLatest, + endX, + endY, + endZ, + (fwdMode == 2) and targetUnitID or nil, + firstJitter, + jitterScale * len + ) + end + + -- Tracking record for the batch (see appendTrackRecord for the layout): + -- inverse: homingByBuilder -- particles converge on the builder; if it + -- moves before they die, applyHoming() re-aims them. + -- fwdMode 1: fadeFwdByTarget -- fade-only tracking for an unfinished target. + -- fwdMode 2: homingFwdByTarget -- forward emission at a moving unit + -- (repair/capture); applyForwardHoming() curves the particles + -- after it. Per-particle landing offsets are rebuilt from the + -- jitter table (entry jc + 3k for the k-th particle, scaled + -- by js), which preserves the spray spread as the target moves. + local list, cap + if inverse then + if U.NANO_PARTICLES_HOMING then + list = homingByBuilder[builderID] + if not list then + list = {} + homingByBuilder[builderID] = list + end + cap = HOMING_MAX_PER_BUILDER + end + elseif fwdMode == 1 then + list = fadeFwdByTarget[targetUnitID] + if not list then + list = {} + fadeFwdByTarget[targetUnitID] = list + end + cap = FADE_FWD_MAX_PER_TARGET + elseif fwdMode == 2 then + list = homingFwdByTarget[targetUnitID] + if not list then + list = {} + homingFwdByTarget[targetUnitID] = list + end + cap = HOMING_FWD_MAX_PER_TARGET + end + if list then + if fwdMode ~= 1 then + local viewRadius = len * (1 + jitterScale) + MAX_SPREAD_AHEAD_ELMOS + if not list._viewRadius or viewRadius > list._viewRadius then + list._viewRadius = viewRadius + end + if not list._latestDeath or maxDeath > list._latestDeath then + list._latestDeath = maxDeath + end + end + local free = U._trackEntryFree + local nFree = #free + local rec = free[nFree] + if rec then + free[nFree] = nil + rec.faded = nil + else + rec = {} + end + rec.f = firstID + rec.n = spawned + rec.d = maxDeath + rec.gc = clampThisEmit + if inverse then + rec.pi = pieceIdx + rec.lc = infoIsMobile + elseif fwdMode == 2 then + rec.bid = builderID + rec.pi = pieceIdx + rec.mrs = fwdMaxRangeSq + rec.lc = targetIsMobileUnit + rec.jc = firstJitter + rec.js = jitterScale * len + end + local n = #list + if n < cap then + list[n + 1] = rec + else + appendTrackRecord(list, rec, cap, frame) + end + end +end + +local function emitNanoBatch( + unitID, + info, + ex, + ey, ez, inverse, jitterRadius, @@ -2629,6 +3044,7 @@ local function fireReclaimBurst(targetUnitID, targetUnitDefID, attackerTeam, bui end end end + U.flushLightBatch() end -------------------------------------------------------------------------------- @@ -2713,7 +3129,10 @@ local function resolveTarget(info, cmdID, targetID) local px, py, pz local cached = emitTargetPosCache[meta.targetID] - if cached and cached[1] == piecePosEpoch then + -- cached[5] marks a target that cannot move (feature, immobile unit): its + -- position is kept until UnitDestroyed / FeatureDestroyed drop the entry + -- instead of being re-queried by every builder on every scan frame. + if cached and (cached[5] or cached[1] == piecePosEpoch) then px, py, pz = cached[2], cached[3], cached[4] else if meta.isFeature then @@ -2724,13 +3143,15 @@ local function resolveTarget(info, cmdID, targetID) px, py, pz = mx, my, mz end if px then + local static = (meta.isFeature or not meta.isMobileUnit) and true or false if cached then cached[1] = piecePosEpoch cached[2] = px cached[3] = py cached[4] = pz + cached[5] = static else - emitTargetPosCache[meta.targetID] = { piecePosEpoch, px, py, pz } + emitTargetPosCache[meta.targetID] = { piecePosEpoch, px, py, pz, static } end end end @@ -2763,8 +3184,17 @@ U._accrueColdVirtualStream = function(unitID, info, meta, frame) return end + -- Same stride compensation as the visible path in scanBuilders. + local lastVisit = info.lastVisitFrame + local elapsed = lastVisit and (frame - lastVisit) or 1 + local compCap = U._emitCompCap or MIN_SCAN_STRIDE + if elapsed < 1 then + elapsed = 1 + elseif elapsed > compCap then + elapsed = compCap + end info.lastVisitFrame = frame - local rate = (info.buildSpeed * bp / EMIT_REF_BUILDSPEED) * (NanoParticlesRate or 1.0) + local rate = (info.buildSpeed * bp / EMIT_REF_BUILDSPEED) * elapsed * (NanoParticlesRate or 1.0) local accum = (info.emitAccum or 0) + rate local emits = mathFloor(accum) info.emitAccum = accum - emits @@ -2854,6 +3284,10 @@ local function materializeVisibleVirtualStreams(frame) idx = 1 end piecePosEpoch = piecePosEpoch + 1 + -- Catch-up bursts run on the draw thread; bound the particles materialized + -- per draw frame so a camera jump onto a busy base costs a few frames of + -- gradual fill-in instead of one long frame. + local budget = U.VIRTUAL_PARTICLE_BUDGET_PER_DRAW or 256 local checked = 0 while checked < maxChecks and listCount > 0 do @@ -2879,9 +3313,19 @@ local function materializeVisibleVirtualStreams(frame) visible = Spring.IsSphereInView(vx, meta.virtualY, meta.virtualZ, radius) and true or false end if visible then + if budget <= 0 then + -- Budget spent: leave this stream queued for the next draw. + deathBuckets.__virtualStreamsNeedCheck = true + break + end + local emitNow = virtualEmits + if emitNow > budget then + emitNow = budget + end local ex, ey, ez, inverse, jitterRadius, isResurrect, targetUnitID = resolveTarget(info, meta.cmdID, meta.targetID) meta = info.targetMeta + remove = true if ex and meta then if info.isFactory then jitterRadius = nil @@ -2891,9 +3335,16 @@ local function materializeVisibleVirtualStreams(frame) meta.lastVisibleFrame = frame meta.coldOffscreenUntil = 0 local catchupAgeFrames = meta.virtualAgeFrames or 0 - meta.virtualEmits = 0 - meta.virtualAgeFrames = 0 - meta.virtualFrame = frame + meta.virtualEmits = virtualEmits - emitNow + if meta.virtualEmits > 0 then + -- Partially materialized: keep it listed for the next draw. + remove = false + deathBuckets.__virtualStreamsNeedCheck = true + else + meta.virtualAgeFrames = 0 + meta.virtualFrame = frame + end + budget = budget - emitNow emitNanoBatch( unitID, info, @@ -2905,12 +3356,11 @@ local function materializeVisibleVirtualStreams(frame) frame, targetUnitID, isResurrect, - virtualEmits, + emitNow, 0, catchupAgeFrames ) end - remove = true end else remove = true @@ -2933,6 +3383,7 @@ local function materializeVisibleVirtualStreams(frame) end deathBuckets.__virtualStreamCursor = idx deathBuckets.__virtualStreamCount = listCount + U.flushLightBatch() local postUsed = nanoVBO.usedElements or 0 if postUsed > preUsed then uploadElementRange(nanoVBO, preUsed, postUsed) @@ -2958,9 +3409,19 @@ local function applyHoming(frame, dirtyMin, dirtyMax) local idtoIndex = nanoVBO.instanceIDtoIndex local step = nanoVBO.instanceStep local trackEntryFree = U._trackEntryFree + local nl = deathBuckets.__nanoLight + local lightActive = nl and nl.bridgeReady and nl.active + local lightMinFrames = nl and nl.correctEvery or 10 + local dirtySlots = U._dirtySlots + local dn = U._dirtySlotN + -- This frame's coset: each builder's list is handled every HOMING_RUN_EVERY frames. + local phase = U.HOMING_SPREAD and (frame % HOMING_RUN_EVERY) or nil for builderID, list in pairs(homingByBuilder) do repeat + if phase and builderID % HOMING_RUN_EVERY ~= phase then + break + end local info = builderCache[builderID] if not info or not spValidUnitID(builderID) then U.recycleTrackList(list) @@ -2977,17 +3438,24 @@ local function applyHoming(frame, dirtyMin, dirtyMax) break end local writeIdx = 0 - -- Hoist the high half of the piecePosCache key out of the per-particle - -- loop. Hot path: ~thousands of particles per scan in heavy reclaim. + -- Hoist the high half of the piecePosCache key out of the per-record + -- loop. Hot path: thousands of particles per scan in heavy reclaim. local builderKeyHi = builderID * 256 local clampDelta = U.GROUND_CLAMP_SMART_DELTA - for i = 1, #list do - local p = list[i] - local remaining = p.death - frame - local slot = (remaining > 1) and idtoIndex[p.id] or nil - if slot then - -- Resolve current piece position via the per-frame cache. - local pieceIdx = p.pieceIdx + -- Bounds of everything rewritten this pass, filed once per builder + -- under the latest death frame still tracked. + local any = false + local lDeath = -1 + local bMinX, bMinY, bMinZ = 1e18, 1e18, 1e18 + local bMaxX, bMaxY, bMaxZ = -1e18, -1e18, -1e18 + for ri = 1, #list do + local rec = list[ri] + if frame >= rec.d - 1 then + -- Whole batch dead or dying next frame: drop the record. + trackEntryFree[#trackEntryFree + 1] = rec + else + -- Resolve the batch's piece position via the per-frame cache. + local pieceIdx = rec.pi local key = builderKeyHi + pieceIdx local entry = piecePosCache[key] local pieceMoving @@ -3039,92 +3507,161 @@ local function applyHoming(frame, dirtyMin, dirtyMax) end end - if nx then - if pieceMoving == false and not p.gc then - writeIdx = writeIdx + 1 - list[writeIdx] = p - else - local base = (slot - 1) * step - local sx, sy, sz = data[base + 1], data[base + 2], data[base + 3] - local vx, vy, vz = data[base + 5], data[base + 6], data[base + 7] - local spawnF = data[base + 8] - local elapsed = frame - spawnF - local cpx = sx + vx * elapsed - local cpy = sy + vy * elapsed - local cpz = sz + vz * elapsed - local aimY = ny - if p.gc then - -- For reclaim on high-to-low terrain, keep particles above current - -- ground during homing updates so they travel along the upper - -- surface before descending at the cliff break. - local gyCur = getGroundYMargin(cpx, cpz, frame) - if cpy < gyCur then - cpy = gyCur + if not nx then + trackEntryFree[#trackEntryFree + 1] = rec + elseif pieceMoving == false and not rec.gc then + -- Static piece, no terrain guidance: spawn-time aim is + -- still exact, nothing to rewrite for this batch. + writeIdx = writeIdx + 1 + list[writeIdx] = rec + else + local gc = rec.gc + local gyDst + if gc then + gyDst = entry[9] + if gyDst == nil then + gyDst = getGroundYMargin(nx, nz, frame) + entry[9] = gyDst + end + end + local lc = rec.lc and lightActive + local recAny = false + for id = rec.f, rec.f + rec.n - 1 do + local slot = idtoIndex[id] + if slot then + local base = (slot - 1) * step + -- A particle whose spawn frame is still ahead (stride + -- stagger) has not moved yet: re-aim it from its spawn + -- point and keep its spawn frame. + local startF = data[base + 8] + if startF < frame then + startF = frame end - local gyDst = entry and entry[9] - if gyDst == nil then - gyDst = getGroundYMargin(nx, nz, frame) - if entry then - entry[9] = gyDst + local remaining = data[base + 16] - startF + if remaining > 1 then + local sx, sy, sz = data[base + 1], data[base + 2], data[base + 3] + local vx, vy, vz = data[base + 5], data[base + 6], data[base + 7] + local elapsed = startF - data[base + 8] + local cpx = sx + vx * elapsed + local cpy = sy + vy * elapsed + local cpz = sz + vz * elapsed + local aimY = ny + local lifted = false + if gc then + -- For reclaim on high-to-low terrain, keep particles above current + -- ground during homing updates so they travel along the upper + -- surface before descending at the cliff break. + local gyCur = getGroundYMargin(cpx, cpz, frame) + if cpy < gyCur then + cpy = gyCur + lifted = true + end + if aimY < gyDst then + aimY = gyDst + end + if gyCur > (aimY + clampDelta) then + aimY = gyCur + end + end + -- Inverse particles all converge on the builder piece (engine + -- behaviour: speed = -dif*3 makes pos arrive at startPos exactly). + -- Visual spread comes from staggered spawn positions, not from + -- the velocity direction, so simple aim is correct here. + local invR = 1.0 / remaining + local nvx = (nx - cpx) * invR + local nvy = (aimY - cpy) * invR + local nvz = (nz - cpz) * invR + -- No rewrite when nothing changes: a particle already flying + -- straight at an unmoved aim from above ground (the common + -- terrain-guided case) needs neither VBO write nor upload. + local dvx, dvy, dvz = nvx - vx, nvy - vy, nvz - vz + if lifted or (dvx * dvx + dvy * dvy + dvz * dvz) > 1e-6 then + data[base + 1] = cpx + data[base + 2] = cpy + data[base + 3] = cpz + data[base + 5] = nvx + data[base + 6] = nvy + data[base + 7] = nvz + data[base + 8] = startF + if cpx < bMinX then + bMinX = cpx + end + if cpx > bMaxX then + bMaxX = cpx + end + if cpy < bMinY then + bMinY = cpy + end + if cpy > bMaxY then + bMaxY = cpy + end + if cpz < bMinZ then + bMinZ = cpz + end + if cpz > bMaxZ then + bMaxZ = cpz + end + if lc then + local lastFix = lightActive[id] + if lastFix and (frame - lastFix) >= lightMinFrames then + local lightID = nl.ids[id] + if lightID then + lightActive[id] = frame + U.queueLightCorrection(lightID, cpx, cpy, cpz, nvx, nvy, nvz, frame) + end + end + end + local s0 = slot - 1 + if s0 < dirtyMin then + dirtyMin = s0 + end + if s0 + 1 > dirtyMax then + dirtyMax = s0 + 1 + end + dn = dn + 1 + dirtySlots[dn] = slot + recAny = true end - end - if aimY < gyDst then - aimY = gyDst - end - if gyCur > (aimY + clampDelta) then - aimY = gyCur end end - -- Inverse particles all converge on the builder piece (engine - -- behaviour: speed = -dif*3 makes pos arrive at startPos exactly). - -- Visual spread comes from staggered spawn positions, not from - -- the velocity direction, so simple aim is correct here. - local invR = 1.0 / remaining - data[base + 1] = cpx - data[base + 2] = cpy - data[base + 3] = cpz - data[base + 5] = (nx - cpx) * invR - data[base + 6] = (aimY - cpy) * invR - data[base + 7] = (nz - cpz) * invR - data[base + 8] = frame - U.expandParticleBounds(p.death, cpx, cpy, cpz, nx, aimY, nz) - local nl = deathBuckets.__nanoLight - if p.lc and nl and nl.bridgeReady then - local lastFix = nl.active[p.id] - local minFrames = nl.correctEvery or 10 - local lightID = nl.ids[p.id] - if lastFix and lightID and (frame - lastFix) >= minFrames then - nl.active[p.id] = frame - Script.LuaUI.EnvNanoBallisticLightCorrect( - lightID, - cpx, - cpy, - cpz, - (nx - cpx) * invR, - (aimY - cpy) * invR, - (nz - cpz) * invR, - frame - ) - end + end + if recAny then + any = true + if rec.d > lDeath then + lDeath = rec.d end - local s0 = slot - 1 - if s0 < dirtyMin then - dirtyMin = s0 + if nx < bMinX then + bMinX = nx end - if s0 + 1 > dirtyMax then - dirtyMax = s0 + 1 + if nx > bMaxX then + bMaxX = nx + end + if nz < bMinZ then + bMinZ = nz + end + if nz > bMaxZ then + bMaxZ = nz + end + local aimTop = gc and gyDst or ny + if aimTop < ny then + aimTop = ny + end + if ny < bMinY then + bMinY = ny + end + if aimTop > bMaxY then + bMaxY = aimTop end - writeIdx = writeIdx + 1 - list[writeIdx] = p end - else - trackEntryFree[#trackEntryFree + 1] = p + writeIdx = writeIdx + 1 + list[writeIdx] = rec end - else - trackEntryFree[#trackEntryFree + 1] = p end end - -- Trim dropped entries (dead, missing slot, or no piece pos). + if any then + U.expandParticleBounds(lDeath, bMinX, bMinY, bMinZ, bMaxX, bMaxY, bMaxZ) + end + -- Trim dropped records (dead, or no piece pos). for j = #list, writeIdx + 1, -1 do list[j] = nil end @@ -3133,6 +3670,7 @@ local function applyHoming(frame, dirtyMin, dirtyMax) end until true end + U._dirtySlotN = dn return dirtyMin, dirtyMax end @@ -3168,8 +3706,19 @@ local function applyForwardHoming(frame, dirtyMin, dirtyMax) local idtoIndex = nanoVBO.instanceIDtoIndex local step = nanoVBO.instanceStep local trackEntryFree = U._trackEntryFree - local function fadeParticle(slot, p) - local remaining = p.death - frame + local jitterTable = U._jitterTable + local jitterLen = U._jitterTableLast + 2 + local nl = deathBuckets.__nanoLight + local lightActive = nl and nl.bridgeReady and nl.active + local lightMinFrames = nl and nl.correctEvery or 10 + local dirtySlots = U._dirtySlots + local dn = U._dirtySlotN + + -- Shorten one particle's life so the shader's fade ramp dissolves it; it + -- keeps coasting on its current trajectory. The record remembers it so + -- later passes leave it alone (re-fading would restart the alpha ramp). + local function fadeParticle(rec, id, slot, base) + local remaining = data[base + 16] - frame if remaining <= 0 then return false end @@ -3180,9 +3729,7 @@ local function applyForwardHoming(frame, dirtyMin, dirtyMax) if fadeFrames > remaining then fadeFrames = remaining end - local newDeath = frame + fadeFrames - local base = (slot - 1) * step - data[base + 16] = newDeath + data[base + 16] = frame + fadeFrames local packed = data[base + 4] local absPacked = packed < 0 and -packed or packed local oldFade = mathFloor(absPacked / 1024) @@ -3196,14 +3743,26 @@ local function applyForwardHoming(frame, dirtyMin, dirtyMax) if s0 + 1 > dirtyMax then dirtyMax = s0 + 1 end - fadeNanoDeferredLight(p.id, frame, fadeFrames) + dn = dn + 1 + dirtySlots[dn] = slot + local faded = rec.faded + if not faded then + faded = {} + rec.faded = faded + end + faded[id] = true + fadeNanoDeferredLight(id, frame, fadeFrames) return true end targetPosEpoch = targetPosEpoch + 1 + -- This frame's coset: each target's list is handled every HOMING_RUN_EVERY frames. + local phase = U.HOMING_SPREAD and (frame % HOMING_RUN_EVERY) or nil for targetID, list in pairs(homingFwdByTarget) do - if not spValidUnitID(targetID) then + if phase and targetID % HOMING_RUN_EVERY ~= phase then + -- not this frame's coset + elseif not spValidUnitID(targetID) then U.recycleTrackList(list) homingFwdByTarget[targetID] = nil else @@ -3220,7 +3779,11 @@ local function applyForwardHoming(frame, dirtyMin, dirtyMax) local h, maxH, _, _, bp = spGetUnitHealth(targetID) if h and maxH and h >= maxH and (bp == nil or bp >= 1.0) then if not list._fadingOut then + -- The fade flushes the shared slot list: hand it our slots + -- so far and continue from the emptied list. + U._dirtySlotN = dn fadeOutHomingFwd(targetID) + dn = U._dirtySlotN list._fadingOut = true end else @@ -3237,7 +3800,9 @@ local function applyForwardHoming(frame, dirtyMin, dirtyMax) if isAir then local mt = spGetUnitMoveTypeData(targetID) if mt and mt.aircraftState == "crashing" then + U._dirtySlotN = dn fadeOutHomingFwd(targetID) + dn = U._dirtySlotN U.recycleTrackList(list) homingFwdByTarget[targetID] = nil targetPosCache[targetID] = nil @@ -3314,17 +3879,17 @@ local function applyForwardHoming(frame, dirtyMin, dirtyMax) end if entry[8] >= STATIONARY_SKIP_AFTER then - -- Stationary: just trim dead/missing particles from list, - -- skip the expensive per-particle rewrite. Spawn-time - -- velocity already aims at the (still-correct) target. + -- Stationary: just trim expired records, skip the + -- per-particle rewrite. Spawn-time velocity already aims + -- at the (still-correct) target. local writeIdx = 0 - for i = 1, #list do - local p = list[i] - if (p.death - frame) >= 1 and idtoIndex[p.id] then - writeIdx = writeIdx + 1 - list[writeIdx] = p + for ri = 1, #list do + local rec = list[ri] + if frame >= rec.d - 1 then + trackEntryFree[#trackEntryFree + 1] = rec else - trackEntryFree[#trackEntryFree + 1] = p + writeIdx = writeIdx + 1 + list[writeIdx] = rec end end for j = #list, writeIdx + 1, -1 do @@ -3336,21 +3901,31 @@ local function applyForwardHoming(frame, dirtyMin, dirtyMax) end else local writeIdx = 0 - for i = 1, #list do - local p = list[i] - local remaining = p.death - frame - local slot = (remaining >= 1) and idtoIndex[p.id] or nil - if slot then - local fadeParticleOut = false - local maxRangeSq = p.maxRangeSq - if maxRangeSq and p.builderID and p.pieceIdx then - local key = p.builderID * 256 + p.pieceIdx + -- Bounds of everything rewritten this pass, filed once per + -- target under the latest death frame still tracked. + local any = false + local lDeath = -1 + local bMinX, bMinY, bMinZ = 1e18, 1e18, 1e18 + local bMaxX, bMaxY, bMaxZ = -1e18, -1e18, -1e18 + for ri = 1, #list do + local rec = list[ri] + if frame >= rec.d - 1 then + trackEntryFree[#trackEntryFree + 1] = rec + else + -- Build-range gate, once per batch: when the target has + -- left the emitting piece's horizontal reach, fade the + -- whole batch instead of chasing it. + local fadeAll = false + local maxRangeSq = rec.mrs + local builderID = rec.bid + if maxRangeSq and builderID then + local key = builderID * 256 + rec.pi local bx, bz local pent = piecePosCache[key] if pent and pent[1] == piecePosEpoch then bx, bz = pent[2], pent[4] else - local px, py, pz = spGetUnitPiecePosDir(p.builderID, p.pieceIdx) + local px, py, pz = spGetUnitPiecePosDir(builderID, rec.pi) if px then if pent then pent[1] = piecePosEpoch @@ -3367,80 +3942,135 @@ local function applyForwardHoming(frame, dirtyMin, dirtyMax) local rdx = tx - bx local rdz = tz - bz if (rdx * rdx + rdz * rdz) > maxRangeSq then - fadeParticleOut = fadeParticle(slot, p) + fadeAll = true end end end - if not fadeParticleOut then - local base = (slot - 1) * step - local sx, sy, sz = data[base + 1], data[base + 2], data[base + 3] - local vx, vy, vz = data[base + 5], data[base + 6], data[base + 7] - local spawnF = data[base + 8] - local elapsed = frame - spawnF - local cpx = sx + vx * elapsed - local cpy = sy + vy * elapsed - local cpz = sz + vz * elapsed - -- Aim at target + per-particle landing offset. The offset is the - -- engine's jitter-driven spread point for this specific particle, - -- so the spray width at the destination is preserved as the - -- target moves. - local aimX = tx + p.ox - local aimZ = tz + p.oz - local aimY = ty + p.oy - local dvx = aimX - cpx - local dvy = aimY - cpy - local dvz = aimZ - cpz - local invR = 1.0 / remaining - local needSpeedSq = (dvx * dvx + dvy * dvy + dvz * dvz) * (invR * invR) - if needSpeedSq > maxSpeedSq then - fadeParticleOut = fadeParticle(slot, p) - else - data[base + 1] = cpx - data[base + 2] = cpy - data[base + 3] = cpz - data[base + 5] = dvx * invR - data[base + 6] = dvy * invR - data[base + 7] = dvz * invR - data[base + 8] = frame - U.expandParticleBounds(p.death, cpx, cpy, cpz, aimX, aimY, aimZ) - local nl = deathBuckets.__nanoLight - if p.lc and nl and nl.bridgeReady then - local lastFix = nl.active[p.id] - local minFrames = nl.correctEvery or 10 - local lightID = nl.ids[p.id] - if lastFix and lightID and (frame - lastFix) >= minFrames then - nl.active[p.id] = frame - Script.LuaUI.EnvNanoBallisticLightCorrect( - lightID, - cpx, - cpy, - cpz, - dvx * invR, - dvy * invR, - dvz * invR, - frame - ) - end + local faded = rec.faded + local js = rec.js + local jc0 = rec.jc - 1 + local firstID = rec.f + local lc = rec.lc and lightActive + local recAny = false + for id = firstID, firstID + rec.n - 1 do + local slot = idtoIndex[id] + if slot and not (faded and faded[id]) then + local base = (slot - 1) * step + -- Not-yet-due particles (stride stagger) are re-aimed + -- from their spawn point and keep their spawn frame. + local startF = data[base + 8] + if startF < frame then + startF = frame end - local s0 = slot - 1 - if s0 < dirtyMin then - dirtyMin = s0 - end - if s0 + 1 > dirtyMax then - dirtyMax = s0 + 1 + local remaining = data[base + 16] - startF + if remaining >= 1 then + if fadeAll then + fadeParticle(rec, id, slot, base) + else + -- Aim at target + this particle's landing offset. The + -- offset is the jitter-driven spread point the particle + -- was emitted with, so the spray width at the + -- destination is preserved as the target moves. + local jidx = ((jc0 + 3 * (id - firstID)) % jitterLen) + 1 + local aimX = tx + jitterTable[jidx] * js + local aimY = ty + jitterTable[jidx + 1] * js + local aimZ = tz + jitterTable[jidx + 2] * js + local sx, sy, sz = data[base + 1], data[base + 2], data[base + 3] + local vx, vy, vz = data[base + 5], data[base + 6], data[base + 7] + local elapsed = startF - data[base + 8] + local cpx = sx + vx * elapsed + local cpy = sy + vy * elapsed + local cpz = sz + vz * elapsed + local dvx = aimX - cpx + local dvy = aimY - cpy + local dvz = aimZ - cpz + local invR = 1.0 / remaining + local needSpeedSq = (dvx * dvx + dvy * dvy + dvz * dvz) * (invR * invR) + if needSpeedSq > maxSpeedSq then + fadeParticle(rec, id, slot, base) + else + local nvx, nvy, nvz = dvx * invR, dvy * invR, dvz * invR + data[base + 1] = cpx + data[base + 2] = cpy + data[base + 3] = cpz + data[base + 5] = nvx + data[base + 6] = nvy + data[base + 7] = nvz + data[base + 8] = startF + if cpx < bMinX then + bMinX = cpx + end + if cpx > bMaxX then + bMaxX = cpx + end + if aimX < bMinX then + bMinX = aimX + end + if aimX > bMaxX then + bMaxX = aimX + end + if cpy < bMinY then + bMinY = cpy + end + if cpy > bMaxY then + bMaxY = cpy + end + if aimY < bMinY then + bMinY = aimY + end + if aimY > bMaxY then + bMaxY = aimY + end + if cpz < bMinZ then + bMinZ = cpz + end + if cpz > bMaxZ then + bMaxZ = cpz + end + if aimZ < bMinZ then + bMinZ = aimZ + end + if aimZ > bMaxZ then + bMaxZ = aimZ + end + if lc then + local lastFix = lightActive[id] + if lastFix and (frame - lastFix) >= lightMinFrames then + local lightID = nl.ids[id] + if lightID then + lightActive[id] = frame + U.queueLightCorrection(lightID, cpx, cpy, cpz, nvx, nvy, nvz, frame) + end + end + end + local s0 = slot - 1 + if s0 < dirtyMin then + dirtyMin = s0 + end + if s0 + 1 > dirtyMax then + dirtyMax = s0 + 1 + end + dn = dn + 1 + dirtySlots[dn] = slot + recAny = true + end + end end end end - if not fadeParticleOut then - writeIdx = writeIdx + 1 - list[writeIdx] = p - else - trackEntryFree[#trackEntryFree + 1] = p + if recAny then + any = true + if rec.d > lDeath then + lDeath = rec.d + end end - else - trackEntryFree[#trackEntryFree + 1] = p + writeIdx = writeIdx + 1 + list[writeIdx] = rec end end + if any then + U.expandParticleBounds(lDeath, bMinX, bMinY, bMinZ, bMaxX, bMaxY, bMaxZ) + end for j = #list, writeIdx + 1, -1 do list[j] = nil end @@ -3453,6 +4083,7 @@ local function applyForwardHoming(frame, dirtyMin, dirtyMax) end -- end of "not fully repaired" else end end + U._dirtySlotN = dn return dirtyMin, dirtyMax end @@ -3487,16 +4118,29 @@ local function applyGroundClamp(frame, dirtyMin, dirtyMax) return dirtyMin, dirtyMax end + -- Per-frame slice: with MAX_PER_STEP 0 each frame examines + -- total / GROUND_CLAMP_SPREAD_FRAMES entries from the rotating cursor, so + -- every entry is still examined on its recheck cadence without the whole + -- pass landing on one frame. local maxPer = U.GROUND_CLAMP_MAX_PER_STEP or 0 - if maxPer < 1 or maxPer > total then + if maxPer < 1 then + maxPer = mathCeil(total / (U.GROUND_CLAMP_SPREAD_FRAMES or 1)) + end + if maxPer > total then maxPer = total end local data = nanoVBO.instanceData local step = nanoVBO.instanceStep local idtoIndex = nanoVBO.instanceIDtoIndex + local jitterTable = U._jitterTable + local jitterLen = U._jitterTableLast + 2 local recheckHit = U.GROUND_CLAMP_RECHECK_HIT or 2 local recheckMiss = U.GROUND_CLAMP_RECHECK_MISS or 4 + local wpMaxSpeed = NANO_SPEED * (U.GROUND_CLAMP_WAYPOINT_MAX_SPEED_MULT or 3.0) + local wpMaxSpeedSq = wpMaxSpeed * wpMaxSpeed + local dirtySlots = U._dirtySlots + local dn = U._dirtySlotN local idx = groundClampCursor if idx < 1 or idx > total then idx = 1 @@ -3504,9 +4148,8 @@ local function applyGroundClamp(frame, dirtyMin, dirtyMax) local processed = 0 local checked = 0 - local maxChecks = total + maxPer local n = total - while processed < maxPer and checked < maxChecks do + while checked < maxPer do if n == 0 then idx = 1 break @@ -3515,102 +4158,224 @@ local function applyGroundClamp(frame, dirtyMin, dirtyMax) idx = 1 end local entry = groundClampParticles[idx] - local slot = idtoIndex[entry.id] - if (not slot) or entry.death <= frame + 1 then - local removed = entry + if entry.death <= frame + 1 then + -- Whole batch dead (or dying next frame). groundClampParticles[idx] = groundClampParticles[n] groundClampParticles[n] = nil n = n - 1 - groundClampFree[#groundClampFree + 1] = removed + groundClampFree[#groundClampFree + 1] = entry if CLAMP_DEBUG then - clampDbg.dropped = clampDbg.dropped + 1 + clampDbg.dropped = clampDbg.dropped + entry.n end elseif entry.next and frame < entry.next then idx = idx + 1 elseif entry.wp then - local base = (slot - 1) * step - local rem = entry.death - frame - if rem > 1 and entry.fx then - local fx, fy, fz = entry.fx, entry.fy, entry.fz - local completionFrame = entry.targetID and recentFactoryBuildTargetCache[entry.targetID] - if completionFrame and frame - completionFrame >= HOMING_SKIP_GRACE_FRAMES then - recentFactoryBuildTargetCache[entry.targetID] = nil - completionFrame = nil + -- Waypoint leg done: aim every live particle of the batch at its + -- own landing point (batch endpoint + per-particle jitter offset). + -- Only a forward-homing batch carries a target (see emitNano); it + -- supplies the unit's current mid position instead. + local fx, fy, fz = entry.ex, entry.ey, entry.ez + local targetID = entry.targetID + if targetID then + local _, _, _, mx, my, mz = spGetUnitPosition(targetID, true) + if mx then + fx, fy, fz = mx, my, mz end - if entry.targetID and not completionFrame then - local _, _, _, mx, my, mz = spGetUnitPosition(entry.targetID, true) - if mx then - fx, fy, fz = mx, my, mz + end + local js = entry.js + local jc0 = entry.jc - 1 + local firstID = entry.f + local any = false + local bMinX, bMinY, bMinZ = 1e18, 1e18, 1e18 + local bMaxX, bMaxY, bMaxZ = -1e18, -1e18, -1e18 + for id = firstID, firstID + entry.n - 1 do + local slot = idtoIndex[id] + if slot then + local base = (slot - 1) * step + -- Not-yet-due particles (stride stagger) are redirected from + -- their spawn point and keep their spawn frame. + local startF = data[base + 8] + if startF < frame then + startF = frame + end + local rem = data[base + 16] - startF + if rem > 1 then + local jidx = ((jc0 + 3 * (id - firstID)) % jitterLen) + 1 + local ax = fx + jitterTable[jidx] * js + local ay = fy + jitterTable[jidx + 1] * js + local az = fz + jitterTable[jidx + 2] * js + local sx, sy, sz = data[base + 1], data[base + 2], data[base + 3] + local vx, vy, vz = data[base + 5], data[base + 6], data[base + 7] + local elapsed = startF - data[base + 8] + local cpx = sx + vx * elapsed + local cpy = sy + vy * elapsed + local cpz = sz + vz * elapsed + local invR = 1.0 / rem + local nvx = (ax - cpx) * invR + local nvy = (ay - cpy) * invR + local nvz = (az - cpz) * invR + -- See GROUND_CLAMP_WAYPOINT_MAX_SPEED_MULT: too few frames left to + -- reach the landing point at a sane speed, keep the current heading. + if (nvx * nvx + nvy * nvy + nvz * nvz) <= wpMaxSpeedSq then + data[base + 1] = cpx + data[base + 2] = cpy + data[base + 3] = cpz + data[base + 5] = nvx + data[base + 6] = nvy + data[base + 7] = nvz + data[base + 8] = startF + if cpx < bMinX then + bMinX = cpx + end + if cpx > bMaxX then + bMaxX = cpx + end + if ax < bMinX then + bMinX = ax + end + if ax > bMaxX then + bMaxX = ax + end + if cpy < bMinY then + bMinY = cpy + end + if cpy > bMaxY then + bMaxY = cpy + end + if ay < bMinY then + bMinY = ay + end + if ay > bMaxY then + bMaxY = ay + end + if cpz < bMinZ then + bMinZ = cpz + end + if cpz > bMaxZ then + bMaxZ = cpz + end + if az < bMinZ then + bMinZ = az + end + if az > bMaxZ then + bMaxZ = az + end + local s0 = slot - 1 + if s0 < dirtyMin then + dirtyMin = s0 + end + if s0 + 1 > dirtyMax then + dirtyMax = s0 + 1 + end + dn = dn + 1 + dirtySlots[dn] = slot + any = true + end end end - local sx, sy, sz = data[base + 1], data[base + 2], data[base + 3] - local vx, vy, vz = data[base + 5], data[base + 6], data[base + 7] - local spawnF = data[base + 8] - local elapsed = frame - spawnF - local cpx = sx + vx * elapsed - local cpy = sy + vy * elapsed - local cpz = sz + vz * elapsed - local invR = 1.0 / rem - data[base + 1] = cpx - data[base + 2] = cpy - data[base + 3] = cpz - data[base + 5] = (fx - cpx) * invR - data[base + 6] = (fy - cpy) * invR - data[base + 7] = (fz - cpz) * invR - data[base + 8] = frame - U.expandParticleBounds(entry.death, cpx, cpy, cpz, fx, fy, fz) - local s0 = slot - 1 - if s0 < dirtyMin then - dirtyMin = s0 - end - if s0 + 1 > dirtyMax then - dirtyMax = s0 + 1 - end + end + if any then + U.expandParticleBounds(entry.death, bMinX, bMinY, bMinZ, bMaxX, bMaxY, bMaxZ) if CLAMP_DEBUG then - clampDbg.corrected = clampDbg.corrected + 1 + clampDbg.corrected = clampDbg.corrected + entry.n end end - local removed = entry groundClampParticles[idx] = groundClampParticles[n] groundClampParticles[n] = nil n = n - 1 - groundClampFree[#groundClampFree + 1] = removed + groundClampFree[#groundClampFree + 1] = entry processed = processed + 1 if CLAMP_DEBUG then clampDbg.processed = clampDbg.processed + 1 end else - local base = (slot - 1) * step - local remaining = entry.death - frame - local sx, sy, sz = data[base + 1], data[base + 2], data[base + 3] - local vx, vy, vz = data[base + 5], data[base + 6], data[base + 7] - local spawnF = data[base + 8] - local elapsed = frame - spawnF - local cpx = sx + vx * elapsed - local cpy = sy + vy * elapsed - local cpz = sz + vz * elapsed - local clampedCpy = clampYAboveGround(cpx, cpy, cpz, frame) - - if clampedCpy ~= cpy then - local aimX = cpx + vx * remaining - local aimY = cpy + vy * remaining - local aimZ = cpz + vz * remaining - local invR = 1.0 / remaining - data[base + 1] = cpx - data[base + 2] = clampedCpy - data[base + 3] = cpz - data[base + 5] = (aimX - cpx) * invR - data[base + 6] = (aimY - clampedCpy) * invR - data[base + 7] = (aimZ - cpz) * invR - data[base + 8] = frame - U.expandParticleBounds(entry.death, cpx, clampedCpy, cpz, aimX, aimY, aimZ) - local s0 = slot - 1 - if s0 < dirtyMin then - dirtyMin = s0 - end - if s0 + 1 > dirtyMax then - dirtyMax = s0 + 1 + -- Recheck mode: reproject any particle of the batch that dipped + -- below ground + margin, keeping its landing point. + local hit = false + local firstID = entry.f + local bMinX, bMinY, bMinZ = 1e18, 1e18, 1e18 + local bMaxX, bMaxY, bMaxZ = -1e18, -1e18, -1e18 + for id = firstID, firstID + entry.n - 1 do + local slot = idtoIndex[id] + if slot then + local base = (slot - 1) * step + local startF = data[base + 8] + if startF < frame then + startF = frame + end + local remaining = data[base + 16] - startF + if remaining >= 1 then + local sx, sy, sz = data[base + 1], data[base + 2], data[base + 3] + local vx, vy, vz = data[base + 5], data[base + 6], data[base + 7] + local elapsed = startF - data[base + 8] + local cpx = sx + vx * elapsed + local cpy = sy + vy * elapsed + local cpz = sz + vz * elapsed + local clampedCpy = clampYAboveGround(cpx, cpy, cpz, frame) + if clampedCpy ~= cpy then + local aimX = cpx + vx * remaining + local aimY = cpy + vy * remaining + local aimZ = cpz + vz * remaining + local invR = 1.0 / remaining + data[base + 1] = cpx + data[base + 2] = clampedCpy + data[base + 3] = cpz + data[base + 5] = (aimX - cpx) * invR + data[base + 6] = (aimY - clampedCpy) * invR + data[base + 7] = (aimZ - cpz) * invR + data[base + 8] = startF + if cpx < bMinX then + bMinX = cpx + end + if cpx > bMaxX then + bMaxX = cpx + end + if aimX < bMinX then + bMinX = aimX + end + if aimX > bMaxX then + bMaxX = aimX + end + if clampedCpy < bMinY then + bMinY = clampedCpy + end + if clampedCpy > bMaxY then + bMaxY = clampedCpy + end + if aimY < bMinY then + bMinY = aimY + end + if aimY > bMaxY then + bMaxY = aimY + end + if cpz < bMinZ then + bMinZ = cpz + end + if cpz > bMaxZ then + bMaxZ = cpz + end + if aimZ < bMinZ then + bMinZ = aimZ + end + if aimZ > bMaxZ then + bMaxZ = aimZ + end + local s0 = slot - 1 + if s0 < dirtyMin then + dirtyMin = s0 + end + if s0 + 1 > dirtyMax then + dirtyMax = s0 + 1 + end + dn = dn + 1 + dirtySlots[dn] = slot + hit = true + end + end end + end + if hit then + U.expandParticleBounds(entry.death, bMinX, bMinY, bMinZ, bMaxX, bMaxY, bMaxZ) if CLAMP_DEBUG then clampDbg.corrected = clampDbg.corrected + 1 end @@ -3618,7 +4383,6 @@ local function applyGroundClamp(frame, dirtyMin, dirtyMax) else entry.next = frame + recheckMiss end - processed = processed + 1 if CLAMP_DEBUG then clampDbg.processed = clampDbg.processed + 1 @@ -3629,6 +4393,7 @@ local function applyGroundClamp(frame, dirtyMin, dirtyMax) end groundClampCursor = idx + U._dirtySlotN = dn return dirtyMin, dirtyMax end @@ -3643,9 +4408,14 @@ function U.updateParticleMaintenance(frame, preUsed, runHoming, runGroundClamp) tracy.ZoneBeginN("G:NanoParticles:RunFrame:ParticleMaintenance") local dirtyMin, dirtyMax = math.huge, -1 - if runHoming and U.NANO_PARTICLES_HOMING and frame >= (deathBuckets.__nextHomingFrame or 0) then + if runHoming and U.NANO_PARTICLES_HOMING and (U.HOMING_SPREAD or frame >= (deathBuckets.__nextHomingFrame or 0)) then + -- With HOMING_SPREAD both passes run every frame on a 1/HOMING_RUN_EVERY + -- coset of their lists, so each list is still re-aimed every + -- HOMING_RUN_EVERY frames without the whole pass landing on one frame. + if not U.HOMING_SPREAD then + deathBuckets.__nextHomingFrame = frame + HOMING_RUN_EVERY + end tracy.ZoneBeginN("G:NanoParticles:RunFrame:ParticleMaintenance:Homing") - deathBuckets.__nextHomingFrame = frame + HOMING_RUN_EVERY dirtyMin, dirtyMax = applyHoming(frame, dirtyMin, dirtyMax) dirtyMin, dirtyMax = applyForwardHoming(frame, dirtyMin, dirtyMax) tracy.ZoneEnd() @@ -3655,21 +4425,18 @@ function U.updateParticleMaintenance(frame, preUsed, runHoming, runGroundClamp) dirtyMin, dirtyMax = applyGroundClamp(frame, dirtyMin, dirtyMax) tracy.ZoneEnd() end + U.flushLightCorrections() + tracy.ZoneBeginN("G:NanoParticles:RunFrame:ParticleMaintenance:Upload") + -- Rewrites are scattered over the pool: upload them per slot (or as one + -- range when dense). This frame's spawns are a contiguous tail append and + -- go up as one range. + U.flushDirtySlots() local postUsed = nanoVBO.usedElements if preUsed and postUsed > preUsed then - if preUsed < dirtyMin then - dirtyMin = preUsed - end - if postUsed > dirtyMax then - dirtyMax = postUsed - end - end - if dirtyMax > dirtyMin then - tracy.ZoneBeginN("G:NanoParticles:RunFrame:ParticleMaintenance:Upload") - uploadElementRange(nanoVBO, dirtyMin, dirtyMax) - tracy.ZoneEnd() + uploadElementRange(nanoVBO, preUsed, postUsed) end tracy.ZoneEnd() + tracy.ZoneEnd() end -------------------------------------------------------------------------------- @@ -3678,14 +4445,6 @@ end local function scanBuilders(frame, includeMaintenance) tracy.ZoneBeginN("G:NanoParticles:RunFrame:ScanBuilders") - if frame >= (U._nextFactoryGraceCleanupFrame or 0) then - U._nextFactoryGraceCleanupFrame = frame + HOMING_SKIP_GRACE_FRAMES - for targetID, completionFrame in pairs(recentFactoryBuildTargetCache) do - if frame - completionFrame >= HOMING_SKIP_GRACE_FRAMES then - recentFactoryBuildTargetCache[targetID] = nil - end - end - end -- Engine emits nano particles for every active builder regardless of camera -- frustum. Iterate the tracked builder set; LOS filtering happens in emitNano. -- Per-frame epoch bump implicitly invalidates piecePosCache / targetPosCache @@ -3717,15 +4476,32 @@ local function scanBuilders(frame, includeMaintenance) -- Dynamic scan-frame skip: empty pool -> every frame, saturated -> every -- MAX_SCAN_RUN_EVERY frames. emitProb scales by runEvery so total emission -- rate is preserved. - local runEvery = MIN_SCAN_RUN_EVERY + math.floor(saturation * (MAX_SCAN_RUN_EVERY - MIN_SCAN_RUN_EVERY) + 0.5) - if runEvery < 1 then - runEvery = 1 - end local scanTick = (deathBuckets.__scanFrameTick or 0) + 1 deathBuckets.__scanFrameTick = scanTick - if runEvery > 1 and (scanTick % runEvery) ~= 0 then - skipEmit = true + -- Saturation throttle. The pool-fill curve is the pre-stride design's: a + -- per-frame emission divisor of throttleStride * runEvery (1 with room in + -- the pool, MAX_SCAN_STRIDE * MAX_SCAN_RUN_EVERY when full). Instead of + -- skipping whole scans it is folded into the visit stride, so the visits + -- are spread evenly over every sim frame (no scan-frame spikes), and each + -- visit compensates stride / divisor frames of emission (U._emitCompCap), + -- which reproduces that curve exactly. + local runEvery = MIN_SCAN_RUN_EVERY + mathFloor(saturation * (MAX_SCAN_RUN_EVERY - MIN_SCAN_RUN_EVERY) + 0.5) + if runEvery < 1 then + runEvery = 1 end + local throttleStride = 1 + mathFloor(saturation * (MAX_SCAN_STRIDE - 1) + 0.5) + local throttleDiv = throttleStride * runEvery + -- Visit stride: the base stride times the former scan-skip factor, so a + -- builder is visited as rarely as before but the visits land on every + -- frame instead of bunching on scan frames. + local stride = MIN_SCAN_STRIDE * runEvery + U._emitCompCap = stride / throttleDiv + -- Emission accrues per frame since the last visit, capped at three visit + -- intervals so a builder skipped by the saturation early-out never dumps + -- a backlog burst. Zero-yield visits sleep for at most two intervals; the + -- extra interval of accrual covers the coset alignment of the wake-up. + local accrueCap = stride * 3 + local waitCap = stride + stride if not skipEmit then tracy.ZoneBeginN("G:NanoParticles:RunFrame:ScanBuilders:EmitLoop") @@ -3734,12 +4510,6 @@ local function scanBuilders(frame, includeMaintenance) -- DISTANT_EMIT_* squared bands live at module scope. local camX, camY, camZ = Spring.GetCameraPosition() - -- Dynamic stride: empty pool -> 1 (full fidelity), near full -> MAX_SCAN_STRIDE. - -- Per-builder elapsed-frames-based emit count compensates so total rate is constant. - local stride = MIN_SCAN_STRIDE + math.floor(saturation * (MAX_SCAN_STRIDE - MIN_SCAN_STRIDE) + 0.5) - if stride < 1 then - stride = 1 - end -- Pool-saturation-driven offscreen keep-fraction. Recomputed once per scan -- so all per-emit checks below use the same value. @@ -3778,7 +4548,7 @@ local function scanBuilders(frame, includeMaintenance) local i = start + cosetIdx * stride repeat local unitID = list[i] - local info = getBuilderInfo(unitID) + local info = builderCache[unitID] or getBuilderInfo(unitID) if not info then break end @@ -3806,6 +4576,13 @@ local function scanBuilders(frame, includeMaintenance) break end info.idleScanUntil = nil + -- Zero-yield visits are skipped: a visit that could not emit knows + -- from its accumulator how many frames it takes to reach one + -- particle at the current throttle and sleeps until then. + local emitReady = info.emitReadyFrame + if emitReady and frame < emitReady then + break + end -- Cheap idle filter: a builder with no current build power is not -- emitting (walking, queued, blocked, paused, no orders). Skipping -- saves the worker-task lookup, which together with this dominates @@ -3919,6 +4696,17 @@ local function scanBuilders(frame, includeMaintenance) local cmdID, targetID = info.cmdID, info.targetID if bpRefetched or not cmdID then cmdID, targetID = spGetUnitWorkerTask(unitID) + -- On the frame a buildee completes the engine still reports it as + -- this builder's repair target (StopBuild runs on the next + -- UpdateBuild) although AddBuildPower already refused it. Skip the + -- visit rather than spray a stride-compensated batch at the empty + -- pad; a damaged unit keeps its repair target for the next visit. + if cmdID == CMD_REPAIR and targetID then + local ient = targetIncompleteCache[targetID] + if ient and ient[2] == false and ient[3] >= 0 and (frame - ient[3]) <= 1 then + cmdID, targetID = nil, nil + end + end info.cmdID = cmdID info.targetID = targetID end @@ -3929,21 +4717,37 @@ local function scanBuilders(frame, includeMaintenance) local ex, ey, ez, inverse, jitterRadius, isResurrect, targetUnitID local meta = info.targetMeta local coldOffscreen = false - if - meta - and meta.cmdID == cmdID - and meta.targetID == targetID - and meta.coldOffscreenUntil - and frame < meta.coldOffscreenUntil - then + local metaCurrent = meta and meta.cmdID == cmdID and meta.targetID == targetID + if metaCurrent and meta.coldOffscreenUntil and frame < meta.coldOffscreenUntil then coldOffscreen = true inverse = meta.isReclaim and true or false isResurrect = meta.isResurrect targetUnitID = (not meta.isFeature) and meta.resolvedID or nil else - ex, ey, ez, inverse, jitterRadius, isResurrect, targetUnitID = - resolveTarget(info, cmdID, targetID) - meta = info.targetMeta + -- Static targets (features, immobile units) keep their + -- resolved position in emitTargetPosCache until a destroy + -- callin drops it: the most common case needs no resolver + -- call at all. + local tpc = metaCurrent and emitTargetPosCache[targetID] + if tpc and tpc[5] then + ex, ey, ez = tpc[2], tpc[3], tpc[4] + if meta.isReclaim then + inverse = true + elseif meta.isResurrect then + inverse, isResurrect = false, true + else + inverse = false + end + jitterRadius = meta.jitterRadius + targetUnitID = (not meta.isFeature) and meta.resolvedID or nil + else + ex, ey, ez, inverse, jitterRadius, isResurrect, targetUnitID = + resolveTarget(info, cmdID, targetID) + meta = info.targetMeta + if meta and not meta.since then + meta.since = frame + end + end end -- Record this builder as actively reclaiming `targetUnitID` -- so the UnitDestroyed callin can fire a finishing burst @@ -3979,26 +4783,50 @@ local function scanBuilders(frame, includeMaintenance) end -- Keep build-progress fresh so UnitDestroyed can scale -- the burst even though the unit is already dead then. - local isBuilt, bp = spGetUnitIsBeingBuilt(nowReclaiming) - if isBuilt and bp then - reclaimTargetBuildProgress[nowReclaiming] = bp - else - reclaimTargetBuildProgress[nowReclaiming] = nil + -- Polled at HEALTH_CHECK_EVERY per builder: the burst only + -- needs a rough progress value. + local rbpFrame = meta.rbpFrame + if not rbpFrame or (frame - rbpFrame) >= HEALTH_CHECK_EVERY then + meta.rbpFrame = frame + local isBuilt, rbp = spGetUnitIsBeingBuilt(nowReclaiming) + if isBuilt and rbp then + reclaimTargetBuildProgress[nowReclaiming] = rbp + else + reclaimTargetBuildProgress[nowReclaiming] = nil + end end end local emits, resurrectEmits = 0, 0 local feedbackForced = false + local visitFrames = 1 if ex or coldOffscreen then -- Factories always use the engine's fixed 0.15 jitter regardless of buildee size. if info.isFactory then jitterRadius = nil end + -- Frames this visit accounts for. The base scan stride is + -- compensated so the emission rate is independent of it; + -- the saturation-driven scan skip (runEvery) is not, so it + -- keeps acting as the pool throttle (U._emitCompCap). + -- emitNano gives the particles of one visit consecutive + -- future spawn frames, which is what keeps a compensated + -- batch from reading as a blob at the nozzle. + local lastVisit = info.lastVisitFrame + local elapsed = lastVisit and (frame - lastVisit) or 1 + if elapsed < 1 then + elapsed = 1 + elseif elapsed > accrueCap then + elapsed = accrueCap + end + visitFrames = elapsed + if visitFrames > MIN_SCAN_STRIDE then + visitFrames = MIN_SCAN_STRIDE + end info.lastVisitFrame = frame - local elapsed = 1 - local rate = (info.buildSpeed * bp / EMIT_REF_BUILDSPEED) - * elapsed - * (NanoParticlesRate or 1.0) - local accum = (info.emitAccum or 0) + rate + -- Per-frame emission at the current throttle (see the scan header), + -- accrued over the frames since the last visit. + local ratePerFrame = (info.buildSpeed * bp / EMIT_REF_BUILDSPEED) * (NanoParticlesRate or 1.0) / throttleDiv + local accum = (info.emitAccum or 0) + ratePerFrame * elapsed emits = mathFloor(accum) info.emitAccum = accum - emits if emits == 0 and bp > 0 then @@ -4011,6 +4839,16 @@ local function scanBuilders(frame, includeMaintenance) end if emits > 0 then info.lastEmitFrame = frame + info.emitReadyFrame = nil + elseif ratePerFrame > 0 then + -- Sleep until the accumulator can reach one particle. + local wait = mathCeil((1 - info.emitAccum) / ratePerFrame) + if wait > waitCap then + wait = waitCap + end + if wait > 1 then + info.emitReadyFrame = frame + wait + end end if isResurrect then resurrectEmits = takeScaledEmitCount( @@ -4142,19 +4980,11 @@ local function scanBuilders(frame, includeMaintenance) if DEBUG then _dbgEmits = _dbgEmits + 1 end - local elapsed = 1 - -- Spread window (half-width in frames) for the in-batch - -- stagger inside emitNano. Particles end up in - -- [-spreadWindow, +spreadWindow] frames of velocity - -- around the nanopiece -- a few slightly behind (model - -- occlusion) and a few slightly ahead. Hard-capped at - -- MAX_SPREAD_AHEAD_FRAMES so the cluster stays close to - -- the source, never partway to the target. Direction - -- jitter already provides lateral spread -- this just - -- breaks the on-axis pile-up of a multi-particle batch. - -- Count compensation still uses full `elapsed`, so total - -- emission rate is preserved. - local spreadWindow = math.min(MAX_SPREAD_AHEAD_FRAMES, elapsed) + -- Spawn frames of this visit's particles are spread over + -- the frames the visit accounts for (see emitNano), so a + -- stride-compensated batch streams out of the nozzle one + -- particle per frame instead of appearing at once. + local spreadWindow = visitFrames local catchupAgeFrames = 0 if meta and meta.virtualEmits and meta.virtualEmits > 0 then resurrectEmits = resurrectEmits + meta.virtualEmits @@ -4164,21 +4994,40 @@ local function scanBuilders(frame, includeMaintenance) meta.virtualFrame = frame end if resurrectEmits > 0 then - emitNanoBatch( - unitID, - info, - ex, - ey, - ez, - inverse, - jitterRadius, - frame, - targetUnitID, - isResurrect, - resurrectEmits, - spreadWindow, - catchupAgeFrames - ) + if info.nPieces == 1 and not isResurrect then + -- Single nozzle, single leg: skip the batch dispatcher. + emitNano( + unitID, + info, + ex, + ey, + ez, + inverse, + jitterRadius, + frame, + targetUnitID, + info.pieces[1], + resurrectEmits, + spreadWindow, + catchupAgeFrames + ) + else + emitNanoBatch( + unitID, + info, + ex, + ey, + ez, + inverse, + jitterRadius, + frame, + targetUnitID, + isResurrect, + resurrectEmits, + spreadWindow, + catchupAgeFrames + ) + end end end elseif info.targetMeta then @@ -4200,6 +5049,7 @@ local function scanBuilders(frame, includeMaintenance) end until true end + U.flushLightBatch() tracy.ZoneEnd() end -- if not skipEmit @@ -4230,46 +5080,61 @@ local function cullDead(frame) local nl = deathBuckets.__nanoLight local lightActive = nl and nl.activeCount and nl.activeCount > 0 and nl.active local lightIDs = nl and nl.ids - local canRemove = lightActive and Script.LuaUI("EnvNanoBallisticLightRemove") + local canRemove = lightActive and nl.bridgeReady + local removeBatch = U._lightRemoveBatch + local removeN = 0 + local vbo = nanoVBO + local data, idToIndex, indexToID, step, gpu + if vbo then + data = vbo.instanceData + idToIndex = vbo.instanceIDtoIndex + indexToID = vbo.indextoInstanceID + step = vbo.instanceStep + gpu = vbo.instanceVBO + end for deathFrame = oldest, frame do local bucket = deathBuckets[deathFrame] if bucket then local nb = #bucket - if not nanoVBO then - if lightActive then - for i = 1, nb do - local id = bucket[i] - if lightActive[id] then - lightActive[id] = nil - nl.activeCount = nl.activeCount - 1 - local lightID = lightIDs and lightIDs[id] - if canRemove and lightID then - Script.LuaUI.EnvNanoBallisticLightRemove(lightID) - end - if lightIDs then - lightIDs[id] = nil + for i = 1, nb do + local id = bucket[i] + if vbo then + -- Inline swap-with-last pop: InstanceVBOTable.popElementInstance + -- minus the unitID / zombie bookkeeping this table never uses. + -- One ~64B upload per moved element; batching the dirty range + -- was measured far slower here because a range upload marshals + -- every element in between. + local slot = idToIndex[id] + if slot then + local used = vbo.usedElements + idToIndex[id] = nil + if slot == used then + indexToID[used] = nil + else + local lastID = indexToID[used] + local dst = (slot - 1) * step + local src = (used - 1) * step + for k = 1, step do + data[dst + k] = data[src + k] end + idToIndex[lastID] = slot + indexToID[slot] = lastID + indexToID[used] = nil + gpu:Upload(data, nil, slot - 1, dst + 1, dst + step) end + vbo.usedElements = used - 1 end end - else - -- Per-pop upload (~64B/swap). Tried batching with one uploadElementRange - -- at the end and cull jumped from ~2-4ms to ~15-18ms in factory-heavy - -- scenes -- per-element marshalling cost in uploadElementRange dominates - -- the GL submit savings when slots are scattered. - for i = 1, nb do - local id = bucket[i] - popElementInstance(nanoVBO, id, false) - if lightActive and lightActive[id] then - lightActive[id] = nil - nl.activeCount = nl.activeCount - 1 - local lightID = lightIDs and lightIDs[id] - if canRemove and lightID then - Script.LuaUI.EnvNanoBallisticLightRemove(lightID) - end - if lightIDs then - lightIDs[id] = nil - end + if lightActive and lightActive[id] then + lightActive[id] = nil + nl.activeCount = nl.activeCount - 1 + local lightID = lightIDs and lightIDs[id] + if canRemove and lightID then + removeN = removeN + 1 + removeBatch[removeN] = lightID + end + if lightIDs then + lightIDs[id] = nil end end end @@ -4279,6 +5144,9 @@ local function cullDead(frame) U.releaseDeathBucket(bucket) end end + if removeN > 0 then + U.flushLightRemovals(removeN) + end local latest = deathBuckets.__latestFrame if latest and latest > frame then oldest = frame + 1 @@ -4511,9 +5379,9 @@ function gadget:Update() nl = { activeCount = 0, active = {}, ids = {} } deathBuckets.__nanoLight = nl end - nl.enabled = (Spring.GetConfigInt("NanoParticlesUpdateLuaUI", 0) == 1) + nl.enabled = (Spring.GetConfigInt("NanoParticlesUpdateLuaUI", 1) == 1) if nl.enabled then - nl.spawnRadius = 33 + nl.spawnRadius = 35 nl.alpha = 0.05 nl.sampleRate = 0.25 nl.maxSpawnsPerFrame = 48 @@ -4526,6 +5394,9 @@ function gadget:Update() nl.bridgeReady = Script.LuaUI("EnvNanoBallisticLightSpawn") and Script.LuaUI("EnvNanoBallisticLightCorrect") and Script.LuaUI("EnvNanoBallisticLightRemove") + nl.batchReady = nl.bridgeReady and Script.LuaUI("EnvNanoBallisticLightSpawnBatch") + nl.correctBatchReady = nl.bridgeReady and Script.LuaUI("EnvNanoBallisticLightCorrectBatch") + nl.removeBatchReady = nl.bridgeReady and Script.LuaUI("EnvNanoBallisticLightRemoveBatch") nl.fadeReady = Script.LuaUI("EnvNanoBallisticLightFade") else if nl.activeCount > 0 then @@ -4541,6 +5412,9 @@ function gadget:Update() nl.activeCount = 0 end nl.bridgeReady = false + nl.batchReady = false + nl.correctBatchReady = false + nl.removeBatchReady = false nl.fadeReady = false end @@ -4763,15 +5637,17 @@ function gadget:UnitCreated(unitID, unitDefID) end function gadget:UnitFinished(unitID, unitDefID) - -- Construction completed: fade trailing build-spray particles instead of - -- letting them coast into the now-finished unit and pop on natural death. - fadeOutHomingFwd(unitID) + -- Construction completed. Forward-homing spray always fades out. The + -- fade-only build spray (fadeFwdByTarget) is included for a mobile unit: + -- it is rolling off a factory pad, and from a far nano turret that spray + -- is still several seconds in flight and would keep landing on the empty + -- pad. A finished structure lets its last particles land as before. + fadeOutHomingFwd(unitID, isMobileUnitDef[unitDefID] == true) U.recycleTrackList(homingFwdByTarget[unitID]) U.recycleTrackList(fadeFwdByTarget[unitID]) homingFwdByTarget[unitID] = nil fadeFwdByTarget[unitID] = nil targetPosCache[unitID] = nil - local completedAtFactory = false -- Keep a completion timestamp so HOMING_SKIP_GRACE_FRAMES still applies -- after UnitFinished; clearing this here made fresh emissions immediately -- re-enter forward homing and chase units as they roll out of factories. @@ -4785,17 +5661,11 @@ function gadget:UnitFinished(unitID, unitDefID) local bid = trackedBuildersList[i] local info = builderCache[bid] if info and info.targetID == unitID then - if info.isFactory then - completedAtFactory = true - end info.cmdID = nil info.targetID = nil info.targetMeta = nil end end - if completedAtFactory then - recentFactoryBuildTargetCache[unitID] = Spring.GetGameFrame() - end trackUnit(unitID, unitDefID) end @@ -4831,62 +5701,106 @@ end -- window so the particles dissolve rather than snapping out. The builder is -- already dead so applyHoming will nil the list on the next pass; we only need -- to touch the VBO data here. -local function fadeOutHomingInverse(builderID) - if not nanoVBO then - return - end - local list = homingByBuilder[builderID] +-- Death-fade every live particle of a tracking-record list: shorten deathFrame +-- and bake a per-particle fade window into spawnPosAndSize.w so the shader's +-- end-of-life alpha ramp dissolves the spray instead of snapping it off. +-- Velocity and spawn are left untouched so the particles keep coasting along +-- their last trajectory. Slot reclamation still happens at the original death +-- frame (deathBuckets is untouched); the shader renders nothing in the gap. +-- Particles a homing pass already faded are skipped: re-fading would restart +-- their alpha ramp. Returns the accumulated 0-based dirty slot range. +-- Death-fade every live particle of a tracking-record list: shorten deathFrame +-- and bake a per-particle fade window into spawnPosAndSize.w so the shader's +-- end-of-life alpha ramp dissolves the spray instead of snapping it off. +-- Velocity and spawn are left untouched so the particles keep coasting along +-- their last trajectory. Slot reclamation still happens at the original death +-- frame (deathBuckets is untouched); the shader renders nothing in the gap. +-- Particles a homing pass already faded are skipped: re-fading would restart +-- their alpha ramp. Returns the accumulated 0-based dirty slot range. +-- Death-fade every live particle of a tracking-record list: shorten deathFrame +-- and bake a per-particle fade window into spawnPosAndSize.w so the shader's +-- end-of-life alpha ramp dissolves the spray instead of snapping it off. +-- Velocity and spawn are left untouched so the particles keep coasting along +-- their last trajectory. Slot reclamation still happens at the original death +-- frame (deathBuckets is untouched); the shader renders nothing in the gap. +-- Particles a homing pass already faded are skipped: re-fading would restart +-- their alpha ramp. Returns the accumulated 0-based dirty slot range. +local function fadeTrackedRecords(list, frame, dirtyMin, dirtyMax) if not list then - return + return dirtyMin, dirtyMax end local data = nanoVBO.instanceData local idtoIndex = nanoVBO.instanceIDtoIndex local step = nanoVBO.instanceStep - local frame = Spring.GetGameFrame() - local dirtyMin, dirtyMax = math.huge, -1 - for i = 1, #list do - local p = list[i] - local slot = idtoIndex[p.id] - if slot then - local remaining = p.death - frame - if remaining > 0 then - local fadeFrames = mathFloor(FADE_FRAMES_DEATH * (0.4 + mathRandom())) - if fadeFrames < 1 then - fadeFrames = 1 - end - if fadeFrames > remaining then - fadeFrames = remaining - end - local newDeath = frame + fadeFrames - local base = (slot - 1) * step - data[base + 16] = newDeath - local packed = data[base + 4] - local absPacked = packed < 0 and -packed or packed - local oldFade = mathFloor(absPacked / 1024) - local sizeBits = absPacked - oldFade * 1024 - local newPacked = sizeBits + fadeFrames * 1024 - data[base + 4] = packed < 0 and -newPacked or newPacked - local s0 = slot - 1 - if s0 < dirtyMin then - dirtyMin = s0 - end - if s0 + 1 > dirtyMax then - dirtyMax = s0 + 1 + local dirtySlots = U._dirtySlots + local dn = U._dirtySlotN + for ri = 1, #list do + local rec = list[ri] + if frame < rec.d then + local faded = rec.faded + for id = rec.f, rec.f + rec.n - 1 do + local slot = idtoIndex[id] + if slot and not (faded and faded[id]) then + local base = (slot - 1) * step + local remaining = data[base + 16] - frame + if remaining > 0 then + -- Per-particle fade duration: FADE_FRAMES_DEATH * (0.4..1.4), + -- staggered so particles don't all wink out on the same + -- frame, and never longer than the remaining life. + local fadeFrames = mathFloor(FADE_FRAMES_DEATH * (0.4 + mathRandom())) + if fadeFrames < 1 then + fadeFrames = 1 + end + if fadeFrames > remaining then + fadeFrames = remaining + end + data[base + 16] = frame + fadeFrames + -- w is packed sizeMult + fadeFrames*1024, negative for + -- inverse (reclaim) particles: keep the size bits and the + -- sign, replace only the fade window. + local packed = data[base + 4] + local absPacked = packed < 0 and -packed or packed + local oldFade = mathFloor(absPacked / 1024) + local sizeBits = absPacked - oldFade * 1024 + local newPacked = sizeBits + fadeFrames * 1024 + data[base + 4] = packed < 0 and -newPacked or newPacked + local s0 = slot - 1 + if s0 < dirtyMin then + dirtyMin = s0 + end + if s0 + 1 > dirtyMax then + dirtyMax = s0 + 1 + end + dn = dn + 1 + dirtySlots[dn] = slot + fadeNanoDeferredLight(id, frame, fadeFrames) + end end - fadeNanoDeferredLight(p.id, frame, fadeFrames) end end end - if dirtyMax > dirtyMin then - uploadElementRange(nanoVBO, dirtyMin, dirtyMax) + U._dirtySlotN = dn + return dirtyMin, dirtyMax +end + +-- Fade out inverse-homing (reclaim) particles travelling toward a builder that +-- just died. The builder is already dead so applyHoming will nil the list on +-- the next pass; we only need to touch the VBO data here. +local function fadeOutHomingInverse(builderID) + if not nanoVBO then + return end + local list = homingByBuilder[builderID] + if not list then + return + end + local dirtyMin, dirtyMax = fadeTrackedRecords(list, Spring.GetGameFrame(), math.huge, -1) + U.flushDirtySlots() end --- Fade out forward-homing particles aimed at a unit that just died: shorten --- their deathFrame so the shader's end-of-life alpha ramp kicks in. Velocity --- and spawn are left untouched so they keep coasting along their last --- trajectory while fading out. Slot reclamation still happens at the original --- death frame (deathBuckets is untouched); the shader renders nothing in the gap. +-- Fade out forward-homing particles aimed at a unit that just died or was +-- completed. includeSkipList also fades the fade-only list of particles that +-- were aimed at the unit while it was still under construction. fadeOutHomingFwd = function(unitID, includeSkipList) if not nanoVBO then return @@ -4896,67 +5810,36 @@ fadeOutHomingFwd = function(unitID, includeSkipList) if not list and not flist then return end - local data = nanoVBO.instanceData - local idtoIndex = nanoVBO.instanceIDtoIndex - local step = nanoVBO.instanceStep local frame = Spring.GetGameFrame() - -- Per-particle fade duration: FADE_FRAMES_DEATH * (0.4..1.6). Staggers the - -- dissolve so particles don't all wink out on the same frame. - local dirtyMin, dirtyMax = math.huge, -1 - local function fadeList(plist) - if not plist then - return - end - for i = 1, #plist do - local p = plist[i] - local slot = idtoIndex[p.id] - if slot then - local remaining = p.death - frame - if remaining > 0 then - local fadeFrames = mathFloor(FADE_FRAMES_DEATH * (0.4 + mathRandom())) - if fadeFrames < 1 then - fadeFrames = 1 - end - -- Clamp to remaining lifetime: never extend a particle's life, - -- only shorten/replace it. - if fadeFrames > remaining then - fadeFrames = remaining - end - local newDeath = frame + fadeFrames - local base = (slot - 1) * step - data[base + 16] = newDeath - -- Force per-particle fade window so reclaim-style (fadeFrames=0) - -- particles also dissolve. w is packed: preserve sizeMult bits, - -- replace only the fadeFrames portion. - -- NOTE: inverse (reclaim) particles store a negative value; use - -- abs before bit-manipulation and restore the sign afterward. - local packed = data[base + 4] - local absPacked = packed < 0 and -packed or packed - local oldFade = mathFloor(absPacked / 1024) - local sizeBits = absPacked - oldFade * 1024 - local newPacked = sizeBits + fadeFrames * 1024 - data[base + 4] = packed < 0 and -newPacked or newPacked - local s0 = slot - 1 - if s0 < dirtyMin then - dirtyMin = s0 - end - if s0 + 1 > dirtyMax then - dirtyMax = s0 + 1 - end - fadeNanoDeferredLight(p.id, frame, fadeFrames) - end - end - end - end - fadeList(list) - fadeList(flist) - if dirtyMax > dirtyMin then - uploadElementRange(nanoVBO, dirtyMin, dirtyMax) - end + local dirtyMin, dirtyMax = fadeTrackedRecords(list, frame, math.huge, -1) + dirtyMin, dirtyMax = fadeTrackedRecords(flist, frame, dirtyMin, dirtyMax) + U.flushDirtySlots() end +-- Fade out inverse-homing (reclaim) particles travelling toward a builder that +-- just died. The builder is already dead so applyHoming will nil the list on +-- the next pass; we only need to touch the VBO data here. + +-- Fade out forward-homing particles aimed at a unit that just died or was +-- completed. includeSkipList also fades the fade-only list of particles that +-- were aimed at the unit while it was still under construction. + +-- Fade out inverse-homing (reclaim) particles travelling toward a builder that +-- just died. The builder is already dead so applyHoming will nil the list on +-- the next pass; we only need to touch the VBO data here. + +-- Fade out forward-homing particles aimed at a unit that just died or was +-- completed. includeSkipList also fades the fade-only list of particles that +-- were aimed at the unit while it was still under construction. + +-- Fade out forward-homing particles aimed at a unit that just died: shorten +-- their deathFrame so the shader's end-of-life alpha ramp kicks in. Velocity +-- and spawn are left untouched so they keep coasting along their last +-- trajectory while fading out. Slot reclamation still happens at the original +-- death frame (deathBuckets is untouched); the shader renders nothing in the gap. + U.refreshFeatureToggles = function() - local homingEnabled = Spring.GetConfigInt("NanoParticlesHoming", 0) ~= 0 + local homingEnabled = Spring.GetConfigInt("NanoParticlesHoming", 1) ~= 0 if homingEnabled ~= U.NANO_PARTICLES_HOMING then U.NANO_PARTICLES_HOMING = homingEnabled if not homingEnabled then @@ -4971,7 +5854,7 @@ U.refreshFeatureToggles = function() end end - local groundClampEnabled = Spring.GetConfigInt("NanoParticlesGroundClamp", 0) ~= 0 + local groundClampEnabled = Spring.GetConfigInt("NanoParticlesGroundClamp", 1) ~= 0 if groundClampEnabled ~= U.GROUND_CLAMP_ENABLED then U.GROUND_CLAMP_ENABLED = groundClampEnabled if not groundClampEnabled then @@ -4996,7 +5879,7 @@ U.refreshFeatureToggles = function() end end - local reclaimBurstEnabled = Spring.GetConfigInt("NanoParticlesReclaimBurst", 0) ~= 0 + local reclaimBurstEnabled = Spring.GetConfigInt("NanoParticlesReclaimBurst", 1) ~= 0 if reclaimBurstEnabled ~= U.NANO_PARTICLES_RECLAIM_BURST then U.NANO_PARTICLES_RECLAIM_BURST = reclaimBurstEnabled if not reclaimBurstEnabled then @@ -5017,7 +5900,6 @@ end function gadget:UnitDestroyed(unitID, unitDefID, unitTeam, attackerID, attackerDefID, attackerTeam, weaponDefID) emitTargetPosCache[unitID] = nil - recentFactoryBuildTargetCache[unitID] = nil -- Reclaim-completion burst: in unsynced UnitDestroyed, when a unit is -- removed by reclaim the engine populates attacker* with the reclaiming -- builder (it's the agent that "killed" the unit, with no weaponDefID). @@ -5063,6 +5945,7 @@ end function gadget:RenderUnitDestroyed(unitID) -- RenderUnitDestroyed has no attacker arg; rely on whatever UnitDestroyed -- already decided. If the burst ran (or skipped), the entry is gone. + emitTargetPosCache[unitID] = nil reclaimedTargets[unitID] = nil reclaimTargetBuildProgress[unitID] = nil fadeOutHomingFwd(unitID, true) diff --git a/luarules/gadgets/gfx_plasma_cannon_gl4.lua b/luarules/gadgets/gfx_plasma_cannon_gl4.lua index dbec4b0ccc3..542c40f1dde 100644 --- a/luarules/gadgets/gfx_plasma_cannon_gl4.lua +++ b/luarules/gadgets/gfx_plasma_cannon_gl4.lua @@ -112,6 +112,22 @@ local shaderConfig = { -------------------------------------------------------------------------------- local weaponConfigs = {} +-- Cannon weapons that cannot damage anything are gadget triggers (drone carrier spawners, energy +-- chargers, ...) rather than plasma shells, so they must not get a plasma projectile drawn. +local numArmorTypes = #Game.armorTypes +local function weaponDealsDamage(weaponDef) + local damages = weaponDef.damages + if not damages then + return false + end + for armorTypeID = 0, numArmorTypes do + if (damages[armorTypeID] or 0) > 0 then + return true + end + end + return false +end + for weaponID, weaponDef in pairs(WeaponDefs) do local vis = weaponDef.visuals or {} if weaponDef.type == "Cannon" and not weaponDef.model and (not vis.modelName or vis.modelName == "") then @@ -124,7 +140,7 @@ for weaponID, weaponDef in pairs(WeaponDefs) do local coreB = mathMin(1, b + CORE_COLOR_ADD) local cp = weaponDef.customParams or {} - if not cp.bogus then + if not cp.bogus and weaponDealsDamage(weaponDef) then local size = tonumber(cp.plasma_size_orig) or weaponDef.size or 1.5 local range = weaponDef.range or 300 diff --git a/luarules/gadgets/gfx_raptor_scum_gl4.lua b/luarules/gadgets/gfx_raptor_scum_gl4.lua index 94aa49d3fcd..90ef6ec1f88 100644 --- a/luarules/gadgets/gfx_raptor_scum_gl4.lua +++ b/luarules/gadgets/gfx_raptor_scum_gl4.lua @@ -31,8 +31,6 @@ if gadgetHandler:IsSyncedCode() then local sqrt = math.sqrt local floor = math.floor - local max = math.max - local min = math.min local clamp = math.clamp local spGetGroundHeight = Spring.GetGroundHeight local spGetGameFrame = Spring.GetGameFrame @@ -1002,7 +1000,6 @@ elseif not BAR.Utilities.Gametype.IsScavengers() then -- UNSYNCED end lastSunChanged = df if GG.NightFactor then - local altitudefactor = 1.0 --+ (1.0 - WG['NightFactor'].altitude) * 0.5 nightFactor[1] = GG.NightFactor.red nightFactor[2] = GG.NightFactor.green nightFactor[3] = GG.NightFactor.blue diff --git a/luarules/gadgets/gfx_tree_feller.lua b/luarules/gadgets/gfx_tree_feller.lua index 26743dc904e..d393609c254 100644 --- a/luarules/gadgets/gfx_tree_feller.lua +++ b/luarules/gadgets/gfx_tree_feller.lua @@ -549,7 +549,7 @@ if gadgetHandler:IsSyncedCode() then --if crushed, attackerID returns unit, but projectileID is nil, if projectile destroys feature, then attackerID is nil, but projectileID contains the projectile. --Echo('tree dying...',featureID) local dx, dy, dz, rx, ry, rz = GetFeatureDirection(featureID) - SetFeatureBlocking(featureID, false, false, false, false, false, false, false) --doesnt block anything + SetFeatureBlocking(featureID, false, false, false, false, false, false, false) --doesn't block anything if weaponDefID == -7 then --weapon is crush --crushed features cannot be saved by returning 0 damage. Must create new one! diff --git a/luarules/gadgets/gfx_unit_script_decals.lua b/luarules/gadgets/gfx_unit_script_decals.lua index 9e85ef1b989..b990f08bb16 100644 --- a/luarules/gadgets/gfx_unit_script_decals.lua +++ b/luarules/gadgets/gfx_unit_script_decals.lua @@ -40,8 +40,6 @@ else -- UNSYNCED end end - local scriptUnitScriptDecal = Script.LuaUI.UnitScriptDecal - local function UnitScriptDecal(_, unitID, unitDefID, lightIndex, posx, posz, heading) if not fullview and not spIsUnitInLos(unitID, myAllyTeamID) then return diff --git a/luarules/gadgets/gfx_unit_script_lights.lua b/luarules/gadgets/gfx_unit_script_lights.lua index 32ca22203fe..2683ee4d81a 100644 --- a/luarules/gadgets/gfx_unit_script_lights.lua +++ b/luarules/gadgets/gfx_unit_script_lights.lua @@ -47,8 +47,6 @@ else -- UNSYNCED end end - local scriptUnitScriptLight = Script.LuaUI.UnitScriptLight - local function UnitScriptLight(_, unitID, unitDefID, lightIndex, param) if not fullview and not spIsUnitInLos(unitID, myAllyTeamID) then return @@ -59,8 +57,6 @@ else -- UNSYNCED end end - local scriptUnitScriptDistortion = Script.LuaUI.UnitScriptDistortion - local function UnitScriptDistortion(_, unitID, unitDefID, lightIndex, param) if not fullview and not spIsUnitInLos(unitID, myAllyTeamID) then return diff --git a/luarules/gadgets/gfx_unit_shield_effects.lua b/luarules/gadgets/gfx_unit_shield_effects.lua index c1573b4aa53..2cebff44ef6 100644 --- a/luarules/gadgets/gfx_unit_shield_effects.lua +++ b/luarules/gadgets/gfx_unit_shield_effects.lua @@ -177,19 +177,25 @@ local spIsSphereInView = Spring.IsSphereInView local spGetUnitRotation = Spring.GetUnitRotation local spGetUnitShieldState = Spring.GetUnitShieldState local spGetUnitIsStunned = Spring.GetUnitIsStunned -local spGetGameFrame = Spring.GetGameFrame -local spGetFrameTimeOffset = Spring.GetFrameTimeOffset local spGetCameraPosition = Spring.GetCameraPosition +local floor = math.floor +local ceil = math.ceil +local huge = math.huge + +local tracyZoneBeginN = (tracy and tracy.ZoneBeginN) or function() end +local tracyZoneEnd = (tracy and tracy.ZoneEnd) or function() end + local IterableMap = VFS.Include("LuaRules/Gadgets/Include/IterableMap.lua") ----------------------------------------------------------------- -- Shield rendering constants ----------------------------------------------------------------- -local MAX_POINTS = 24 +local MAX_POINTS = 24 -- max impact points per shield local LOS_UPDATE_PERIOD = 10 local HIT_UPDATE_PERIOD = 2 +local STUNNED_CHECK_PERIOD = 40 -- draw frames between stunned polls -- Fade-in/out when shield turns on or depletes (in 1/SHIELD_FADE_FRAMES per draw frame) local SHIELD_FADE_FRAMES = 120 @@ -205,6 +211,29 @@ local OVERLAP_FALLOFF_BEHIND = 0.9 -- per neighbour that is in front of this one local OVERLAP_MIN_SCALE = 0.6 -- absolute floor so shields never fully vanish local OVERLAP_LERP_RATE = 0.18 -- per-frame smoothing toward target scalar +-- Overlap targets are recomputed for 1/OVERLAP_UPDATE_DIVISOR of the visible +-- shields each draw frame (the lerp above still runs every frame). +local OVERLAP_UPDATE_DIVISOR = 2 + +-- Spatial hashes used by the overlap pass, one per shield size class so that +-- a few huge shields don't force a coarse grid onto the many small ones. The +-- cell size follows the largest radius seen in that class (2 x radius, so a +-- shield only needs to look at its 3x3 neighbourhood); the search range is +-- always derived exactly from the radii, the cell size is only a heuristic. +local OVERLAP_KEY_MUL = 8192 +local OVERLAP_CELL_MIN = { small = 256, large = 1024 } +local OVERLAP_CELL_MAX = 4096 + +-- Instanced rendering layout (must match ShieldSphereColorGL4.vert.glsl) +local INSTANCE_STRIDE = 20 -- floats per instance: 5 x vec4 +local INSTANCE_CAPACITY_INITIAL = 256 -- instances per geometry size, grows on demand +local IMPACT_SSBO_BINDING = 6 +local IMPACT_ELEMENT_FLOATS = 16 -- one SSBO element = 4 x vec4 (engine quirk, see CreateImpactBuffer) +local IMPACT_CAPACITY_INITIAL = 512 -- elements (4 impact points each), grows on demand +local GEOMETRY_SUBDIVISIONS = { small = 4, large = 5 } + +local VIS_STRIDE = 6 -- visible-shield scratch layout: unitID, unitData, x, y, z, radius + ----------------------------------------------------------------- -- Shield rendering state ----------------------------------------------------------------- @@ -217,27 +246,32 @@ local shieldUnits = IterableMap.New() -- Rendering state local shieldShader -local geometryLists = {} -local renderBuckets = {} +local geometry = {} -- size name -> { vertexVBO, vertexCount, instanceVBO, vao, capacity, data, count } +local impactSSBO +local impactCapacity = 0 -- in SSBO elements +local impactData = {} -- flat float scratch, reused every frame +local impactFloatCount = 0 local canOutline -local haveTerrainOutline -local haveUnitsOutline -local checkStunned = true local checkStunnedTime = 0 --- Shader uniforms cache -local impactInfoStringTable = {} -local impactInfoUniformCache = {} -for i = 1, MAX_POINTS + 1 do - impactInfoStringTable[i - 1] = string.format("impactInfo.impactInfoArray[%d]", i - 1) -end +-- Per-frame scratch, reused to avoid allocations +local visScratch = {} +local visCount = 0 +local overlapPhase = 0 --- Cached uniform locations (set after shader initialization) -local uTranslationScale, uRotMargin, uEffects, uColor1, uColor2, uImpactCount, uShieldFade, uOverlapScale +local function NewOverlapGrid(sizeName) + return { + cells = {}, -- cell key -> { [0] = count, [1..count] = visible-shield index } + usedKeys = {}, + usedCount = 0, + maxRadius = 0, -- largest radius inserted this frame + cellInv = 1 / OVERLAP_CELL_MIN[sizeName], + cellMin = OVERLAP_CELL_MIN[sizeName], + } +end --- Scratch buffer reused every frame for the overlap pass to avoid allocations. -local overlapScratch = {} -local overlapScratchN = 0 +local overlapGrids = { small = NewOverlapGrid("small"), large = NewOverlapGrid("large") } +local overlapGridList = { overlapGrids.small, overlapGrids.large } local function UpdateVisibility(unitID, unitData, fullview, forceUpdate) -- A shield should render if the player can actually perceive any part of it: @@ -309,6 +343,30 @@ local function UpdateVisibility(unitID, unitData, fullview, forceUpdate) end end +-- Teams run by the Scavengers AI; shields they own get the purple palette. +local scavengerTeams = {} +for _, teamID in ipairs(Spring.GetTeamList()) do + local luaAI = Spring.GetTeamLuaAI(teamID) + if luaAI and string.find(luaAI, "Scavenger", 1, true) then + scavengerTeams[teamID] = true + end +end + +-- Selects the normal or scavenger colours for a unit from its owning team. +local function ApplyTeamPalette(unitData, teamID) + local info = unitData.shieldInfo + local config = shieldUnitDefs[unitData.unitDefID].config + if scavengerTeams[teamID] then + info.scavenger = true + info.colormap1 = config.scavColormap1 + info.colormap2 = config.scavColormap2 + else + info.scavenger = false + info.colormap1 = config.colormap1 + info.colormap2 = config.colormap2 + end +end + local function AddUnit(unitID, unitDefID) local def = shieldUnitDefs[unitDefID] if not def then @@ -328,6 +386,7 @@ local function AddUnit(unitID, unitDefID) shieldInfo.stunned = false shieldInfo.fadeAlpha = 0.0 shieldInfo.overlapScale = 1.0 + shieldInfo.overlapTarget = 1.0 local unitData = { unitDefID = unitDefID, @@ -336,6 +395,8 @@ local function AddUnit(unitID, unitDefID) radius = def.shieldRadius, shieldInfo = shieldInfo, allyTeamID = Spring.GetUnitAllyTeam(unitID), + immobile = def.immobile, -- buildings never rotate: yaw is fetched once + yaw = nil, } if highEnoughQuality then @@ -344,6 +405,8 @@ local function AddUnit(unitID, unitDefID) unitData.needsUpdate = false end + ApplyTeamPalette(unitData, Spring.GetUnitTeam(unitID)) + IterableMap.Add(shieldUnits, unitID, unitData) local _, fullview = spGetSpectatingState() @@ -495,12 +558,10 @@ end -- Geometry generation functions ----------------------------------------------------------------- -local function DrawIcosahedron(subd, cw) +-- Builds a subdivided icosahedron as a flat triangle list (x, y, z per vertex) +-- on the unit sphere. Returns the float table and the vertex count. +local function BuildIcosahedronVertices(subd) local sqrt = math.sqrt - local sin = math.sin - local cos = math.cos - local atan2 = math.atan2 - local acos = math.acos local function normalize(vertex) local r = sqrt(vertex[1] * vertex[1] + vertex[2] * vertex[2] + vertex[3] * vertex[3]) @@ -526,12 +587,6 @@ local function DrawIcosahedron(subd, cw) } end - local function GetSphericalUV(f) - local u = atan2(f[3], f[1]) / math.pi -- [-0.5 <--> 0.5] - local v = acos(f[2]) / math.pi --[0 <--> 1] - return u * 0.5 + 0.5, 1.0 - v - end - -------------------------------------------- local X = 1 @@ -552,8 +607,8 @@ local function DrawIcosahedron(subd, cw) { -Z, -X, 0.0 }, } - for _, vert in ipairs(vertexes0) do - vert = normalize(vert) + for i = 1, #vertexes0 do + normalize(vertexes0[i]) end local fi0 = { @@ -579,53 +634,66 @@ local function DrawIcosahedron(subd, cw) { 8, 3, 12 }, } - if cw then -- re-wind to clockwise order - for i = 1, #fi0 do - fi0[i][2], fi0[i][3] = fi0[i][3], fi0[i][2] - end - end - - local faces0 = {} + local faces = {} for i = 1, #fi0 do - faces0[i] = { vertexes0[fi0[i][1]], vertexes0[fi0[i][2]], vertexes0[fi0[i][3]] } + faces[i] = { vertexes0[fi0[i][1]], vertexes0[fi0[i][2]], vertexes0[fi0[i][3]] } end - local faces = faces0 - subd = subd or 1 - for s = 2, subd do + for _ = 2, subd do local newfaces = {} for fii = 1, #faces do local newsub = subdivide(faces[fii][1], faces[fii][2], faces[fii][3]) - for _, tri in ipairs(newsub) do - table.insert(newfaces, tri) + for k = 1, #newsub do + newfaces[#newfaces + 1] = newsub[k] end end faces = newfaces end - gl.BeginEnd(GL.TRIANGLES, function() - for _, face in ipairs(faces) do - gl.TexCoord(GetSphericalUV(face[1])) - gl.Normal(face[1][1], face[1][2], face[1][3]) - gl.Vertex(face[1][1], face[1][2], face[1][3]) - - gl.TexCoord(GetSphericalUV(face[2])) - gl.Normal(face[2][1], face[2][2], face[2][3]) - gl.Vertex(face[2][1], face[2][2], face[2][3]) - - gl.TexCoord(GetSphericalUV(face[3])) - gl.Normal(face[3][1], face[3][2], face[3][3]) - gl.Vertex(face[3][1], face[3][2], face[3][3]) + local verts = {} + local n = 0 + for i = 1, #faces do + local face = faces[i] + -- The base icosahedron is wound clockwise seen from outside; emit each + -- triangle reversed so the outside is the GL front face (CCW), which the + -- fragment shader relies on to tell the near hemisphere from the far one. + for k = 3, 1, -1 do + local v = face[k] + verts[n + 1] = v[1] + verts[n + 2] = v[2] + verts[n + 3] = v[3] + n = n + 3 end - end) + end + + return verts, #faces * 3 end ----------------------------------------------------------------- -- Shield configuration ----------------------------------------------------------------- +-- Lua limitations only allow to send 24 bits. Should be enough :) +local function EncodeBitmaskField(bitmask, option, position) + return math.bit_or(bitmask, ((option and 1) or 0) * math.floor(2 ^ position)) +end + +local function EncodeEffects(config, outline) + local effects = 0 + effects = EncodeBitmaskField(effects, config.terrainOutline and outline, 1) + effects = EncodeBitmaskField(effects, config.unitsOutline and outline, 2) + effects = EncodeBitmaskField(effects, config.impactAnimation, 3) + effects = EncodeBitmaskField(effects, config.impactChrommaticAberrations, 4) + effects = EncodeBitmaskField(effects, config.impactHexSwirl, 5) + effects = EncodeBitmaskField(effects, config.bandedNoise, 6) + effects = EncodeBitmaskField(effects, config.impactScaleWithDistance, 7) + effects = EncodeBitmaskField(effects, config.impactRipples, 8) + effects = EncodeBitmaskField(effects, config.vertexWobble, 9) + return effects +end + local function LoadShieldConfig() local ShieldSphereBase = { colormap1 = { { 0.99, 0.99, 0.90, 0.002 }, { 0.6, 0.30, 0.09, 0.0 } }, @@ -705,10 +773,18 @@ local function LoadShieldConfig() myShield.colormap1[2][4] = strengthMult * myShield.colormap1[2][4] end - -- Special handling for raptors - if string.find(ud.name, "raptor_", nil, true) then - myShield.colormap1 = { { 0.3, 0.9, 0.2, 1.2 }, { 0.6, 0.4, 0.1, 1.2 } } - end + -- Scavenger-owned shields get the faction's purple tint (chosen per unit + -- from the owning team, see ApplyTeamPalette): full-charge body colours + -- turn purple (alphas kept), the depleted orange stays so the low-charge + -- warning still reads; the shader picks a purple rim too. + local c1 = myShield.colormap1 + local c2 = myShield.colormap2 + myShield.scavColormap1 = { { 0.80, 0.40, 1.00, c1[1][4] }, { c1[2][1], c1[2][2], c1[2][3], c1[2][4] } } + myShield.scavColormap2 = { { 0.60, 0.30, 0.80, c2[1][4] }, { c2[2][1], c2[2][2], c2[2][3], c2[2][4] } } + + -- Effects bitmask is static per unitdef; precompute both outline variants + myShield.effectsOutline = EncodeEffects(myShield, true) + myShield.effectsNoOutline = EncodeEffects(myShield, false) configTable[unitDefID] = { config = myShield, @@ -716,6 +792,7 @@ local function LoadShieldConfig() shieldCapacity = tonumber(ud.customParams.shield_power), shieldPos = myShield.pos, shieldRadius = radius, + immobile = ud.isImmobile, } end end @@ -723,157 +800,203 @@ local function LoadShieldConfig() return configTable end +----------------------------------------------------------------- +-- GL4 resources ----------------------------------------------------------------- --- Lua limitations only allow to send 24 bits. Should be enough :) -local function EncodeBitmaskField(bitmask, option, position) - return math.bit_or(bitmask, ((option and 1) or 0) * math.floor(2 ^ position)) -end - -local function InitializeShader() - local LuaShader = gl.LuaShader +local function CreateInstanceBuffers(geo, capacity) + if geo.vao then + geo.vao:Delete() + geo.vao = nil + end + if geo.instanceVBO then + geo.instanceVBO:Delete() + geo.instanceVBO = nil + end - -- Check if shader files exist - if not VFS.FileExists("shaders/ShieldSphereColor.vert") then - Spring.Echo("Shield shader error: shaders/ShieldSphereColor.vert not found!") + local instanceVBO = gl.GetVBO(GL.ARRAY_BUFFER, true) + if not instanceVBO then return false end - if not VFS.FileExists("shaders/ShieldSphereColor.frag") then - Spring.Echo("Shield shader error: shaders/ShieldSphereColor.frag not found!") + instanceVBO:Define(capacity, { + { id = 1, name = "instPosRadius", size = 4 }, + { id = 2, name = "instRotMargin", size = 4 }, + { id = 3, name = "instColor1", size = 4 }, + { id = 4, name = "instColor2", size = 4 }, + { id = 5, name = "instParams", size = 4 }, + }) + + local vao = gl.GetVAO() + if not vao then + instanceVBO:Delete() return false end + vao:AttachVertexBuffer(geo.vertexVBO) + vao:AttachInstanceBuffer(instanceVBO) - local shieldShaderVert = VFS.LoadFile("shaders/ShieldSphereColor.vert") - local shieldShaderFrag = VFS.LoadFile("shaders/ShieldSphereColor.frag") + geo.instanceVBO = instanceVBO + geo.vao = vao + geo.capacity = capacity + return true +end - if not shieldShaderVert or not shieldShaderFrag then - Spring.Echo("Shield shader error: Failed to load shader files!") +local function CreateImpactBuffer(capacity) + if impactSSBO then + impactSSBO:Delete() + impactSSBO = nil + end + impactSSBO = gl.GetVBO(GL.SHADER_STORAGE_BUFFER, true) + if not impactSSBO then return false end + -- For SSBOs the engine's attribute unit is vec4 and size = 4 means one + -- element of 4 x vec4 (64 bytes); uploads must cover whole elements. + impactSSBO:Define(capacity, { { id = 0, name = "impacts", size = 4 } }) + impactCapacity = capacity + return true +end - shieldShaderFrag = shieldShaderFrag:gsub("###DEPTH_CLIP01###", (Platform.glSupportClipSpaceControl and "1" or "0")) - shieldShaderFrag = shieldShaderFrag:gsub("###MAX_POINTS###", MAX_POINTS) +local function FinalizeRendering() + if shieldShader then + shieldShader:Finalize() + shieldShader = nil + end - local uniformFloats = { - color1 = { 1, 1, 1, 1 }, - color2 = { 1, 1, 1, 1 }, - translationScale = { 1, 1, 1, 1 }, - rotMargin = { 1, 1, 1, 1 }, - shieldFade = 1.0, - overlapScale = 1.0, - ["impactInfo.count"] = 1, - } - for i = 1, MAX_POINTS + 1 do - uniformFloats[impactInfoStringTable[i - 1]] = { 0, 0, 0, 0 } + for _, geo in pairs(geometry) do + if geo.vao then + geo.vao:Delete() + end + if geo.instanceVBO then + geo.instanceVBO:Delete() + end + if geo.vertexVBO then + geo.vertexVBO:Delete() + end + end + geometry = {} + + if impactSSBO then + impactSSBO:Delete() + impactSSBO = nil + end + impactCapacity = 0 +end + +local function InitializeRendering() + local LuaShader = gl.LuaShader + + local vertPath = "shaders/ShieldSphereColorGL4.vert.glsl" + local fragPath = "shaders/ShieldSphereColorGL4.frag.glsl" + if not VFS.FileExists(vertPath) or not VFS.FileExists(fragPath) then + Spring.Echo("Shield shader error: " .. vertPath .. " / " .. fragPath .. " not found!") + return false end + local vsSrc = VFS.LoadFile(vertPath) + local fsSrc = VFS.LoadFile(fragPath) + if not vsSrc or not fsSrc then + Spring.Echo("Shield shader error: Failed to load shader files!") + return false + end + + local engineUniformBufferDefs = LuaShader.GetEngineUniformBufferDefs() + vsSrc = vsSrc:gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs) + fsSrc = fsSrc:gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs) + fsSrc = fsSrc:gsub("###DEPTH_CLIP01###", (Platform.glSupportClipSpaceControl and "1" or "0")) + fsSrc = fsSrc:gsub("###IMPACT_SSBO_BINDING###", tostring(IMPACT_SSBO_BINDING)) + shieldShader = LuaShader({ - vertex = shieldShaderVert, - fragment = shieldShaderFrag, + vertex = vsSrc, + fragment = fsSrc, uniformInt = { mapDepthTex = 0, modelsDepthTex = 1, - effects = 0, }, - uniformFloat = uniformFloats, - }, "ShieldSphereColor") + }, "ShieldSphereColorGL4") - local shaderCompiled = shieldShader:Initialize() - if not shaderCompiled then + if not shieldShader:Initialize() then Spring.Echo("Shield shader failed to compile!") shieldShader = nil return false end - -- Verify shader object is valid - if not shieldShader or not shieldShader.uniformLocations then - Spring.Echo("Shield shader object is invalid after initialization!") - shieldShader = nil - return false + for sizeName, subd in pairs(GEOMETRY_SUBDIVISIONS) do + local verts, vertexCount = BuildIcosahedronVertices(subd) + local vertexVBO = gl.GetVBO(GL.ARRAY_BUFFER, false) + if not vertexVBO then + FinalizeRendering() + return false + end + vertexVBO:Define(vertexCount, { { id = 0, name = "vertexPos", size = 3 } }) + vertexVBO:Upload(verts) + + local geo = { + vertexVBO = vertexVBO, + vertexCount = vertexCount, + data = {}, + count = 0, + } + geometry[sizeName] = geo + if not CreateInstanceBuffers(geo, INSTANCE_CAPACITY_INITIAL) then + FinalizeRendering() + return false + end end - -- Cache uniform locations for performance - local uniformLocations = shieldShader.uniformLocations - uTranslationScale = uniformLocations.translationScale - uRotMargin = uniformLocations.rotMargin - uEffects = uniformLocations.effects - uColor1 = uniformLocations.color1 - uColor2 = uniformLocations.color2 - uImpactCount = uniformLocations["impactInfo.count"] - uShieldFade = uniformLocations.shieldFade - uOverlapScale = uniformLocations.overlapScale - - -- Cache impact info uniform locations - for i = 1, MAX_POINTS do - impactInfoUniformCache[i] = uniformLocations[impactInfoStringTable[i - 1]] + if not CreateImpactBuffer(IMPACT_CAPACITY_INITIAL) then + FinalizeRendering() + return false end - geometryLists = { - large = gl.CreateList(DrawIcosahedron, 5, false), - small = gl.CreateList(DrawIcosahedron, 4, false), - } - return true end -local function FinalizeShader() - if shieldShader then - shieldShader:Finalize() - shieldShader = nil - end - - for _, list in pairs(geometryLists) do - gl.DeleteList(list) - end - geometryLists = {} -end - ----------------------------------------------------------------- -- Shield rendering ----------------------------------------------------------------- -function gadget:DrawWorld() - if not shieldShader then - return - end - - -- Additional safety check to ensure shader is actually usable - if not shieldShader.uniformLocations or not uTranslationScale then - return - end - - -- Clear renderBuckets in-place to avoid per-frame table allocation - for k, bucket in pairs(renderBuckets) do - for i = 1, #bucket do - bucket[i] = nil - end - renderBuckets[k] = nil - end - haveTerrainOutline = false - haveUnitsOutline = false - canOutline = gl.LuaShader.isDeferredShadingEnabled and gl.LuaShader.GetAdvShadingActive() - - -- Update stunned check throttling +-- Pass 1: fade shields in/out and collect those in view into visScratch and +-- the overlap spatial hash. +local function CollectVisibleShields() checkStunnedTime = checkStunnedTime + 1 - if checkStunnedTime > 40 then + local checkStunned = false + if checkStunnedTime >= STUNNED_CHECK_PERIOD then checkStunned = true checkStunnedTime = 0 - else - checkStunned = false end - -- Draw (collect visible shields into render buckets) - for unitID, unitData in IterableMap.Iterator(shieldUnits) do - if unitData.shieldInfo then - local info = unitData.shieldInfo + visCount = 0 + + -- Size the grids from the previous frame's radii (cell size is only a + -- performance heuristic, correctness never depends on it) + for g = 1, #overlapGridList do + local grid = overlapGridList[g] + local cell = 2 * grid.maxRadius + if cell < grid.cellMin then + cell = grid.cellMin + elseif cell > OVERLAP_CELL_MAX then + cell = OVERLAP_CELL_MAX + end + grid.cellInv = 1 / cell + grid.maxRadius = 0 + end + -- Iterate the IterableMap storage directly: no iterator closure, no + -- per-element function call. + local keyByIndex = shieldUnits.keyByIndex + local dataByKey = shieldUnits.dataByKey + for i = 1, shieldUnits.indexMax do + local unitID = keyByIndex[i] + local unitData = dataByKey[unitID] + local info = unitData.shieldInfo + if info then if checkStunned then info.stunned = spGetUnitIsStunned(unitID) end -- Fade target: 1 if shield should be shown, 0 otherwise. Lerp every frame. local fadeTarget = ((not info.stunned) and info.visibleToMyAllyTeam) and 1.0 or 0.0 - local fa = info.fadeAlpha or 0.0 + local fa = info.fadeAlpha if fa < fadeTarget then fa = fa + SHIELD_FADE_STEP if fa > fadeTarget then @@ -891,94 +1014,302 @@ function gadget:DrawWorld() local radius = info.radius local posx, posy, posz = spGetUnitPosition(unitID) - if posx then - local shieldvisible = spIsSphereInView(posx, posy, posz, radius * 1.2) - - if shieldvisible then - local bucket = renderBuckets[radius] - if not bucket then - bucket = {} - renderBuckets[radius] = bucket - end - - -- Store unitID and unitData directly to avoid table allocation - bucket[#bucket + 1] = unitID - bucket[#bucket + 1] = unitData - - -- Record this shield in the overlap-pass scratch list - -- (5 floats per shield: idx, x, y, z, radius) - overlapScratch[overlapScratchN + 1] = info - overlapScratch[overlapScratchN + 2] = posx - overlapScratch[overlapScratchN + 3] = posy - overlapScratch[overlapScratchN + 4] = posz - overlapScratch[overlapScratchN + 5] = radius - overlapScratchN = overlapScratchN + 5 - - haveTerrainOutline = haveTerrainOutline or (info.terrainOutline and canOutline) - haveUnitsOutline = haveUnitsOutline or (info.unitsOutline and canOutline) + if posx and spIsSphereInView(posx, posy, posz, radius * 1.2) then + local vi = visCount * VIS_STRIDE + visScratch[vi + 1] = unitID + visScratch[vi + 2] = unitData + visScratch[vi + 3] = posx + visScratch[vi + 4] = posy + visScratch[vi + 5] = posz + visScratch[vi + 6] = radius + visCount = visCount + 1 + + -- Register in the overlap spatial hash of this size class + local grid = overlapGrids[info.shieldSize] + if radius > grid.maxRadius then + grid.maxRadius = radius + end + local cellInv = grid.cellInv + local key = floor(posx * cellInv) * OVERLAP_KEY_MUL + floor(posz * cellInv) + local cells = grid.cells + local cell = cells[key] + if not cell then + cell = { [0] = 0 } + cells[key] = cell + end + local c = cell[0] + if c == 0 then + local usedCount = grid.usedCount + 1 + grid.usedCount = usedCount + grid.usedKeys[usedCount] = key end + cell[c + 1] = visCount + cell[0] = c + 1 end end end end +end - -- Reset bucket-collection counters for next frame is done at top. - - -- Overlap pass: for each visible shield count overlapping neighbours, - -- distinguishing those in front (camera-side) from those behind. Build a - -- per-shield target opacity scalar, then smoothly lerp the stored value - -- toward it so dimming doesn't pop as units enter/leave clusters. - do - local n = overlapScratchN - local cx, cy, cz = spGetCameraPosition() - cx = cx or 0 - cy = cy or 0 - cz = cz or 0 - for i = 1, n, 5 do - local infoA = overlapScratch[i] - local ax, ay, az, ar = - overlapScratch[i + 1], overlapScratch[i + 2], overlapScratch[i + 3], overlapScratch[i + 4] - local dxA, dyA, dzA = ax - cx, ay - cy, az - cz - local camDistA = dxA * dxA + dyA * dyA + dzA * dzA - local target = 1.0 - for j = 1, n, 5 do - if j ~= i then - local bx, by, bz, br = - overlapScratch[j + 1], overlapScratch[j + 2], overlapScratch[j + 3], overlapScratch[j + 4] - local ddx, ddy, ddz = ax - bx, ay - by, az - bz - local d2 = ddx * ddx + ddy * ddy + ddz * ddz - local sumR = ar + br - if d2 < sumR * sumR then - local dxB, dyB, dzB = bx - cx, by - cy, bz - cz - local camDistB = dxB * dxB + dyB * dyB + dzB * dzB - if camDistA > camDistB then - -- A is behind B: dim more aggressively - target = target * OVERLAP_FALLOFF_BEHIND - else - target = target * OVERLAP_FALLOFF +-- Counts overlapping neighbours of visible shield i, distinguishing those in +-- front (camera-side) from those behind, and returns the target opacity +-- scalar. Only nearby spatial-hash cells are visited and the search stops as +-- soon as the floor is reached, so dense clusters stay cheap. +local function ComputeOverlapTarget(i, ax, ay, az, ar, camx, camy, camz) + local dxA, dyA, dzA = ax - camx, ay - camy, az - camz + local camDistA = dxA * dxA + dyA * dyA + dzA * dzA + + local target = 1.0 + for g = 1, #overlapGridList do + local grid = overlapGridList[g] + if grid.usedCount > 0 then + local cells = grid.cells + local cellInv = grid.cellInv + local range = ceil((ar + grid.maxRadius) * cellInv) + local cxi = floor(ax * cellInv) + local czi = floor(az * cellInv) + + for gx = cxi - range, cxi + range do + local keyBase = gx * OVERLAP_KEY_MUL + for gz = czi - range, czi + range do + local cell = cells[keyBase + gz] + if cell then + for k = 1, cell[0] do + local j = cell[k] + if j ~= i then + local vj = (j - 1) * VIS_STRIDE + local bx, by, bz, br = + visScratch[vj + 3], visScratch[vj + 4], visScratch[vj + 5], visScratch[vj + 6] + local ddx, ddy, ddz = ax - bx, ay - by, az - bz + local sumR = ar + br + if ddx * ddx + ddy * ddy + ddz * ddz < sumR * sumR then + local dxB, dyB, dzB = bx - camx, by - camy, bz - camz + if camDistA > dxB * dxB + dyB * dyB + dzB * dzB then + -- A is behind B: dim more aggressively + target = target * OVERLAP_FALLOFF_BEHIND + else + target = target * OVERLAP_FALLOFF + end + if target <= OVERLAP_MIN_SCALE then + return OVERLAP_MIN_SCALE + end + end + end end end end end - if target < OVERLAP_MIN_SCALE then - target = OVERLAP_MIN_SCALE + end + end + return target +end + +-- Pass 2: refresh the overlap target of a slice of the visible shields, then +-- smoothly lerp every shield's stored opacity scalar toward its target so +-- dimming doesn't pop as units enter/leave clusters. +local function UpdateOverlapScales() + local camx, camy, camz = spGetCameraPosition() + camx = camx or 0 + camy = camy or 0 + camz = camz or 0 + + overlapPhase = (overlapPhase + 1) % OVERLAP_UPDATE_DIVISOR + + for i = 1, visCount do + local vi = (i - 1) * VIS_STRIDE + local info = visScratch[vi + 2].shieldInfo + if (i % OVERLAP_UPDATE_DIVISOR) == overlapPhase then + info.overlapTarget = ComputeOverlapTarget( + i, + visScratch[vi + 3], + visScratch[vi + 4], + visScratch[vi + 5], + visScratch[vi + 6], + camx, + camy, + camz + ) + end + local cur = info.overlapScale + info.overlapScale = cur + (info.overlapTarget - cur) * OVERLAP_LERP_RATE + end + + -- Reset the spatial hashes for the next frame (cell tables are kept) + for g = 1, #overlapGridList do + local grid = overlapGridList[g] + local cells = grid.cells + local usedKeys = grid.usedKeys + for k = 1, grid.usedCount do + cells[usedKeys[k]][0] = 0 + end + grid.usedCount = 0 + end +end + +-- Pass 3: write per-shield instance attributes and the packed impact list. +-- Returns whether any drawn shield wants the terrain / unit depth outline. +local function BuildInstanceData() + local geoSmall = geometry.small + local geoLarge = geometry.large + geoSmall.count = 0 + geoLarge.count = 0 + impactFloatCount = 0 + local impactVec4Count = 0 + + local haveTerrainOutline = false + local haveUnitsOutline = false + + for i = 1, visCount do + local vi = (i - 1) * VIS_STRIDE + local unitID = visScratch[vi + 1] + local unitData = visScratch[vi + 2] + local info = unitData.shieldInfo + local pos = info.pos + local posx = visScratch[vi + 3] + pos[1] + local posy = visScratch[vi + 4] + pos[2] + local posz = visScratch[vi + 5] + pos[3] + + -- Only yaw is used by the shader; immobile units keep their first value + local yaw = unitData.yaw + if not yaw or not unitData.immobile then + local _, y = spGetUnitRotation(unitID) + yaw = y or 0 + unitData.yaw = yaw + end + + local fadeAlpha = info.fadeAlpha + + -- Charge fraction drives the colour lerp + local _, charge = spGetUnitShieldState(unitID) + local frac = 1.0 + if charge then + frac = charge / info.shieldCapacity + if frac ~= frac then -- NaN + frac = 0 + elseif frac > 1 then + frac = 1 + elseif frac < 0 then + frac = 0 end - local cur = infoA.overlapScale or 1.0 - infoA.overlapScale = cur + (target - cur) * OVERLAP_LERP_RATE - overlapScratch[i] = nil -- release table ref end - overlapScratchN = 0 + local fracinv = 1.0 - frac + local c1full, c1empty = info.colormap1[1], info.colormap1[2] + local c2full, c2empty = info.colormap2[1], info.colormap2[2] + + local effects + if canOutline then + effects = info.effectsOutline + haveTerrainOutline = haveTerrainOutline or info.terrainOutline + haveUnitsOutline = haveUnitsOutline or info.unitsOutline + else + effects = info.effectsNoOutline + end + + -- Impact points: append to the shared list, reference by base index + local impactBase = 0 + local impactCount = 0 + if highEnoughQuality and info.impactAnimation then + local hitData = unitData.hitData + local n = hitData and #hitData or 0 + if n > 0 then + if n > MAX_POINTS then + n = MAX_POINTS + end + impactBase = impactVec4Count + impactCount = n + local f = impactFloatCount + for j = 1, n do + local hit = hitData[j] + local aoe = hit.aoe + if aoe ~= aoe or aoe == huge or aoe == -huge then + aoe = 0 + end + impactData[f + 1] = hit.x + impactData[f + 2] = hit.y + impactData[f + 3] = hit.z + impactData[f + 4] = aoe + f = f + 4 + end + impactFloatCount = f + impactVec4Count = impactVec4Count + n + end + end + + local geo = (info.shieldSize == "large") and geoLarge or geoSmall + local data = geo.data + local o = geo.count * INSTANCE_STRIDE + geo.count = geo.count + 1 + + data[o + 1] = posx + data[o + 2] = posy + data[o + 3] = posz + data[o + 4] = info.radius + + data[o + 5] = yaw + data[o + 6] = info.margin + data[o + 7] = fadeAlpha + data[o + 8] = info.overlapScale + + data[o + 9] = frac * c1full[1] + fracinv * c1empty[1] + data[o + 10] = frac * c1full[2] + fracinv * c1empty[2] + data[o + 11] = frac * c1full[3] + fracinv * c1empty[3] + data[o + 12] = (frac * c1full[4] + fracinv * c1empty[4]) * fadeAlpha + + data[o + 13] = frac * c2full[1] + fracinv * c2empty[1] + data[o + 14] = frac * c2full[2] + fracinv * c2empty[2] + data[o + 15] = frac * c2full[3] + fracinv * c2empty[3] + data[o + 16] = (frac * c2full[4] + fracinv * c2empty[4]) * fadeAlpha + + data[o + 17] = effects + data[o + 18] = impactBase + data[o + 19] = impactCount + data[o + 20] = info.scavenger and 1 or 0 -- flags: 1 = scavenger palette end - -- EndDraw (render all buckets) - if next(renderBuckets) == nil then - return + return haveTerrainOutline, haveUnitsOutline +end + +local function UploadInstanceData() + for _, geo in pairs(geometry) do + local count = geo.count + if count > 0 then + if count > geo.capacity then + local capacity = geo.capacity + while capacity < count do + capacity = capacity * 2 + end + if not CreateInstanceBuffers(geo, capacity) then + geo.count = 0 + end + end + if geo.count > 0 then + geo.instanceVBO:Upload(geo.data, -1, 0, 1, count * INSTANCE_STRIDE) + end + end end - if tracy then - tracy.ZoneBeginN("Shield:EndDraw") + if impactFloatCount > 0 then + -- Pad to whole SSBO elements (see CreateImpactBuffer) + local padded = ceil(impactFloatCount / IMPACT_ELEMENT_FLOATS) * IMPACT_ELEMENT_FLOATS + for f = impactFloatCount + 1, padded do + impactData[f] = 0 + end + local elements = padded / IMPACT_ELEMENT_FLOATS + if elements > impactCapacity then + local capacity = impactCapacity + while capacity < elements do + capacity = capacity * 2 + end + if not CreateImpactBuffer(capacity) then + impactFloatCount = 0 + return + end + end + impactSSBO:Upload(impactData, -1, 0, 1, padded) end +end +local function DrawShields(haveTerrainOutline, haveUnitsOutline) gl.Blending("alpha") gl.DepthTest(GL.LEQUAL) gl.DepthMask(false) @@ -991,126 +1322,23 @@ function gadget:DrawWorld() gl.Texture(1, "$model_gbuffer_zvaltex") end - local gf = spGetGameFrame() + spGetFrameTimeOffset() - local glUniform = gl.Uniform - local glUniformInt = gl.UniformInt + local haveImpacts = impactFloatCount > 0 + if haveImpacts then + impactSSBO:BindBufferRange(IMPACT_SSBO_BINDING) + end shieldShader:Activate() - - shieldShader:SetUniformFloat("gameFrame", gf) - shieldShader:SetUniformMatrix("viewMat", "view") - shieldShader:SetUniformMatrix("projMat", "projection") - - for _, rb in pairs(renderBuckets) do - -- Iterate in pairs (unitID, unitData) - for i = 1, #rb, 2 do - local unitID = rb[i] - local unitData = rb[i + 1] - local info = unitData.shieldInfo - - local posx, posy, posz = spGetUnitPosition(unitID) - - if posx then - posx, posy, posz = posx + info.pos[1], posy + info.pos[2], posz + info.pos[3] - - local pitch, yaw, roll = spGetUnitRotation(unitID) - - local fadeAlpha = info.fadeAlpha or 1.0 - - glUniform(uTranslationScale, posx, posy, posz, info.radius) - glUniform(uRotMargin, pitch, yaw, roll, info.margin) - if uShieldFade then - glUniform(uShieldFade, fadeAlpha) - end - if uOverlapScale then - glUniform(uOverlapScale, info.overlapScale or 1.0) - end - - if not info.optionX then - local optionX = 0 - optionX = EncodeBitmaskField(optionX, info.terrainOutline and canOutline, 1) - optionX = EncodeBitmaskField(optionX, info.unitsOutline and canOutline, 2) - optionX = EncodeBitmaskField(optionX, info.impactAnimation, 3) - optionX = EncodeBitmaskField(optionX, info.impactChrommaticAberrations, 4) - optionX = EncodeBitmaskField(optionX, info.impactHexSwirl, 5) - optionX = EncodeBitmaskField(optionX, info.bandedNoise, 6) - optionX = EncodeBitmaskField(optionX, info.impactScaleWithDistance, 7) - optionX = EncodeBitmaskField(optionX, info.impactRipples, 8) - optionX = EncodeBitmaskField(optionX, info.vertexWobble, 9) - info.optionX = optionX - end - - glUniformInt(uEffects, info.optionX) - - local _, charge = spGetUnitShieldState(unitID) - if charge and info.shieldCapacity and info.shieldCapacity > 0 then - local frac = charge / info.shieldCapacity - - if frac > 1 then - frac = 1 - elseif frac < 0 then - frac = 0 - end - - -- Additional NaN safety check - if frac ~= frac then - frac = 0 - end -- NaN check (NaN != NaN) - - local fracinv = 1.0 - frac - - local colormap1 = info.colormap1[1] - local colormap2 = info.colormap1[2] - - -- Safety check for colormap values - if colormap1 and colormap2 and colormap1[1] and colormap2[1] then - local col1r = frac * colormap1[1] + fracinv * colormap2[1] - local col1g = frac * colormap1[2] + fracinv * colormap2[2] - local col1b = frac * colormap1[3] + fracinv * colormap2[3] - local col1a = frac * colormap1[4] + fracinv * colormap2[4] - - glUniform(uColor1, col1r, col1g, col1b, col1a * fadeAlpha) - end - - colormap1 = info.colormap2[1] - colormap2 = info.colormap2[2] - - -- Safety check for colormap values - if colormap1 and colormap2 and colormap1[1] and colormap2[1] then - local col1r = frac * colormap1[1] + fracinv * colormap2[1] - local col1g = frac * colormap1[2] + fracinv * colormap2[2] - local col1b = frac * colormap1[3] + fracinv * colormap2[3] - local col1a = frac * colormap1[4] + fracinv * colormap2[4] - - glUniform(uColor2, col1r, col1g, col1b, col1a * fadeAlpha) - end - end - - -- Impact animation - if highEnoughQuality and info.impactAnimation then - local hitData = unitData.hitData - if hitData then - local hitPointCount = math.min(#hitData, MAX_POINTS) - glUniformInt(uImpactCount, hitPointCount) - for j = 1, hitPointCount do - local hit = hitData[j] - -- Safeguard against NaN values - local aoe = hit.aoe - if aoe ~= aoe or aoe == math.huge or aoe == -math.huge then - aoe = 0 - end - glUniform(impactInfoUniformCache[j], hit.x, hit.y, hit.z, aoe) - end - end - end - - gl.CallList(geometryLists[info.shieldSize]) - end + for _, geo in pairs(geometry) do + if geo.count > 0 then + geo.vao:DrawArrays(GL.TRIANGLES, geo.vertexCount, 0, geo.count) end end - shieldShader:Deactivate() + if haveImpacts then + impactSSBO:UnbindBufferRange(IMPACT_SSBO_BINDING) + end + if haveTerrainOutline then gl.Texture(0, false) end @@ -1121,10 +1349,38 @@ function gadget:DrawWorld() gl.DepthTest(false) gl.DepthMask(false) +end - if tracy then - tracy.ZoneEnd() +function gadget:DrawWorld() + if not shieldShader then + return end + + tracyZoneBeginN("Shield:Collect") + CollectVisibleShields() + tracyZoneEnd() + + if visCount == 0 then + return + end + + canOutline = gl.LuaShader.isDeferredShadingEnabled and gl.LuaShader.GetAdvShadingActive() + + tracyZoneBeginN("Shield:Overlap") + UpdateOverlapScales() + tracyZoneEnd() + + tracyZoneBeginN("Shield:Build") + local haveTerrainOutline, haveUnitsOutline = BuildInstanceData() + tracyZoneEnd() + + tracyZoneBeginN("Shield:Upload") + UploadInstanceData() + tracyZoneEnd() + + tracyZoneBeginN("Shield:Draw") + DrawShields(haveTerrainOutline, haveUnitsOutline) + tracyZoneEnd() end -------------------------------------------------------------------------------- @@ -1140,13 +1396,22 @@ function gadget:UnitFinished(unitID, unitDefID, unitTeam) end end -function gadget:UnitTaken(unitID, unitDefID, newTeam, oldTeam) +local function UnitChangedTeam(unitID, newTeam) local unitData = IterableMap.Get(shieldUnits, unitID) if unitData then unitData.allyTeamID = Spring.GetUnitAllyTeam(unitID) + ApplyTeamPalette(unitData, newTeam) end end +function gadget:UnitTaken(unitID, unitDefID, newTeam, oldTeam) + UnitChangedTeam(unitID, newTeam) +end + +function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) + UnitChangedTeam(unitID, newTeam) +end + function gadget:PlayerChanged() myAllyTeamID = spGetMyAllyTeamID() end @@ -1171,13 +1436,18 @@ function gadget:GameFrame(n) end function gadget:Initialize(n) + if Platform.glHaveGL4 ~= true then + Spring.Echo("Shield gadget: GL4 not supported by this GPU/driver, disabling shield rendering") + gadgetHandler:RemoveGadget(self) + return + end + -- Load shield configuration shieldUnitDefs = LoadShieldConfig() -- Initialize shader and geometry - local shaderSuccess = InitializeShader() - if not shaderSuccess then - Spring.Echo("Shield gadget: Failed to initialize shader, disabling") + if not InitializeRendering() then + Spring.Echo("Shield gadget: Failed to initialize rendering, disabling") gadgetHandler:RemoveGadget(self) return end @@ -1205,8 +1475,8 @@ function gadget:Shutdown() GG.GetShieldHitPositions = nil end - -- Cleanup shader - FinalizeShader() + -- Cleanup GL resources + FinalizeRendering() -- Remove all units local allUnits = Spring.GetAllUnits() diff --git a/luarules/gadgets/gfx_wade_fx.lua b/luarules/gadgets/gfx_wade_fx.lua index 8d010e25e1f..ad2f29050b4 100644 --- a/luarules/gadgets/gfx_wade_fx.lua +++ b/luarules/gadgets/gfx_wade_fx.lua @@ -18,7 +18,7 @@ end ---@class WadeUnitData ---@field id integer ----@field unitID integer +---@field unitID UnitID ---@field h number ---@field ceg string diff --git a/luarules/gadgets/gfx_water_type_overlay_state.lua b/luarules/gadgets/gfx_water_type_overlay_state.lua index 787a8b11112..ca8ee7e34fe 100644 --- a/luarules/gadgets/gfx_water_type_overlay_state.lua +++ b/luarules/gadgets/gfx_water_type_overlay_state.lua @@ -70,7 +70,6 @@ local spGetUnitDefID = Spring.GetUnitDefID local spGetMoveData = Spring.GetUnitMoveTypeData local spMoveCtrlEnabled = Spring.MoveCtrl.IsEnabled local spSetMoveData = Spring.MoveCtrl.SetGroundMoveTypeData -local spGetGroundHeight = Spring.GetGroundHeight local spGetGroundExtremes = Spring.GetGroundExtremes local spSpawnCEG = Spring.SpawnCEG local clamp = math.clamp diff --git a/luarules/gadgets/include/GenEnvLut.lua b/luarules/gadgets/include/GenEnvLut.lua index fcf71b59212..c09d4ffb441 100644 --- a/luarules/gadgets/include/GenEnvLut.lua +++ b/luarules/gadgets/include/GenEnvLut.lua @@ -142,9 +142,6 @@ local lutFS = [[ ]] local GL_RGB16F = 0x881B -local GL_RGB32F = 0x8815 - -local GL_COLOR_ATTACHMENT0_EXT = 0x8CE0 local function new(class, numSamples) return setmetatable({ diff --git a/luarules/gadgets/include/startbox_utilities.lua b/luarules/gadgets/include/startbox_utilities.lua index f236e166d73..db6292d71a7 100644 --- a/luarules/gadgets/include/startbox_utilities.lua +++ b/luarules/gadgets/include/startbox_utilities.lua @@ -11,7 +11,7 @@ -- https://github.com/beyond-all-reason/maps-metadata schemas/map_list.yaml local SplineLib = VFS.Include("common/lib_spline.lua") -local base64 = VFS.Include("common/luaUtilities/base64.lua") +local ModoptionPayload = VFS.Include("common/luaUtilities/modoption_payload.lua") local function GetStartboxName(midX, midZ) if midX < 0.33 then @@ -41,30 +41,6 @@ local function GetStartboxName(midX, midZ) end end -local function decodeModoption(raw) - if not raw or #raw == 0 then - return nil - end - - local okDecode, decoded = pcall(base64.Decode, raw) - if not okDecode or not decoded or decoded == "" then - return nil - end - - -- VFS.ZlibDecompress raises on non-zlib or empty input rather than returning nil. - local okZlib, decompressed = pcall(VFS.ZlibDecompress, decoded) - if not okZlib or not decompressed then - return nil - end - - local okJson, parsed = pcall(Json.decode, decompressed) - if not okJson or type(parsed) ~= "table" then - return nil - end - - return parsed -end - local function getActiveAllyTeamCount() local gaiaAllyTeamID local gaiaTeamID = Spring.GetGaiaTeamID() @@ -226,6 +202,34 @@ local function transformArrangement(arrangement) return config end +local function buildWholeMapEntry() + local mapSizeX, mapSizeZ = Game.mapSizeX, Game.mapSizeZ + + return { + boxes = { + { + { 0, 0 }, + { 0, mapSizeZ }, + { mapSizeX, mapSizeZ }, + { mapSizeX, 0 }, + }, + }, + startpoints = { { mapSizeX * 0.5, mapSizeZ * 0.5 } }, + nameLong = "Anywhere", + nameShort = "Any", + wholeMap = true, + } +end + +-- resolveArrangement settles for an arrangement covering fewer allyteams than the game has. +local function fillUnboxedAllyTeams(config, numTeams) + for allyTeamID = 0, numTeams - 1 do + if not config[allyTeamID] then + config[allyTeamID] = buildWholeMapEntry() + end + end +end + local function buildFallback() local mapSizeX = Game.mapSizeX local mapSizeZ = Game.mapSizeZ @@ -295,14 +299,15 @@ local function ParseBoxes() local numTeams = getActiveAllyTeamCount() local modoptions = Spring.GetModOptions() - local parsedOverride = decodeModoption(modoptions.mapmetadata_startbox_override) - local parsedSet = decodeModoption(modoptions.mapmetadata_startboxes_set) + local parsedOverride = ModoptionPayload.Decode(modoptions.mapmetadata_startbox_override) + local parsedSet = ModoptionPayload.Decode(modoptions.mapmetadata_startboxes_set) local arrangement, configSource = resolveArrangement(parsedOverride, parsedSet, numTeams) local startBoxConfig if arrangement then startBoxConfig = transformArrangement(arrangement) + fillUnboxedAllyTeams(startBoxConfig, numTeams) else startBoxConfig = buildFallback() configSource = "fallback" @@ -355,7 +360,7 @@ local cachedConfig, cachedSource, cachedExplicit local haveParsed = false -- Modoptions and the allyteam list are both fixed for the life of the game, so the parse --- happens once however many callers ask for it. +-- happens once per file that includes this one. local function GetConfig() if not haveParsed then haveParsed = true @@ -413,8 +418,9 @@ end -- against the wrong axis. A box covering everything restricts nothing, which is what -- those callers were really asking about. local function HasStartbox(allyTeamID) - if GetEntry(allyTeamID) then - return true + local entry = GetEntry(allyTeamID) + if entry then + return not entry.wholeMap end local xmin, zmin, xmax, zmax = Spring.GetAllyTeamStartBox(allyTeamID) @@ -525,8 +531,7 @@ local function ClosestPos(allyTeamID, x, z) return bestX, bestZ end --- Callable as well as indexable: some callers include this file and call the result as the parser. -return setmetatable({ +return { ParseBoxes = ParseBoxes, GetConfig = GetConfig, GetBounds = GetBounds, @@ -535,8 +540,4 @@ return setmetatable({ IsInside = IsInside, GetRandomPos = GetRandomPos, ClosestPos = ClosestPos, -}, { - __call = function(_, ...) - return ParseBoxes(...) - end, -}) +} diff --git a/luarules/gadgets/include/unit_attachments.lua b/luarules/gadgets/include/unit_attachments.lua new file mode 100644 index 00000000000..7df64979f5e --- /dev/null +++ b/luarules/gadgets/include/unit_attachments.lua @@ -0,0 +1,59 @@ +-- unit_attachments.lua -------------------------------------------------------- +-- Simple common reference for multiple types of unit attachments. Provides some +-- different approaches, preferring customParams values over piece default-name. + +local SECTION = "unit_attachments" +local PIECENAME_ATTACH = "attach" + +local spGetUnitDefID = Spring.GetUnitDefID +local spGetUnitPieceMap = Spring.GetUnitPieceMap + +---Searches for attachment pieces via simple naming conventions. +--- +---Custom attachment points are preferred over default ones. +---@param unitID UnitID +---@return integer? pieceNum index of the default attach piece +local function resolveAttachPiece(unitID) + local pieceMap = spGetUnitPieceMap(unitID) + if not pieceMap then + return + end + + local unitDefID = spGetUnitDefID(unitID) + local unitDef = UnitDefs[unitDefID] ---@as table + + local attachedTurret = unitDef.customParams.attached_con_turret_piece + if attachedTurret and pieceMap[attachedTurret] then + return pieceMap[attachedTurret] + end + + local attachPiece = unitDef.customParams.attach_piece + if attachPiece then + if pieceMap[attachPiece] then + return pieceMap[attachPiece] + end + local index = attachPiece and tonumber(attachPiece) + if index and table.contains(pieceMap, index) then + return index ---@as integer + end + end + + local pieceIndex = pieceMap[PIECENAME_ATTACH] + if pieceIndex then + return pieceIndex + end + + local messages = {} + for key, value in pairs({ + [unitDef.name .. " missing attached_con_turret_piece "] = attachedTurret, + [unitDef.name .. " missing attach_piece "] = attachPiece, + [unitDef.name .. " missing piece named "] = pieceIndex, + }) do + messages[#messages + 1] = key .. value + end + Spring.Log(SECTION, LOG.WARNING, table.concat(messages)) +end + +return { + ResolveAttachPiece = resolveAttachPiece, +} diff --git a/luarules/gadgets/map_lava.lua b/luarules/gadgets/map_lava.lua index 38dff97454c..565a5cac69a 100644 --- a/luarules/gadgets/map_lava.lua +++ b/luarules/gadgets/map_lava.lua @@ -44,6 +44,9 @@ if gadgetHandler:IsSyncedCode() then local lavaDamage = lava.damage * (DAMAGE_RATE / gameSpeed) local lavaDamageFeatures = lava.damageFeatures local lavaDamageAirUnits = true + -- lava damage is dealt as the engine's environmental water damage type (the engine uses it for + -- lava/acid water too), so other gadgets can tell it apart from weapon damage (e.g. no rush mode) + local DAMAGE_EXTSOURCE_WATER = Game.envDamageTypes.Water -- ceg effects local lavaEffectBurst = lava.effectBurst @@ -186,7 +189,7 @@ if gadgetHandler:IsSyncedCode() then data.currentSlow = unitSlow end end - spAddUnitDamage(unitID, lavaDamage, nil, nil) + spAddUnitDamage(unitID, lavaDamage, nil, nil, DAMAGE_EXTSOURCE_WATER) spSpawnCEG(lavaEffectDamage, x, y + 5, z) else -- unit exited lava if data.slowed then @@ -211,7 +214,7 @@ if gadgetHandler:IsSyncedCode() then if lavaDamageAirUnits then local x, y, z = spGetUnitBasePosition(unitID) if y and y < lavaLevel then - spAddUnitDamage(unitID, lavaDamage, nil, nil) + spAddUnitDamage(unitID, lavaDamage, nil, nil, DAMAGE_EXTSOURCE_WATER) spSpawnCEG(lavaEffectDamage, x, y + 5, z) end end @@ -240,7 +243,7 @@ if gadgetHandler:IsSyncedCode() then data = { unitDefID = unitDefID, slowed = false } end lavaUnits[unitID] = data - spAddUnitDamage(unitID, lavaDamage, nil, nil) + spAddUnitDamage(unitID, lavaDamage, nil, nil, DAMAGE_EXTSOURCE_WATER) spSpawnCEG(lavaEffectDamage, x, y + 5, z) end end @@ -279,7 +282,7 @@ if gadgetHandler:IsSyncedCode() then for featureID, y in pairs(featureY) do if y < lavaLevel then local x, z = featureX[featureID], featureZ[featureID] - spAddFeatureDamage(featureID, lavaDamage, nil, nil) + spAddFeatureDamage(featureID, lavaDamage, nil, nil, DAMAGE_EXTSOURCE_WATER) spSpawnCEG(lavaEffectDamage, x, y + 5, z) end end @@ -411,8 +414,6 @@ if gadgetHandler:IsSyncedCode() then -- end end - local DAMAGE_EXTSOURCE_WATER = -5 - function gadget:UnitPreDamaged(unitID, unitDefID, unitTeam, damage, paralyzer, weaponDefID, projectileID) if weaponDefID ~= DAMAGE_EXTSOURCE_WATER then -- not water damage, do not modify diff --git a/luarules/gadgets/map_nightmode.lua b/luarules/gadgets/map_nightmode.lua index 551efc041d8..00464564f68 100644 --- a/luarules/gadgets/map_nightmode.lua +++ b/luarules/gadgets/map_nightmode.lua @@ -259,8 +259,6 @@ if not gadgetHandler:IsSyncedCode() then end end if a.sunDir and b.sunDir then - local asun = a.sunDir - local bsun = b.sunDir local aworldrot, aheight = SunDirToAzimuthHeight(a.sunDir) local bworldrot, bheight = SunDirToAzimuthHeight(b.sunDir) @@ -291,10 +289,6 @@ if not gadgetHandler:IsSyncedCode() then local initial_atmosphere_lighting = GetLightingAndAtmosphere() - local initlight - local endlight - local mixedlight - local function GetNightLight(fromlight, nightfactor, azimuth, altitude) if fromlight == nil then fromlight = tablecopy(initial_atmosphere_lighting) @@ -427,7 +421,6 @@ if not gadgetHandler:IsSyncedCode() then nc.mixedlight = tablecopy(nc.endLight) end - local transitionfactor = 0 if phase <= nc.dayDuration + nc.transitionDuration then -- moving to night mixfac = math.smoothstep(nc.dayDuration, nc.dayDuration + nc.transitionDuration, phase) else diff --git a/luarules/gadgets/raptor_spawner_defense.lua b/luarules/gadgets/raptor_spawner_defense.lua index 787a256c885..34ebde605cd 100644 --- a/luarules/gadgets/raptor_spawner_defense.lua +++ b/luarules/gadgets/raptor_spawner_defense.lua @@ -88,7 +88,6 @@ if gadgetHandler:IsSyncedCode() then local SetFeatureResources = Spring.SetFeatureResources local SetFeatureHealth = Spring.SetFeatureHealth local GetFeatureHealth = Spring.GetFeatureHealth - local DestroyFeature = Spring.DestroyFeature local GetFeatureDefID = Spring.GetFeatureDefID local SpawnCEG = Spring.SpawnCEG @@ -232,6 +231,10 @@ if gadgetHandler:IsSyncedCode() then local gaiaTeamID = GetGaiaTeamID() humanTeams[gaiaTeamID] = nil + local humanTeamCount = 0 + for _ in pairs(humanTeams) do + humanTeamCount = humanTeamCount + 1 + end local function PutRaptorAlliesInRaptorTeam(n) local players = GetPlayerList() @@ -359,16 +362,16 @@ if gadgetHandler:IsSyncedCode() then config.gracePeriodInitial = config.gracePeriod + 0 local maxBurrows = ( (config.maxBurrows * (1 - config.raptorPerPlayerMultiplier)) - + (config.maxBurrows * config.raptorPerPlayerMultiplier) * (math.min(SetCount(humanTeams), 8)) + + (config.maxBurrows * config.raptorPerPlayerMultiplier) * (math.min(humanTeamCount, 8)) ) * config.raptorSpawnMultiplier local queenTime = (config.queenTime + config.gracePeriod) local maxWaveSize = ( (config.maxRaptors * (1 - config.raptorPerPlayerMultiplier)) - + (config.maxRaptors * config.raptorPerPlayerMultiplier) * SetCount(humanTeams) + + (config.maxRaptors * config.raptorPerPlayerMultiplier) * humanTeamCount ) * config.raptorSpawnMultiplier local minWaveSize = ( (config.minRaptors * (1 - config.raptorPerPlayerMultiplier)) - + (config.minRaptors * config.raptorPerPlayerMultiplier) * SetCount(humanTeams) + + (config.minRaptors * config.raptorPerPlayerMultiplier) * humanTeamCount ) * config.raptorSpawnMultiplier local currentMaxWaveSize = minWaveSize local endlessLoopCounter = 1 @@ -427,15 +430,15 @@ if gadgetHandler:IsSyncedCode() then queenTime = (config.queenTime + config.gracePeriod) maxBurrows = ( (config.maxBurrows * (1 - config.raptorPerPlayerMultiplier)) - + (config.maxBurrows * config.raptorPerPlayerMultiplier) * (math.min(SetCount(humanTeams), 8)) + + (config.maxBurrows * config.raptorPerPlayerMultiplier) * (math.min(humanTeamCount, 8)) ) * config.raptorSpawnMultiplier maxWaveSize = ( (config.maxRaptors * (1 - config.raptorPerPlayerMultiplier)) - + (config.maxRaptors * config.raptorPerPlayerMultiplier) * SetCount(humanTeams) + + (config.maxRaptors * config.raptorPerPlayerMultiplier) * humanTeamCount ) * config.raptorSpawnMultiplier minWaveSize = ( (config.minRaptors * (1 - config.raptorPerPlayerMultiplier)) - + (config.minRaptors * config.raptorPerPlayerMultiplier) * SetCount(humanTeams) + + (config.minRaptors * config.raptorPerPlayerMultiplier) * humanTeamCount ) * config.raptorSpawnMultiplier config.raptorSpawnRate = nextDifficulty.raptorSpawnRate currentMaxWaveSize = minWaveSize @@ -491,6 +494,7 @@ if gadgetHandler:IsSyncedCode() then ]] function squadManagerKillerLoop() -- Kills squads that have been alive for too long (most likely stuck somewhere on the map) --squadsTable + local burrowCount = SetCount(burrows) for i = 1, #squadsTable do squadsTable[i].squadLife = squadsTable[i].squadLife - 1 if squadsTable[i].squadLife < 3 and squadsTable[i].squadRegroupEnabled then @@ -500,7 +504,7 @@ if gadgetHandler:IsSyncedCode() then if squadsTable[i].squadLife <= 0 then -- Spring.Echo("Life is 0, time to do some killing") - if SetCount(squadsTable[i].squadUnits) > 0 and SetCount(burrows) > 2 then + if #squadsTable[i].squadUnits > 0 and burrowCount > 2 then if squadsTable[i].squadBurrow and nSpawnedQueens == 0 then if GetUnitIsDead(squadsTable[i].squadBurrow) == false then squadsTable[i].squadBurrow = nil @@ -533,7 +537,7 @@ if gadgetHandler:IsSyncedCode() then tracy.ZoneBeginN("Raptors:squadCommanderGiveOrders") local units = squadsTable[squadID].squadUnits local role = squadsTable[squadID].squadRole - if SetCount(units) > 0 and squadsTable[squadID].target and squadsTable[squadID].target.x then + if #units > 0 and squadsTable[squadID].target and squadsTable[squadID].target.x then if squadsTable[squadID].squadRegroupEnabled then local xmin = 999999 local xmax = 0 @@ -650,7 +654,7 @@ if gadgetHandler:IsSyncedCode() then else for i = 1, #squadsTable do -- Spring.Echo("Attempt to recycle squad #" .. i .. ". Containing " .. SetCount(squadsTable[i].squadUnits) .. " units.") - if SetCount(squadsTable[i].squadUnits) == 0 then -- Yes, we found one empty squad to recycle + if #squadsTable[i].squadUnits == 0 then -- Yes, we found one empty squad to recycle squadID = i -- Spring.Echo("Recycled squad, #".. squadID) break @@ -688,7 +692,7 @@ if gadgetHandler:IsSyncedCode() then -- Spring.Echo("Created Raptor Squad, containing " .. #squadsTable[squadID].squadUnits .. " units!") -- Spring.Echo("Role: " .. squadsTable[squadID].squadRole) -- Spring.Echo("Lifetime: " .. squadsTable[squadID].squadLife) - for i = 1, SetCount(squadsTable[squadID].squadUnits) do + for i = 1, #squadsTable[squadID].squadUnits do local unitID = squadsTable[squadID].squadUnits[i] unitSquadTable[unitID] = squadID -- Spring.Echo("#".. i ..", ID: ".. unitID .. ", Name:" .. UnitDefs[Spring.GetUnitDefID(unitID)].name) @@ -704,17 +708,8 @@ if gadgetHandler:IsSyncedCode() then function manageAllSquads() -- Get new target for all squads that need it for i = 1, #squadsTable do - if mRandom(1, 100) == 1 then - local hasTarget = false - for squad, target in pairs(unitTargetPool) do - if i == squad then - hasTarget = true - break - end - end - if not hasTarget then - refreshSquad(i) - end + if mRandom(1, 100) == 1 and unitTargetPool[i] == nil then + refreshSquad(i) end end end @@ -1077,7 +1072,7 @@ if gadgetHandler:IsSyncedCode() then end end - if SetCount(queenIDs) > 0 then + if next(queenIDs) ~= nil then if queenStagger.currentlyStaggered == false then if queenStagger.CurrentHealth > 0 then SetGameRulesParam( @@ -1625,10 +1620,10 @@ if gadgetHandler:IsSyncedCode() then if uSettings.minQueenAnger <= techAnger and uSettings.maxQueenAnger >= techAnger then local numOfTurrets = (uSettings.spawnedPerWave * (1 - config.raptorPerPlayerMultiplier)) + (uSettings.spawnedPerWave * config.raptorPerPlayerMultiplier) - * (math.min(SetCount(humanTeams), 8)) + * (math.min(humanTeamCount, 8)) local maxExisting = (uSettings.maxExisting * (1 - config.raptorPerPlayerMultiplier)) + (uSettings.maxExisting * config.raptorPerPlayerMultiplier) - * (math.min(SetCount(humanTeams), 8)) + * (math.min(humanTeamCount, 8)) local maxAllowedToSpawn if techAnger <= 100 then -- i don't know how this works but it does. scales maximum amount of turrets allowed to spawn with techAnger. maxAllowedToSpawn = math.ceil( @@ -2452,7 +2447,7 @@ if gadgetHandler:IsSyncedCode() then if t < config.gracePeriod then queenAnger = 0 minBurrows = - math.ceil(math.max(4, 2 * (math.min(SetCount(humanTeams), 8))) * (t / config.gracePeriodInitial)) + math.ceil(math.max(4, 2 * (math.min(humanTeamCount, 8))) * (t / config.gracePeriodInitial)) else if nSpawnedQueens == 0 then queenAnger = math.clamp( @@ -2512,7 +2507,7 @@ if gadgetHandler:IsSyncedCode() then if t > config.gracePeriodInitial + 5 then if burrowCount > 0 - and SetCount(spawnQueue) == 0 + and next(spawnQueue) == nil and ((config.raptorSpawnRate * waveParameters.waveTimeMultiplier) < (t - timeOfLastWave)) then Wave() @@ -2539,13 +2534,18 @@ if gadgetHandler:IsSyncedCode() then local raptors = GetTeamUnits(raptorTeamID) for i = 1, #raptors do local unitID = raptors[i] - local defID = GetUnitDefID(unitID) - if - defID - and mRandom(1, math.ceil((33 * math.max(1, GetTeamUnitDefCount(raptorTeamID, defID))))) == 1 - and mRandom() < config.spawnChance - then - SpawnMinions(unitID, defID) + local defID + -- constant 1/33 roll first so the def lookups only run for units that pass it; + -- 1/33 * 1/count keeps the old 1/(33*count) odds + if mRandom(1, 33) == 1 then + defID = GetUnitDefID(unitID) + if + defID + and mRandom(1, math.max(1, GetTeamUnitDefCount(raptorTeamID, defID))) == 1 + and mRandom() < config.spawnChance + then + SpawnMinions(unitID, defID) + end end if math.random(1, 10) == 1 and unitCowardCooldown[unitID] and (n > unitCowardCooldown[unitID]) then unitCowardCooldown[unitID] = nil @@ -2601,9 +2601,11 @@ if gadgetHandler:IsSyncedCode() then unitSquadTable[unitID] = nil end - for index, _ in ipairs(squadsTable) do - if squadsTable[index].squadBurrow == unitID then - squadsTable[index].squadBurrow = nil + if unitTeam == raptorTeamID then -- squadBurrow is always one of our own units + for index, _ in ipairs(squadsTable) do + if squadsTable[index].squadBurrow == unitID then + squadsTable[index].squadBurrow = nil + end end end @@ -2697,6 +2699,9 @@ if gadgetHandler:IsSyncedCode() then if unitTeleportCooldown[unitID] then unitTeleportCooldown[unitID] = nil end + unitCowardCooldown[unitID] = nil + UnitReactionsTimeout[unitID] = nil + UnitLifetimeResetTimeout[unitID] = nil if unitTeam ~= raptorTeamID and config.ecoBuildingsPenalty[unitDefID] then playerAggressionEcoValue = playerAggressionEcoValue - (config.ecoBuildingsPenalty[unitDefID] / (config.queenTime / 3600)) -- scale to 60minutes = 3600seconds queen time @@ -2704,7 +2709,10 @@ if gadgetHandler:IsSyncedCode() then end function gadget:TeamDied(teamID) - humanTeams[teamID] = nil + if humanTeams[teamID] then + humanTeams[teamID] = nil + humanTeamCount = humanTeamCount - 1 + end --computerTeams[teamID] = nil end diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/IronFist_Defences.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/IronFist_Defences.lua index dd902fe625c..7d57a25f108 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/IronFist_Defences.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/IronFist_Defences.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames local function blueprint0() return { diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/KrashKourse_land.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/KrashKourse_land.lua index 8680d9c7194..94a957371d9 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/KrashKourse_land.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/KrashKourse_land.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames local function IRON_BEAM_RING() return { @@ -572,6 +571,7 @@ local function Power_fort_2() } end +--[[ local function T1_short_def() return { type = types.Land, @@ -588,6 +588,7 @@ local function T1_short_def() }, } end +]] local function Punisher_wall() return { diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/KrashKourse_sea.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/KrashKourse_sea.lua index d0006c9b6f4..b35ce2aa5e5 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/KrashKourse_sea.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/KrashKourse_sea.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames local function WATER_OUTPOST() return { diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/Nikuksis_land.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/Nikuksis_land.lua index 3f11f685eaf..2d2f9a69a30 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/Nikuksis_land.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/Nikuksis_land.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames local function Nikuksis_land0() return { diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_HLT_defences.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_HLT_defences.lua index 9e27b6a1d40..de990933a8b 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_HLT_defences.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_HLT_defences.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames -- facing: -- 0 - south diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_Jammers.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_Jammers.lua index 32445a5b428..54321daa45f 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_Jammers.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_Jammers.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames -- facing: -- 0 - south diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_LLT_defences.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_LLT_defences.lua index c75bb1d3548..db57260d16f 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_LLT_defences.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_LLT_defences.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames -- facing: -- 0 - south diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_T2_Eco.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_T2_Eco.lua index efcfb5e0209..5fb402f0fc2 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_T2_Eco.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_T2_Eco.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames -- facing: -- 0 - south diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_epic_defences.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_epic_defences.lua index df3d6dd7353..18007bcea65 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_epic_defences.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_epic_defences.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames -- facing: -- 0 - south diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_factory_centers_2.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_factory_centers_2.lua index 5ff7d66b5b3..34678fe5d6a 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_factory_centers_2.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_factory_centers_2.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames -- facing: -- 0 - south diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_shielded_LRPCs.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_shielded_LRPCs.lua index fe80037b93c..f33bd7c52a8 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_shielded_LRPCs.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_shielded_LRPCs.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames -- facing: -- 0 - south diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_tacnukes.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_tacnukes.lua index 8945360ee5c..adf96c66da8 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_tacnukes.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_tacnukes.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames -- facing: -- 0 - south diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_tiny_defences_T1.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_tiny_defences_T1.lua index a5cd34e53e5..d229db176e0 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_tiny_defences_T1.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/damgam_tiny_defences_T1.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames -- facing: -- 0 - south @@ -275,7 +274,7 @@ local function tinyDefences18() }, } end - +--[[ local function tinyDefences19() return { type = types.Land, @@ -289,7 +288,9 @@ local function tinyDefences19() }, } end +]] +--[[ local function tinyDefences20() return { type = types.Land, @@ -303,6 +304,7 @@ local function tinyDefences20() }, } end +]] local function tinyDefences21() return { diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/hermano_T2_Eco.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/hermano_T2_Eco.lua index 6eea3d2d727..7910f2ac655 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/hermano_T2_Eco.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/hermano_T2_Eco.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames -- facing: -- 0 - south diff --git a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/link_sea.lua b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/link_sea.lua index 9e4e718237a..2bd57e6e0ce 100644 --- a/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/link_sea.lua +++ b/luarules/gadgets/ruins/Blueprints/BYAR/Blueprints/link_sea.lua @@ -2,7 +2,6 @@ local blueprintConfig = VFS.Include("luarules/gadgets/ruins/Blueprints/" .. Game.gameShortName .. "/blueprint_tiers.lua") local tiers = blueprintConfig.Tiers local types = blueprintConfig.BlueprintTypes -local UDN = UnitDefNames local function t1RadarOutpost() return { diff --git a/luarules/gadgets/scav_lootbox_collector.lua b/luarules/gadgets/scav_lootbox_collector.lua index 7c155778c31..6090f0f7838 100644 --- a/luarules/gadgets/scav_lootbox_collector.lua +++ b/luarules/gadgets/scav_lootbox_collector.lua @@ -113,7 +113,6 @@ function gadget:GameFrame(frame) end if frame - math.ceil(18000 / aliveLootboxesCount) > lastTransportSentFrame then -- 10 minutes for 1 lootbox alive local targetLootboxID = -1 - local loopCount = 0 local success = false for lootboxID, lootboxTier in pairs(aliveLootboxes) do local lootboxPosX, lootboxPosY, lootboxPosZ = Spring.GetUnitPosition(lootboxID) diff --git a/luarules/gadgets/scav_spawner_defense.lua b/luarules/gadgets/scav_spawner_defense.lua index c3a427de4f1..992eb794b5e 100644 --- a/luarules/gadgets/scav_spawner_defense.lua +++ b/luarules/gadgets/scav_spawner_defense.lua @@ -37,7 +37,6 @@ if gadgetHandler:IsSyncedCode() then local ValidUnitID = Spring.ValidUnitID local GetUnitNeutral = Spring.GetUnitNeutral local GetTeamList = Spring.GetTeamList - local GetTeamLuaAI = Spring.GetTeamLuaAI local GetGaiaTeamID = Spring.GetGaiaTeamID local SetGameRulesParam = Spring.SetGameRulesParam local GetGameRulesParam = Spring.GetGameRulesParam @@ -72,6 +71,8 @@ if gadgetHandler:IsSyncedCode() then local table = table local ipairs = ipairs local pairs = pairs + local modOptions = Spring.GetModOptions() + local SetListUtilities = VFS.Include("common/SetList.lua") local MAPSIZEX = Game.mapSizeX local MAPSIZEZ = Game.mapSizeZ @@ -81,7 +82,7 @@ if gadgetHandler:IsSyncedCode() then Spring.SetGameRulesParam("BossFightStarted", 0) local nKilledBosses = 0 local nSpawnedBosses = 0 - local nTotalBosses = Spring.GetModOptions().scav_boss_count or 1 + local nTotalBosses = modOptions.scav_boss_count or 1 local maxTries = 30 local scavUnitCap = math.floor(Game.maxUnits * 0.80) local minBurrows = 1 @@ -137,6 +138,14 @@ if gadgetHandler:IsSyncedCode() then }, } local squadSpawnOptions = config.squadSpawnOptionsTable + local commanderOptionCount = 0 + for _ in pairs(squadSpawnOptions.commanders) do + commanderOptionCount = commanderOptionCount + 1 + end + local decoyCommanderOptionCount = 0 + for _ in pairs(squadSpawnOptions.decoyCommanders) do + decoyCommanderOptionCount = decoyCommanderOptionCount + 1 + end --local miniBossCooldown = 0 local firstSpawn = true local fullySpawned = false @@ -163,16 +172,17 @@ if gadgetHandler:IsSyncedCode() then local burrows = {} local squadsTable = {} local unitSquadTable = {} - local squadPotentialTarget = {} - local squadPotentialHighValueTarget = {} + local squadPotentialTarget = SetListUtilities.NewSetList() -- immobile player units, O(1) random pick + local squadPotentialHighValueTarget = SetListUtilities.NewSetList() local unitTargetPool = {} local unitCowardCooldown = {} local unitTeleportCooldown = {} capturableUnits = {} + local capturableScratch = {} -- reused snapshot buffer for the capture pass local squadCreationQueue = { units = {}, role = false, - life = math.ceil(10 * Spring.GetModOptions().scav_spawntimemult), + life = math.ceil(10 * modOptions.scav_spawntimemult), regroupenabled = true, regrouping = false, needsregroup = false, @@ -181,7 +191,7 @@ if gadgetHandler:IsSyncedCode() then squadCreationQueueDefaults = { units = {}, role = false, - life = math.ceil(10 * Spring.GetModOptions().scav_spawntimemult), + life = math.ceil(10 * modOptions.scav_spawntimemult), regroupenabled = true, regrouping = false, needsregroup = false, @@ -189,12 +199,14 @@ if gadgetHandler:IsSyncedCode() then } UnitDefStaggerMultiplier = {} + local captureProgressBase = {} -- per-def part of the capture speed formula (see the capture pass in GameFrame) for udefID, def in ipairs(UnitDefs) do if def.customParams.bossStaggerMultiplier then UnitDefStaggerMultiplier[udefID] = tonumber(def.customParams.bossStaggerMultiplier) else UnitDefStaggerMultiplier[udefID] = 1 end + captureProgressBase[udefID] = 0.016667 * (3 / math.ceil(math.sqrt(math.sqrt(def.health)))) end CommandersPopulation = 0 @@ -232,6 +244,10 @@ if gadgetHandler:IsSyncedCode() then end humanTeams[gaiaTeamID] = nil + local humanTeamCount = 0 + for _ in pairs(humanTeams) do + humanTeamCount = humanTeamCount + 1 + end function PutScavAlliesInScavTeam(n) local players = Spring.GetPlayerList() @@ -295,45 +311,22 @@ if gadgetHandler:IsSyncedCode() then end function getRandomEnemyPos() - local loops = 0 - local targetCount = SetCount(squadPotentialTarget) - local highValueTargetCount = SetCount(squadPotentialHighValueTarget) - local pos = {} - local pickedTarget = nil + local highValueTargetCount = squadPotentialHighValueTarget.count local highValueTargetPickChance = math.min(0.75, highValueTargetCount * 0.15) - repeat - loops = loops + 1 + for _ = 1, 10 do + local target if highValueTargetCount > 0 and mRandom() <= highValueTargetPickChance then - for target in pairs(squadPotentialHighValueTarget) do - if mRandom(1, highValueTargetCount) == 1 then - if ValidUnitID(target) and not GetUnitIsDead(target) and not GetUnitNeutral(target) then - local x, y, z = Spring.GetUnitPosition(target) - pos = { x = x + mRandom(-32, 32), y = y, z = z + mRandom(-32, 32) } - pickedTarget = target - break - end - end - end + target = squadPotentialHighValueTarget:GetRandom() else - for target in pairs(squadPotentialTarget) do - if mRandom(1, targetCount) == 1 then - if ValidUnitID(target) and not GetUnitIsDead(target) and not GetUnitNeutral(target) then - local x, y, z = Spring.GetUnitPosition(target) - pos = { x = x + mRandom(-32, 32), y = y, z = z + mRandom(-32, 32) } - pickedTarget = target - break - end - end - end + target = squadPotentialTarget:GetRandom() + end + if target and ValidUnitID(target) and not GetUnitIsDead(target) and not GetUnitNeutral(target) then + local x, y, z = GetUnitPosition(target) + return { x = x + mRandom(-32, 32), y = y, z = z + mRandom(-32, 32) }, target end - - until pos.x or loops >= 10 - - if not pos.x then - pos = getRandomMapPos() end - return pos, pickedTarget + return getRandomMapPos(), nil end function setScavXP(unitID) @@ -352,16 +345,16 @@ if gadgetHandler:IsSyncedCode() then config.gracePeriodInitial = config.gracePeriod + 0 local maxBurrows = ( (config.maxBurrows * (1 - config.scavPerPlayerMultiplier)) - + (config.maxBurrows * config.scavPerPlayerMultiplier) * (math.min(SetCount(humanTeams), 8)) + + (config.maxBurrows * config.scavPerPlayerMultiplier) * (math.min(humanTeamCount, 8)) ) * config.scavSpawnMultiplier local bossTime = (config.bossTime + config.gracePeriod) local maxWaveSize = ( (config.maxScavs * (1 - config.scavPerPlayerMultiplier)) - + (config.maxScavs * config.scavPerPlayerMultiplier) * SetCount(humanTeams) + + (config.maxScavs * config.scavPerPlayerMultiplier) * humanTeamCount ) * config.scavSpawnMultiplier local minWaveSize = ( (config.minScavs * (1 - config.scavPerPlayerMultiplier)) - + (config.minScavs * config.scavPerPlayerMultiplier) * SetCount(humanTeams) + + (config.minScavs * config.scavPerPlayerMultiplier) * humanTeamCount ) * config.scavSpawnMultiplier local currentMaxWaveSize = minWaveSize local endlessLoopCounter = 1 @@ -421,15 +414,15 @@ if gadgetHandler:IsSyncedCode() then bossTime = (config.bossTime + config.gracePeriod) maxBurrows = ( (config.maxBurrows * (1 - config.scavPerPlayerMultiplier)) - + (config.maxBurrows * config.scavPerPlayerMultiplier) * (math.min(SetCount(humanTeams), 8)) + + (config.maxBurrows * config.scavPerPlayerMultiplier) * (math.min(humanTeamCount, 8)) ) * config.scavSpawnMultiplier maxWaveSize = ( (config.maxScavs * (1 - config.scavPerPlayerMultiplier)) - + (config.maxScavs * config.scavPerPlayerMultiplier) * SetCount(humanTeams) + + (config.maxScavs * config.scavPerPlayerMultiplier) * humanTeamCount ) * config.scavSpawnMultiplier minWaveSize = ( (config.minScavs * (1 - config.scavPerPlayerMultiplier)) - + (config.minScavs * config.scavPerPlayerMultiplier) * SetCount(humanTeams) + + (config.minScavs * config.scavPerPlayerMultiplier) * humanTeamCount ) * config.scavSpawnMultiplier config.scavSpawnRate = nextDifficulty.scavSpawnRate currentMaxWaveSize = minWaveSize @@ -485,6 +478,7 @@ if gadgetHandler:IsSyncedCode() then ]] function squadManagerKillerLoop() -- Kills squads that have been alive for too long (most likely stuck somewhere on the map) --squadsTable + local burrowCount = SetCount(burrows) for i = 1, #squadsTable do squadsTable[i].squadLife = squadsTable[i].squadLife - 1 if squadsTable[i].squadLife < 3 and squadsTable[i].squadRegroupEnabled then @@ -494,7 +488,7 @@ if gadgetHandler:IsSyncedCode() then if squadsTable[i].squadLife == 0 then -- Spring.Echo("Life is 0, time to do some killing") - if SetCount(squadsTable[i].squadUnits) > 0 and SetCount(burrows) > 2 then + if #squadsTable[i].squadUnits > 0 and burrowCount > 2 then if squadsTable[i].squadBurrow and nSpawnedBosses == 0 then if Spring.GetUnitIsDead(squadsTable[i].squadBurrow) == false then squadsTable[i].squadBurrow = nil @@ -526,7 +520,7 @@ if gadgetHandler:IsSyncedCode() then function squadCommanderGiveOrders(squadID, targetx, targety, targetz) local units = squadsTable[squadID].squadUnits local role = squadsTable[squadID].squadRole - if SetCount(units) > 0 and squadsTable[squadID].target and squadsTable[squadID].target.x then + if #units > 0 and squadsTable[squadID].target and squadsTable[squadID].target.x then if squadsTable[squadID].squadRegroupEnabled then local xmin = 999999 local xmax = 0 @@ -704,7 +698,7 @@ if gadgetHandler:IsSyncedCode() then else for i = 1, #squadsTable do -- Spring.Echo("Attempt to recycle squad #" .. i .. ". Containing " .. SetCount(squadsTable[i].squadUnits) .. " units.") - if SetCount(squadsTable[i].squadUnits) == 0 then -- Yes, we found one empty squad to recycle + if #squadsTable[i].squadUnits == 0 then -- Yes, we found one empty squad to recycle squadID = i -- Spring.Echo("Recycled squad, #".. squadID) break @@ -725,7 +719,7 @@ if gadgetHandler:IsSyncedCode() then role = newSquad.role end if not newSquad.life then - newSquad.life = math.ceil(10 * Spring.GetModOptions().scav_spawntimemult) + newSquad.life = math.ceil(10 * modOptions.scav_spawntimemult) end squadsTable[squadID] = { @@ -742,7 +736,7 @@ if gadgetHandler:IsSyncedCode() then -- Spring.Echo("Created Scav Squad, containing " .. #squadsTable[squadID].squadUnits .. " units!") -- Spring.Echo("Role: " .. squadsTable[squadID].squadRole) -- Spring.Echo("Lifetime: " .. squadsTable[squadID].squadLife) - for i = 1, SetCount(squadsTable[squadID].squadUnits) do + for i = 1, #squadsTable[squadID].squadUnits do local unitID = squadsTable[squadID].squadUnits[i] unitSquadTable[unitID] = squadID -- Spring.Echo("#".. i ..", ID: ".. unitID .. ", Name:" .. UnitDefs[Spring.GetUnitDefID(unitID)].name) @@ -758,17 +752,8 @@ if gadgetHandler:IsSyncedCode() then function manageAllSquads() -- Get new target for all squads that need it for i = 1, #squadsTable do - if mRandom(1, 100) == 1 then - local hasTarget = false - for squad, target in pairs(unitTargetPool) do - if i == squad then - hasTarget = true - break - end - end - if not hasTarget then - refreshSquad(i) - end + if mRandom(1, 100) == 1 and unitTargetPool[i] == nil then + refreshSquad(i) end end end @@ -1015,13 +1000,13 @@ if gadgetHandler:IsSyncedCode() then for name, data in pairs(squadSpawnOptions.commanders) do if mRandom() <= config.spawnChance - and mRandom(1, SetCount(squadSpawnOptions.commanders)) == 1 + and mRandom(1, commanderOptionCount) == 1 and not waveParameters.commanders.waveCommanders[name] and data.minAnger <= waveParameters.waveTechAnger and data.maxAnger >= waveParameters.waveTechAnger and Spring.GetTeamUnitDefCount(scavTeamID, UnitDefNames[name].id) < data.maxAlive and CommandersPopulation + waveParameters.commanders.waveCommanderCount - < SetCount(humanTeams) * 0.5 * (techAnger * 0.01) + < humanTeamCount * 0.5 * (techAnger * 0.01) then waveParameters.commanders.waveCommanders[name] = true waveParameters.commanders.waveCommanderCount = waveParameters.commanders.waveCommanderCount + 1 @@ -1033,13 +1018,13 @@ if gadgetHandler:IsSyncedCode() then for name, data in pairs(squadSpawnOptions.decoyCommanders) do if mRandom() <= config.spawnChance - and mRandom(1, SetCount(squadSpawnOptions.decoyCommanders)) == 1 + and mRandom(1, decoyCommanderOptionCount) == 1 and not waveParameters.commanders.waveDecoyCommanders[name] and data.minAnger <= waveParameters.waveTechAnger and data.maxAnger >= waveParameters.waveTechAnger and Spring.GetTeamUnitDefCount(scavTeamID, UnitDefNames[name].id) < data.maxAlive and DecoyCommandersPopulation + waveParameters.commanders.waveDecoyCommanderCount - < SetCount(humanTeams) * 0.5 * (techAnger * 0.01) + < humanTeamCount * 0.5 * (techAnger * 0.01) then waveParameters.commanders.waveDecoyCommanders[name] = true waveParameters.commanders.waveDecoyCommanderCount = waveParameters.commanders.waveDecoyCommanderCount @@ -1345,7 +1330,7 @@ if gadgetHandler:IsSyncedCode() then end end - if SetCount(bossIDs) > 0 then + if next(bossIDs) ~= nil then if bossStagger.currentlyStaggered == false then if bossStagger.CurrentHealth > 0 then SetGameRulesParam( @@ -1696,6 +1681,7 @@ if gadgetHandler:IsSyncedCode() then waveParameters.waveTechAnger = math.min(999, techAnger * dynamicDifficultyClamped) waveParameters.waveSizeMultiplier = waveParameters.waveSizeMultiplier * dynamicDifficultyClamped + local burrowSurfaces = {} -- LandOrSeaCheck per burrow, cached for the duration of this wave repeat loopCounter = loopCounter + 1 for burrowID in pairs(burrows) do @@ -1704,8 +1690,12 @@ if gadgetHandler:IsSyncedCode() then local airRandom = mRandom(1, 100) local specialRandom = mRandom(1, 100) local squad - local burrowX, burrowY, burrowZ = Spring.GetUnitPosition(burrowID) - local surface = positionCheckLibrary.LandOrSeaCheck(burrowX, burrowY, burrowZ, config.burrowSize) + local surface = burrowSurfaces[burrowID] + if not surface then + local burrowX, burrowY, burrowZ = Spring.GetUnitPosition(burrowID) + surface = positionCheckLibrary.LandOrSeaCheck(burrowX, burrowY, burrowZ, config.burrowSize) + burrowSurfaces[burrowID] = surface + end if waveParameters.waveTechAnger > config.airStartAnger and airRandom <= waveParameters.waveAirPercentage @@ -1875,13 +1865,13 @@ if gadgetHandler:IsSyncedCode() then for name, data in pairs(squadSpawnOptions.commanders) do if mRandom() <= config.spawnChance - and mRandom(1, SetCount(squadSpawnOptions.commanders)) == 1 + and mRandom(1, commanderOptionCount) == 1 and not waveParameters.commanders.waveCommanders[name] and data.minAnger <= waveParameters.waveTechAnger and data.maxAnger >= waveParameters.waveTechAnger and Spring.GetTeamUnitDefCount(scavTeamID, UnitDefNames[name].id) < data.maxAlive and CommandersPopulation + waveParameters.commanders.waveCommanderCount - < SetCount(humanTeams) * (techAnger * 0.01) + < humanTeamCount * (techAnger * 0.01) then waveParameters.commanders.waveCommanders[name] = true waveParameters.commanders.waveCommanderCount = waveParameters.commanders.waveCommanderCount @@ -1898,14 +1888,14 @@ if gadgetHandler:IsSyncedCode() then for name, data in pairs(squadSpawnOptions.decoyCommanders) do if mRandom() <= config.spawnChance - and mRandom(1, SetCount(squadSpawnOptions.decoyCommanders)) == 1 + and mRandom(1, decoyCommanderOptionCount) == 1 and not waveParameters.commanders.waveDecoyCommanders[name] and data.minAnger <= waveParameters.waveTechAnger and data.maxAnger >= waveParameters.waveTechAnger and Spring.GetTeamUnitDefCount(scavTeamID, UnitDefNames[name].id) < data.maxAlive and DecoyCommandersPopulation + waveParameters.commanders.waveDecoyCommanderCount - < SetCount(humanTeams) * (techAnger * 0.01) + < humanTeamCount * (techAnger * 0.01) then waveParameters.commanders.waveDecoyCommanders[name] = true waveParameters.commanders.waveDecoyCommanderCount = waveParameters.commanders.waveDecoyCommanderCount @@ -1924,7 +1914,7 @@ if gadgetHandler:IsSyncedCode() then -- local squad = squadSpawnOptions.frontbusters[math.random(1, #squadSpawnOptions.frontbusters)] -- if squad and squad.surface and ((surface == "land" and squad.surface ~= "sea") or (surface == "sea" and squad.surface ~= "land")) then -- if mRandom() <= config.spawnChance and (not waveParameters.frontbusters.units[squad.name]) and squad.minAnger <= waveParameters.waveTechAnger and squad.maxAnger >= waveParameters.waveTechAnger and Spring.GetTeamUnitDefCount(scavTeamID, UnitDefNames[squad.name].id) < squad.maxAlive and waveParameters.frontbusters.unitCount == 0 then - -- for i = 1, math.ceil(squad.squadSize*config.spawnChance*((SetCount(humanTeams)*config.scavPerPlayerMultiplier)+(1-config.scavPerPlayerMultiplier))) do + -- for i = 1, math.ceil(squad.squadSize*config.spawnChance*((humanTeamCount*config.scavPerPlayerMultiplier)+(1-config.scavPerPlayerMultiplier))) do -- waveParameters.frontbusters.units[squad.name] = true -- waveParameters.frontbusters.unitCount = waveParameters.frontbusters.unitCount + 1 -- table.insert(spawnQueue, { burrow = burrowID, unitName = squad.name, team = scavTeamID, squadID = 1, alwaysVisible = true }) @@ -2053,9 +2043,9 @@ if gadgetHandler:IsSyncedCode() then then local numOfTurrets = (uSettings.spawnedPerWave * (1 - config.scavPerPlayerMultiplier)) + (uSettings.spawnedPerWave * config.scavPerPlayerMultiplier) - * (math.min(SetCount(humanTeams), 8)) + * (math.min(humanTeamCount, 8)) local maxExisting = (uSettings.maxExisting * (1 - config.scavPerPlayerMultiplier)) - + (uSettings.maxExisting * config.scavPerPlayerMultiplier) * (math.min(SetCount(humanTeams), 8)) + + (uSettings.maxExisting * config.scavPerPlayerMultiplier) * (math.min(humanTeamCount, 8)) local maxAllowedToSpawn if waveParameters.waveTechAnger <= 100 then -- i don't know how this works but it does. scales maximum amount of turrets allowed to spawn with techAnger. maxAllowedToSpawn = math.ceil( @@ -2186,14 +2176,12 @@ if gadgetHandler:IsSyncedCode() then end capturableUnits[unitID] = true - if squadPotentialTarget[unitID] or squadPotentialHighValueTarget[unitID] then - squadPotentialTarget[unitID] = nil - squadPotentialHighValueTarget[unitID] = nil - end + squadPotentialTarget:Remove(unitID) + squadPotentialHighValueTarget:Remove(unitID) if not UnitDefs[unitDefID].canMove then - squadPotentialTarget[unitID] = true + squadPotentialTarget:Add(unitID) if config.highValueTargets[unitDefID] then - squadPotentialHighValueTarget[unitID] = true + squadPotentialHighValueTarget:Add(unitID) end end if config.ecoBuildingsPenalty[unitDefID] then @@ -2500,6 +2488,7 @@ if gadgetHandler:IsSyncedCode() then (unitTeam == scavTeamID or attackerTeam == scavTeamID) and UnitLifetimeResetTimeout[unitID] < GetGameSeconds - 60 then + UnitLifetimeResetTimeout[unitID] = GetGameSeconds if unitID and unitSquadTable[unitID] @@ -2617,8 +2606,8 @@ if gadgetHandler:IsSyncedCode() then if config.scavBehaviours.HEALER[UnitDefNames[defs.unitName].id] then squadCreationQueue.role = "healer" squadCreationQueue.regroupenabled = false - if squadCreationQueue.life < math.ceil(20 * Spring.GetModOptions().scav_spawntimemult) then - squadCreationQueue.life = math.ceil(20 * Spring.GetModOptions().scav_spawntimemult) + if squadCreationQueue.life < math.ceil(20 * modOptions.scav_spawntimemult) then + squadCreationQueue.life = math.ceil(20 * modOptions.scav_spawntimemult) end end if config.scavBehaviours.ARTILLERY[UnitDefNames[defs.unitName].id] then @@ -2628,15 +2617,15 @@ if gadgetHandler:IsSyncedCode() then if config.scavBehaviours.KAMIKAZE[UnitDefNames[defs.unitName].id] then squadCreationQueue.role = "kamikaze" squadCreationQueue.regroupenabled = false - if squadCreationQueue.life < math.ceil(100 * Spring.GetModOptions().scav_spawntimemult) then - squadCreationQueue.life = math.ceil(100 * Spring.GetModOptions().scav_spawntimemult) + if squadCreationQueue.life < math.ceil(100 * modOptions.scav_spawntimemult) then + squadCreationQueue.life = math.ceil(100 * modOptions.scav_spawntimemult) end end if UnitDefNames[defs.unitName].canFly then squadCreationQueue.role = "aircraft" squadCreationQueue.regroupenabled = false - if squadCreationQueue.life < math.ceil(100 * Spring.GetModOptions().scav_spawntimemult) then - squadCreationQueue.life = math.ceil(100 * Spring.GetModOptions().scav_spawntimemult) + if squadCreationQueue.life < math.ceil(100 * modOptions.scav_spawntimemult) then + squadCreationQueue.life = math.ceil(100 * modOptions.scav_spawntimemult) end end if defs.alwaysVisible then @@ -2788,23 +2777,23 @@ if gadgetHandler:IsSyncedCode() then * (config.bossFightWaveSizeScale * 0.01) ) end - if pastFirstBoss or Spring.GetModOptions().scav_graceperiodmult <= 1 then + if pastFirstBoss or modOptions.scav_graceperiodmult <= 1 then techAnger = (t - config.gracePeriodInitial) - / ((bossTime / Spring.GetModOptions().scav_bosstimemult) - config.gracePeriodInitial) + / ((bossTime / modOptions.scav_bosstimemult) - config.gracePeriodInitial) * 100 else - techAnger = (t - (config.gracePeriodInitial / Spring.GetModOptions().scav_graceperiodmult)) - / ((bossTime / Spring.GetModOptions().scav_bosstimemult) - (config.gracePeriodInitial / Spring.GetModOptions().scav_graceperiodmult)) + techAnger = (t - (config.gracePeriodInitial / modOptions.scav_graceperiodmult)) + / ((bossTime / modOptions.scav_bosstimemult) - (config.gracePeriodInitial / modOptions.scav_graceperiodmult)) * 100 end - --techAnger = (t - config.gracePeriodInitial) / ((bossTime/(Spring.GetModOptions().scav_bosstimemult)) - config.gracePeriodInitial) * 100 + --techAnger = (t - config.gracePeriodInitial) / ((bossTime/(modOptions.scav_bosstimemult)) - config.gracePeriodInitial) * 100 techAnger = math.ceil(techAnger * ((config.economyScale * 0.5) + 0.5)) techAnger = math.clamp(techAnger, 0, 999) if t < config.gracePeriodInitial then bossAnger = 0 minBurrows = - math.ceil(math.max(4, 2 * (math.min(SetCount(humanTeams), 8))) * (t / config.gracePeriodInitial)) + math.ceil(math.max(4, 2 * (math.min(humanTeamCount, 8))) * (t / config.gracePeriodInitial)) else if nSpawnedBosses == 0 then bossAnger = math.max( @@ -2818,7 +2807,7 @@ if gadgetHandler:IsSyncedCode() then minBurrows = 1 else bossAnger = 100 - if Spring.GetModOptions().scav_endless then + if modOptions.scav_endless then minBurrows = 4 else minBurrows = 1 @@ -2863,7 +2852,7 @@ if gadgetHandler:IsSyncedCode() then if t > config.gracePeriodInitial + 5 then if burrowCount > 0 - and SetCount(spawnQueue) == 0 + and next(spawnQueue) == nil and ((config.scavSpawnRate * waveParameters.waveTimeMultiplier) < (t - timeOfLastWave)) then Wave() @@ -2890,13 +2879,18 @@ if gadgetHandler:IsSyncedCode() then local scavs = GetTeamUnits(scavTeamID) for i = 1, #scavs do local unitID = scavs[i] - local defID = GetUnitDefID(unitID) - if - defID - and mRandom(1, math.ceil((33 * math.max(1, GetTeamUnitDefCount(scavTeamID, defID))))) == 1 - and mRandom() < config.spawnChance - then - SpawnMinions(unitID, defID) + local defID + -- constant 1/33 roll first so the def lookups only run for units that pass it; + -- 1/33 * 1/count keeps the old 1/(33*count) odds + if mRandom(1, 33) == 1 then + defID = GetUnitDefID(unitID) + if + defID + and mRandom(1, math.max(1, GetTeamUnitDefCount(scavTeamID, defID))) == 1 + and mRandom() < config.spawnChance + then + SpawnMinions(unitID, defID) + end end if math.random(1, 10) == 1 and unitCowardCooldown[unitID] and (n > unitCowardCooldown[unitID]) then unitCowardCooldown[unitID] = nil @@ -2918,6 +2912,7 @@ if gadgetHandler:IsSyncedCode() then end else local pos = getRandomEnemyPos() + defID = defID or GetUnitDefID(unitID) GiveOrderToUnit(unitID, CMD.STOP, {}, {}) if defID and config.scavBehaviours.HEALER[defID] then if mRandom() < 0.75 then @@ -3000,25 +2995,33 @@ if gadgetHandler:IsSyncedCode() then captureRuns = (captureRuns + 1) % 4 -- Removing and inserting a key to a table in next/pairs corrupts the iterator. - -- Copy the table and loop only the values that existed in it before the update. - local capturableIDs = {} + -- Snapshot the quarter handled this pass (into a reused buffer) and loop only those. + local capturableIDs = capturableScratch + local capturableCount = 0 for unitID in pairs(capturableUnits) do - capturableIDs[#capturableIDs + 1] = unitID + if unitID % 4 == captureRuns then + capturableCount = capturableCount + 1 + capturableIDs[capturableCount] = unitID + end end - for i = 1, #capturableIDs do + local techAngerCaptureMult = math.max(0.1, (techAnger / 100)) + local IsPosInRaptorScum = GG.IsPosInRaptorScum + for i = 1, capturableCount do local unitID = capturableIDs[i] - if capturableUnits[unitID] and unitID % 4 == captureRuns then + if capturableUnits[unitID] then + -- only units standing in scum can be captured, so check that before pulling health/def/team local ux, uy, uz = GetUnitPosition(unitID) - local health, maxHealth, _, captureLevel = GetUnitHealth(unitID) + local health, maxHealth, _, captureLevel + if ux and IsPosInRaptorScum(ux, uy, uz) then + health, maxHealth, _, captureLevel = GetUnitHealth(unitID) + end if health then - local captureProgress = 0.016667 - * (3 / math.ceil(math.sqrt(math.sqrt(UnitDefs[GetUnitDefID(unitID)].health)))) - * math.max(0.1, (techAnger / 100)) -- really wack formula that i really don't want to explain. + local captureProgress = captureProgressBase[GetUnitDefID(unitID)] * techAngerCaptureMult -- really wack formula that i really don't want to explain. if health < maxHealth then captureProgress = captureProgress / math.max(0.000001, (health / maxHealth) ^ 3) end captureProgress = math.min(0.05, captureProgress) - if Spring.GetUnitTeam(unitID) ~= scavTeamID and GG.IsPosInRaptorScum(ux, uy, uz) then + if Spring.GetUnitTeam(unitID) ~= scavTeamID then if captureLevel + captureProgress >= 0.99 then SpawnCEG("scavmist", ux, uy + 100, uz, 0, 0, 0) SpawnCEG("scavradiation", ux, uy + 100, uz, 0, 0, 0) @@ -3046,8 +3049,6 @@ if gadgetHandler:IsSyncedCode() then end GG.addUnitToCaptureDecay(unitID) end - elseif Spring.GetUnitTeam(unitID) == scavTeamID and captureLevel > 0 then - GG.addUnitToCaptureDecay(unitID) end end end @@ -3070,8 +3071,8 @@ if gadgetHandler:IsSyncedCode() then end if newTeam == scavTeamID then - squadPotentialTarget[unitID] = nil - squadPotentialHighValueTarget[unitID] = nil + squadPotentialTarget:Remove(unitID) + squadPotentialHighValueTarget:Remove(unitID) capturableUnits[unitID] = nil for squad in ipairs(unitTargetPool) do if unitTargetPool[squad] == unitID then @@ -3162,14 +3163,16 @@ if gadgetHandler:IsSyncedCode() then unitSquadTable[unitID] = nil end - for index, _ in ipairs(squadsTable) do - if squadsTable[index].squadBurrow == unitID then - squadsTable[index].squadBurrow = nil + if unitTeam == scavTeamID then -- squadBurrow is always one of our own units + for index, _ in ipairs(squadsTable) do + if squadsTable[index].squadBurrow == unitID then + squadsTable[index].squadBurrow = nil + end end end - squadPotentialTarget[unitID] = nil - squadPotentialHighValueTarget[unitID] = nil + squadPotentialTarget:Remove(unitID) + squadPotentialHighValueTarget:Remove(unitID) capturableUnits[unitID] = nil for squad in ipairs(unitTargetPool) do if unitTargetPool[squad] == unitID then @@ -3191,7 +3194,7 @@ if gadgetHandler:IsSyncedCode() then if nKilledBosses >= nTotalBosses then Spring.SetGameRulesParam("BossFightStarted", 0) - if Spring.GetModOptions().scav_endless then + if modOptions.scav_endless then updateDifficultyForSurvival() Spring.SetGameRulesParam("scavBossAnger", 0) Spring.SetGameRulesParam("scavBossHealth", 0) @@ -3255,6 +3258,9 @@ if gadgetHandler:IsSyncedCode() then if unitTeleportCooldown[unitID] then unitTeleportCooldown[unitID] = nil end + unitCowardCooldown[unitID] = nil + UnitReactionsTimeout[unitID] = nil + UnitLifetimeResetTimeout[unitID] = nil if unitTeam ~= scavTeamID and config.ecoBuildingsPenalty[unitDefID] then playerAggressionEcoValue = playerAggressionEcoValue - (config.ecoBuildingsPenalty[unitDefID] / (config.bossTime / 3600)) -- scale to 60minutes = 3600seconds boss time @@ -3272,7 +3278,10 @@ if gadgetHandler:IsSyncedCode() then end function gadget:TeamDied(teamID) - humanTeams[teamID] = nil + if humanTeams[teamID] then + humanTeams[teamID] = nil + humanTeamCount = humanTeamCount - 1 + end --computerTeams[teamID] = nil end diff --git a/luarules/gadgets/snd_notifications.lua b/luarules/gadgets/snd_notifications.lua index 91579419053..c7ce1e62684 100644 --- a/luarules/gadgets/snd_notifications.lua +++ b/luarules/gadgets/snd_notifications.lua @@ -60,8 +60,8 @@ if gadgetHandler:IsSyncedCode() then for unitDefID, unitDef in pairs(UnitDefs) do -- not critter/raptor/object if - not string.find(unitDef.name, "critter") - and not string.find(unitDef.name, "raptor") + not unitDef.customParams.iscritter + and not unitDef.customParams.israptor and (not unitDef.modCategories or not unitDef.modCategories.object) then if unitDef.extractsMetal >= 0.004 then @@ -153,21 +153,15 @@ else for unitDefID, unitDef in pairs(UnitDefs) do -- not critter/raptor/object if - not string.find(unitDef.name, "critter") - and not string.find(unitDef.name, "raptor") + not unitDef.customParams.iscritter + and not unitDef.customParams.israptor and (not unitDef.modCategories or not unitDef.modCategories.object) then isBuilding[unitDefID] = unitDef.isBuilding or unitDef.isFactory if unitDef.customParams.iscommander or unitDef.customParams.isscavcommander then isCommander[unitDefID] = true end - if - string.find(unitDef.name, "corint") - or string.find(unitDef.name, "armbrtha") - or string.find(unitDef.name, "corbuzz") - or string.find(unitDef.name, "armvulc") - or string.find(unitDef.name, "legstarfall") - then + if unitDef.customParams.islrpc then isLrpc[unitDefID] = true end if unitDef.isBuilding and unitDef.radarDistance > 1900 then diff --git a/luarules/gadgets/unit_air_plants.lua b/luarules/gadgets/unit_air_plants.lua index 65e29e082a8..0be1d5aef6a 100644 --- a/luarules/gadgets/unit_air_plants.lua +++ b/luarules/gadgets/unit_air_plants.lua @@ -24,31 +24,10 @@ local SetUnitNeutral = Spring.SetUnitNeutral local CMD_IDLEMODE = CMD.IDLEMODE local CMD_LAND_AT = GameCMD.LAND_AT -local isAirplantNames = { - corap = true, - coraap = true, - corplat = true, - corapt3 = true, - - armap = true, - armaap = true, - armplat = true, - armapt3 = true, - - legap = true, - legaap = true, - legapt3 = true, - legsplab = true, -} -local isAirplantNamesCopy = table.copy(isAirplantNames) -for name, v in pairs(isAirplantNamesCopy) do - isAirplantNames[name .. "_scav"] = true -end --- convert unitname -> unitDefID local isAirplant = {} -for unitName, params in pairs(isAirplantNames) do - if UnitDefNames[unitName] then - isAirplant[UnitDefNames[unitName].id] = params +for unitDefID, unitDef in pairs(UnitDefs) do + if unitDef.customParams.airfactory then + isAirplant[unitDefID] = true end end diff --git a/luarules/gadgets/unit_airunitsturnradius.lua b/luarules/gadgets/unit_airunitsturnradius.lua index 70671d97ee9..6f16aea9f93 100644 --- a/luarules/gadgets/unit_airunitsturnradius.lua +++ b/luarules/gadgets/unit_airunitsturnradius.lua @@ -20,7 +20,6 @@ local attackTurnRadius = 500 local CMD_ATTACK = CMD.ATTACK local spGetUnitCurrentCommand = Spring.GetUnitCurrentCommand -local spGetUnitMoveTypeData = Spring.GetUnitMoveTypeData local spMoveCtrlEnable = Spring.MoveCtrl.Enable local spMoveCtrlIsEnabled = Spring.MoveCtrl.IsEnabled local spMoveCtrlDisable = Spring.MoveCtrl.Disable diff --git a/luarules/gadgets/unit_areaattack.lua b/luarules/gadgets/unit_areaattack.lua index 1d919a7b1ad..4e672131c7a 100644 --- a/luarules/gadgets/unit_areaattack.lua +++ b/luarules/gadgets/unit_areaattack.lua @@ -19,20 +19,30 @@ local CMD_AREA_ATTACK_GROUND = GameCMD.AREA_ATTACK_GROUND if gadgetHandler:IsSyncedCode() then local attackList = {} local closeList = {} + local activeAttacks = {} local math_random = math.random local math_pi = math.pi local math_sqrt = math.sqrt local math_cos = math.cos local math_sin = math.sin + local math_max = math.max + local math_bit_and = math.bit_and local CMD_ATTACK = CMD.ATTACK + local CMD_OPT_INTERNAL = CMD.OPT_INTERNAL local reissueOrder = Game.Commands.ReissueOrder local canAreaAttack = {} + local areaAttackWeaponDefs = {} + local areaAttackWeaponDefByUnitDef = {} for unitDefID, unitDef in pairs(UnitDefs) do if #unitDef.weapons > 0 and unitDef.customParams.canareaattack then - canAreaAttack[unitDefID] = WeaponDefs[unitDef.weapons[1].weaponDef].range + local weaponDefID = unitDef.weapons[1].weaponDef + local weaponDef = WeaponDefs[weaponDefID] + canAreaAttack[unitDefID] = weaponDef.range + areaAttackWeaponDefs[weaponDefID] = true + areaAttackWeaponDefByUnitDef[unitDefID] = weaponDefID end end local range = canAreaAttack -- range per unitDefID, same data @@ -52,12 +62,14 @@ if gadgetHandler:IsSyncedCode() then local phase = math_random(200 * math_pi) / 100.0 if o.radius > 0 then local amp = math_random(o.radius) - Spring.GiveOrderToUnit( - o.unit, - CMD.INSERT, - { 0, CMD.ATTACK, 0, o.x + math_cos(phase) * amp, o.y, o.z + math_sin(phase) * amp }, - { "alt" } - ) + Spring.GiveOrderToUnit(o.unit, CMD.INSERT, { + 0, + CMD.ATTACK, + CMD_OPT_INTERNAL, + o.x + math_cos(phase) * amp, + o.y, + o.z + math_sin(phase) * amp, + }, { "alt" }) end end for i, o in pairs(closeList) do @@ -66,6 +78,31 @@ if gadgetHandler:IsSyncedCode() then end end + function gadget:GameFramePost(frame) + for unitID, attack in pairs(activeAttacks) do + if frame >= attack.checkFrame then + local salvoLeft = Spring.GetUnitWeaponState(unitID, 1, "salvoLeft") + if not salvoLeft then + activeAttacks[unitID] = nil + elseif salvoLeft > 0 then + local nextSalvo = Spring.GetUnitWeaponState(unitID, 1, "nextSalvo") + attack.checkFrame = math_max(nextSalvo or 0, frame + 1) + else + activeAttacks[unitID] = nil + local commandID, _, commandTag = Spring.GetUnitCurrentCommand(unitID) + if commandID == CMD_ATTACK and commandTag == attack.commandTag then + -- Internal commands are not requeued by normal command completion. + Spring.UnitFinishCommand(unitID) + else + -- A command can move ahead while the burst is active. Remove only + -- the generated attack in that case. + Spring.GiveOrderToUnit(unitID, CMD.REMOVE, { attack.commandTag }, 0) + end + end + end + end + end + function gadget:AllowCommand( unitID, unitDefID, @@ -98,7 +135,13 @@ if gadgetHandler:IsSyncedCode() then end local dist = math_sqrt((x - param[1]) * (x - param[1]) + (z - param[3]) * (z - param[3])) if dist <= range[ud] - param[4] then - attackList[#attackList + 1] = { unit = u, x = param[1], y = param[2], z = param[3], radius = param[4] } + attackList[#attackList + 1] = { + unit = u, + x = param[1], + y = param[2], + z = param[3], + radius = param[4], + } else closeList[#closeList + 1] = { unit = u, x = param[1], y = param[2], z = param[3], radius = range[ud] - param[4] } @@ -108,15 +151,47 @@ if gadgetHandler:IsSyncedCode() then return false end + function gadget:ProjectileCreated(projectileID, ownerID, weaponDefID) + if not areaAttackWeaponDefs[weaponDefID] or activeAttacks[ownerID] then + return + end + + local unitDefID = Spring.GetUnitDefID(ownerID) + if areaAttackWeaponDefByUnitDef[unitDefID] ~= weaponDefID then + return + end + + local commandID, commandOptions, commandTag, _, _, targetZ = Spring.GetUnitCurrentCommand(ownerID) + if commandID ~= CMD_ATTACK or targetZ == nil or math_bit_and(commandOptions, CMD_OPT_INTERNAL) == 0 then + return + end + if Spring.GetUnitCurrentCommand(ownerID, 2) ~= CMD_AREA_ATTACK_GROUND then + return + end + + -- Poll weapon state after simulation instead of counting every projectile. + activeAttacks[ownerID] = { + commandTag = commandTag, + checkFrame = Spring.GetGameFrame(), + } + end + function gadget:UnitCreated(u, ud, team) if canAreaAttack[ud] then Spring.InsertUnitCmdDesc(u, aadesc) end end + function gadget:UnitDestroyed(unitID) + activeAttacks[unitID] = nil + end + function gadget:Initialize() gadgetHandler:RegisterCMDID(CMD_AREA_ATTACK_GROUND) gadgetHandler:RegisterAllowCommand(CMD_AREA_ATTACK_GROUND) + for weaponDefID in pairs(areaAttackWeaponDefs) do + Script.SetWatchProjectile(weaponDefID, true) + end end else -- UNSYNCED function gadget:Initialize() diff --git a/luarules/gadgets/unit_areaattack_limiter.lua b/luarules/gadgets/unit_areaattack_limiter.lua index 2ebe7ec8c8c..d0aac292b0f 100644 --- a/luarules/gadgets/unit_areaattack_limiter.lua +++ b/luarules/gadgets/unit_areaattack_limiter.lua @@ -22,7 +22,6 @@ local CMD_STOP = CMD.STOP local spGetSelectedUnits = Spring.GetSelectedUnits local spGetUnitDefID = Spring.GetUnitDefID -local spGiveOrderArrayToUnitArray = Spring.GiveOrderArrayToUnitArray local isBombWeapon = {} for weaponDefID, weaponDef in pairs(WeaponDefs) do @@ -31,13 +30,13 @@ for weaponDefID, weaponDef in pairs(WeaponDefs) do end end +-- customparams.areaattack_unlimited: must classify identically in +-- cmd_exclude_walls_area_attacks.lua and cmd_bomber_attack_building_ground.lua local isBomberUnitDef = {} for unitDefID, unitDef in pairs(UnitDefs) do if (unitDef.weapons and unitDef.weapons[1] and isBombWeapon[unitDef.weapons[1].weaponDef]) - or string.find(unitDef.name, "armlance") - or string.find(unitDef.name, "cortitan") - or string.find(unitDef.name, "legatorpbomber") + or unitDef.customParams.areaattack_unlimited then isBomberUnitDef[unitDefID] = true end diff --git a/luarules/gadgets/unit_attached_con_turret.lua b/luarules/gadgets/unit_attached_con_turret.lua index bd6abf12b07..55186aa24ff 100644 --- a/luarules/gadgets/unit_attached_con_turret.lua +++ b/luarules/gadgets/unit_attached_con_turret.lua @@ -20,6 +20,7 @@ end local CMD_REPAIR = CMD.REPAIR local CMD_RECLAIM = CMD.RECLAIM local CMD_STOP = CMD.STOP +local SpGetFactoryCommands = Spring.GetFactoryCommands local SpGetUnitCommands = Spring.GetUnitCommands local SpGiveOrderToUnit = Spring.GiveOrderToUnit local SpGetUnitPosition = Spring.GetUnitPosition @@ -44,17 +45,15 @@ local SpGetUnitHeading = Spring.GetUnitHeading local SpCallCOBScript = Spring.CallCOBScript local SendToUnsynced = SendToUnsynced +local resolveAttachPiece = VFS.Include("luarules/gadgets/include/unit_attachments.lua").ResolveAttachPiece +local SpUnitAttach = Spring.UnitAttach + --repairs and reclaims start at the edge of the unit radius --so we need to increase our search radius by the maximum unit radius local max_unit_radius = 0 -function gadget:Initialize() - local radius = 0 - for ix, udef in pairs(UnitDefs) do - dimensions = SpGetUnitDefDimensions(udef.id) - radius = dimensions.radius - max_unit_radius = math.max(radius, max_unit_radius) - end -end +local attached_builders = {} ---@type table +local attached_turrets = {} ---@type table +local cobScriptTurrets = {} ---@type table local function auto_repair_routine(nanoID, unitDefID, baseUnitID) local transporterID = SpGetUnitTransporter(baseUnitID) @@ -63,7 +62,9 @@ local function auto_repair_routine(nanoID, unitDefID, baseUnitID) return end -- first, check command the body is performing - local commandQueue = SpGetUnitCommands(attached_builders[nanoID], 1) + local baseDefID = SpGetUnitDefID(baseUnitID) + local getQueue = (baseDefID and UnitDefs[baseDefID].isFactory) and SpGetFactoryCommands or SpGetUnitCommands + local commandQueue = getQueue(baseUnitID, 1) or {} if commandQueue[1] ~= nil and commandQueue[1].id < 0 then -- build command -- The attached turret must have the same buildlist as the body for this to work correctly @@ -98,8 +99,8 @@ local function auto_repair_routine(nanoID, unitDefID, baseUnitID) if commandQueue[1] ~= nil and commandQueue[1].id < 0 then -- out of range build command object_radius = SpGetUnitDefDimensions(-commandQueue[1].id).radius - distance = math.sqrt((ux - commandQueue[1].params[1]) ^ 2 + (uz - commandQueue[1].params[3]) ^ 2) - - object_radius + tx, tz = commandQueue[1].params[1], commandQueue[1].params[3] + distance = math.sqrt((ux - tx) ^ 2 + (uz - tz) ^ 2) - object_radius end if commandQueue[1] ~= nil and commandQueue[1].id == CMD_REPAIR then -- out of range repair command @@ -128,11 +129,12 @@ local function auto_repair_routine(nanoID, unitDefID, baseUnitID) end end if tx and distance <= radius then - --let auto con turret continue its thing - --update heading, by calling into unit script - heading1 = SpGetHeadingFromVector(ux - tx, uz - tz) - heading2 = SpGetUnitHeading(nanoID) - SpCallCOBScript(nanoID, "UpdateHeading", 0, heading1 - heading2 + 32768) + -- probably don't even need this for COB, but w/e: + if cobScriptTurrets[unitDefID] then + local heading1 = SpGetHeadingFromVector(ux - tx, uz - tz) + local heading2 = SpGetUnitHeading(nanoID) + SpCallCOBScript(nanoID, "UpdateHeading", 0, heading1 - heading2 + 32768) + end return end @@ -199,52 +201,108 @@ local function auto_repair_routine(nanoID, unitDefID, baseUnitID) SpGiveOrderToUnit(nanoID, CMD.STOP) end -attached_builders = {} -attached_builder_def = {} function gadget:UnitDestroyed(unitID, unitDefID, unitTeam, attackerID, attackerDefID, attackerTeam, weaponDefID) + local hostID = attached_builders[unitID] + if hostID then + attached_turrets[hostID] = nil + end attached_builders[unitID] = nil - attached_builder_def[unitID] = nil + + local nanoID = attached_turrets[unitID] + if nanoID then + attached_turrets[unitID] = nil + attached_builders[nanoID] = nil + end end -function gadget:UnitFinished(unitID, unitDefID, unitTeam) - local unitDef = UnitDefs[unitDefID] - -- for now, just corvac gets an attached con turret - if unitDef.name == "corvac" then - local xx, yy, zz = SpGetUnitPosition(unitID) - nanoID = Spring.CreateUnit("corvacct", xx, yy, zz, 0, Spring.GetUnitTeam(unitID)) - if not nanoID then - -- unit limit hit or invalid spawn surface - return +function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) + local nanoID = attached_turrets[unitID] + -- Best-effort since the engine can refuse transfer: + if nanoID and Spring.GetUnitTeam(nanoID) ~= newTeam then + Spring.TransferUnit(nanoID, newTeam) + end +end + +-- customparams.attached_con_turret names the turret def to spawn and attach on finish; +-- customparams.attached_con_turret_noselect additionally hides it from selection/groups +-- +-- By default, attached units are hidden; they are difficult to select and should not act +-- like a separate unit, rather as a "paired" set with one real and one virtual unit. +-- Scav copies inherit the params, but historically never got a turret, so are excluded. +local attachedTurretDef = {} -- unitDefID -> { con = defname, noSelect = bool } +local turretDefIDs = {} +for udid, ud in pairs(UnitDefs) do + local con = ud.customParams.attached_con_turret + if + con + and UnitDefNames[con] + and not ud.customParams.isscavenger + and not ud.customParams.attached_con_turret_mex + then + attachedTurretDef[udid] = { + con = con, + select = ud.customParams.attached_con_turret_select and true or false, + } + local turretDefID = UnitDefNames[con].id + turretDefIDs[turretDefID] = true + local scriptName = UnitDefs[turretDefID].scriptName + if scriptName and string.lower(scriptName):sub(-4) == ".cob" then + cobScriptTurrets[turretDefID] = true end - Spring.UnitAttach(unitID, nanoID, 3, true) - -- makes the attached con turret as non-interacting as possible - Spring.SetUnitBlocking(nanoID, false, false, false) - Spring.SetUnitNoSelect(nanoID, true) + end +end + +function gadget:UnitFinished(unitID, unitDefID, unitTeam) + local data = attachedTurretDef[unitDefID] + if not data then + return + end + + local piece = resolveAttachPiece(unitID) + if not piece then + return + end + + local xx, yy, zz = SpGetUnitPosition(unitID) + local nanoID = Spring.CreateUnit(data.con, xx, yy, zz, 0, Spring.GetUnitTeam(unitID)) + if not nanoID then + -- unit limit hit or invalid spawn surface + return + end + Spring.UnitAttach(unitID, nanoID, piece, true) + -- makes the attached con turret as non-interacting as possible + Spring.SetUnitBlocking(nanoID, false, false, false) + Spring.SetUnitNoSelect(nanoID, not data.select) + if not data.select then SendToUnsynced("setUnitNoGroup", nanoID, true) - attached_builders[nanoID] = unitID - attached_builder_def[nanoID] = SpGetUnitDefID(nanoID) - end - if unitDef.name == "legmohobp" then - local xx, yy, zz = SpGetUnitPosition(unitID) - nanoID = Spring.CreateUnit("legmohobpct", xx, yy, zz, 0, Spring.GetUnitTeam(unitID)) - if not nanoID then - -- unit limit hit or invalid spawn surface - return - end - Spring.UnitAttach(unitID, nanoID, 3, true) - -- makes the attached con turret as non-interacting as possible - Spring.SetUnitBlocking(nanoID, false, false, false) - Spring.SetUnitNoSelect(nanoID, false) - attached_builders[nanoID] = unitID - attached_builder_def[nanoID] = SpGetUnitDefID(nanoID) end + attached_builders[nanoID] = unitID + attached_turrets[unitID] = nanoID end function gadget:GameFrame(gameFrame) if gameFrame % 15 == 0 then -- go on a slowupdate cycle for nanoID, baseUnitID in pairs(attached_builders) do - auto_repair_routine(nanoID, attached_builder_def[nanoID], baseUnitID) + auto_repair_routine(nanoID, SpGetUnitDefID(nanoID), baseUnitID) + end + end +end + +function gadget:Initialize() + -- For /luarules reload, get max unit dims and reattach+register turrets. + local radius = 0 + for _, udef in pairs(UnitDefs) do + radius = SpGetUnitDefDimensions(udef.id).radius + max_unit_radius = math.max(radius, max_unit_radius) + end + for _, nanoID in pairs(Spring.GetAllUnits()) do + if turretDefIDs[SpGetUnitDefID(nanoID)] then + local hostID = SpGetUnitTransporter(nanoID) + if hostID and attachedTurretDef[SpGetUnitDefID(hostID)] then + attached_builders[nanoID] = hostID + attached_turrets[hostID] = nanoID + end end end end diff --git a/luarules/gadgets/unit_attached_con_turret_mex.lua b/luarules/gadgets/unit_attached_con_turret_mex.lua index 62c97867832..babb8f87013 100644 --- a/luarules/gadgets/unit_attached_con_turret_mex.lua +++ b/luarules/gadgets/unit_attached_con_turret_mex.lua @@ -20,17 +20,21 @@ end local spGetUnitHealth = Spring.GetUnitHealth local spGiveOrderToUnit = Spring.GiveOrderToUnit local SendToUnsynced = SendToUnsynced - --- TODO: do not use hardcoded unit names -local unitDefData = { - legmohocon = { mex = "legmohoconin", con = "legmohoconct" }, -} -for unitName, unitPair in pairs(unitDefData) do - if not unitName:find("_scav") then - unitDefData[unitName .. "_scav"] = { - mex = unitPair.mex .. "_scav", - con = unitPair.con .. "_scav", - } +local resolveAttachPiece = VFS.Include("luarules/gadgets/include/unit_attachments.lua").ResolveAttachPiece + +-- customparams.attached_con_turret_mex (the extractor def) + attached_con_turret (the con def) +-- mark builds that split into a mex plus an attached con turret; scav copies inherit the +-- params and get the _scav variants of both spawned defs +local unitDefData = {} +for udid, ud in pairs(UnitDefs) do + local con = ud.customParams.attached_con_turret + local mex = ud.customParams.attached_con_turret_mex + if con and mex then + if ud.customParams.isscavenger then + con = con .. "_scav" + mex = mex .. "_scav" + end + unitDefData[ud.name] = { mex = mex, con = con } end end @@ -87,7 +91,6 @@ local function doSwapMex(unitID, unitTeam, unitData) return end Spring.SetUnitBlocking(mexID, true, true, false) - Spring.SetUnitNoSelect(mexID, true) SendToUnsynced("setUnitNoGroup", mexID, true) Spring.SetUnitStealth(mexID, true) @@ -100,8 +103,22 @@ local function doSwapMex(unitID, unitTeam, unitData) end Spring.SetUnitHealth(conID, unitHealth) - -- TODO: Get attachment piece by customparam. - Spring.UnitAttach(mexID, conID, 6, true) + local piece = resolveAttachPiece(conID) + if not piece then + Spring.DestroyUnit(conID, false, true) + Spring.DestroyUnit(mexID, false, true) + Spring.AddTeamResource(unitTeam, "m", unitData.metal) + Spring.AddTeamResource(unitTeam, "e", unitData.energy) + return + end + + -- transported units can't be targeted, so the turret carries the mex + Spring.UnitAttach(conID, mexID, piece, true) + -- attaching resets these + Spring.SetUnitNoSelect(mexID, true) + Spring.SetUnitNoMinimap(mexID, true) + Spring.SetUnitIconDraw(mexID, false) + Spring.SetUnitNoDraw(mexID, true) Spring.SetUnitRulesParam(conID, "pairedUnitID", mexID) Spring.SetUnitRulesParam(mexID, "pairedUnitID", conID) pairedUnits[conID] = mexID diff --git a/luarules/gadgets/unit_attributes.lua b/luarules/gadgets/unit_attributes.lua index 9868e7fc794..dbe5fee6172 100644 --- a/luarules/gadgets/unit_attributes.lua +++ b/luarules/gadgets/unit_attributes.lua @@ -27,7 +27,6 @@ local UPDATE_PERIOD = 3 -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -local floor = math.floor local spValidUnitID = Spring.ValidUnitID local spGetUnitDefID = Spring.GetUnitDefID @@ -53,8 +52,6 @@ local WACKY_CONVERSION_FACTOR_1 = 2184.53 local HALF_FRAME = 1 / 60 local mathMin = math.min local mathFloor = math.floor -local mathCeil = math.ceil -local mathMax = math.max local workingGroundMoveType = true -- not ((Spring.GetModOptions() and (Spring.GetModOptions().pathfinder == "classic") and true) or false) diff --git a/luarules/gadgets/unit_builder_priority.lua b/luarules/gadgets/unit_builder_priority.lua index 8ce3c022d6d..40715fba81d 100644 --- a/luarules/gadgets/unit_builder_priority.lua +++ b/luarules/gadgets/unit_builder_priority.lua @@ -34,8 +34,8 @@ if not gadgetHandler:IsSyncedCode() then return end --- These values are supposedly engine-backed: -local stallMarginInc = 0.20 +-- Arbitrarily chosen heuristics to prevent stall in engine code +local stallMarginInc = 0.4 -- Builder priority is checked every 6 frames, so this represents a buffer of 2 cycles without stalling. local stallMarginSto = 0.01 local passiveCons = {} -- passiveCons[teamID][builderID] @@ -51,16 +51,18 @@ local currentBuildSpeed = {} --build speed of builderID for current interval, no local costID = {} -- costID[unitID] (contains all non-finished units) local ruleName = "builderPriority" -local CMD_PRIORITY = GameCMD.PRIORITY +local CMD_PRIORITY = GameCMD.PRIORITY ---@as integer local PRIORITY_LOW = 0 local PRIORITY_HIGH = 1 + +---@type CommandDescription local cmdPassiveDesc = { id = CMD_PRIORITY, name = "priority", action = "priority", type = CMDTYPE.ICON_MODE, tooltip = "Builder Mode: Low Priority restricts build when stalling on resources", - params = { PRIORITY_HIGH, "Low Prio", "High Prio" }, + params = { tostring(PRIORITY_HIGH), "Low Prio", "High Prio" }, } local spInsertUnitCmdDesc = Spring.InsertUnitCmdDesc @@ -72,6 +74,7 @@ local spGetTeamList = Spring.GetTeamList local spSetUnitRulesParam = Spring.SetUnitRulesParam local spGetUnitRulesParam = Spring.GetUnitRulesParam local spGetTeamRulesParam = Spring.GetTeamRulesParam +local spGetUnitResources = Spring.GetUnitResources local spSetUnitBuildSpeed = Spring.SetUnitBuildSpeed local spGetUnitIsBuilding = Spring.GetUnitIsBuilding local spValidUnitID = Spring.ValidUnitID @@ -92,6 +95,7 @@ local canPassive = {} -- canPassive[unitDefID] = nil / true local cost = {} -- cost[unitDefID] = { metal, energy, buildTime } local suspendBuilderPriority local teamsWithOwners = {} -- teams that have active buildTargetOwners entries +local converterEnergyUsageParamName = "mmUse" -- Reusable scratch tables to reduce GC pressure (cleared before each use) local _passiveMetal = {} @@ -141,7 +145,7 @@ function gadget:Initialize() local allUnits = spGetAllUnits() for i = 1, #allUnits do local unitID = allUnits[i] - gadget:UnitCreated(unitID, spGetUnitDefID(unitID), spGetUnitTeam(unitID)) + gadget:UnitCreated(unitID, spGetUnitDefID(unitID), spGetUnitTeam(unitID)) ---@diagnostic disable-line if currentBuildSpeed[unitID] then spSetUnitBuildSpeed(unitID, currentBuildSpeed[unitID]) -- needed for luarules reloads end @@ -152,7 +156,7 @@ function gadget:UnitCreated(unitID, unitDefID, teamID) -- Units use their full build speed, by default. if unitBuildSpeed[unitDefID] then canBuild[teamID][unitID] = true - realBuildSpeed[unitID] = unitBuildSpeed[unitDefID] or 0 + realBuildSpeed[unitID] = unitBuildSpeed[unitDefID] -- Only units that can build other units can use passive build priority. if canPassive[unitDefID] then @@ -161,7 +165,7 @@ function gadget:UnitCreated(unitID, unitDefID, teamID) passiveCons[teamID][unitID] = true passiveConsCount[teamID] = (passiveConsCount[teamID] or 0) + 1 end - currentBuildSpeed[unitID] = realBuildSpeed[unitID] + currentBuildSpeed[unitID] = unitBuildSpeed[unitDefID] end end @@ -221,7 +225,7 @@ function gadget:AllowCommand( local cmdIdx = spFindUnitCmdDesc(unitID, CMD_PRIORITY) local suspend = spGetTeamRulesParam(teamID, "suspendbuilderpriority") or 0 if cmdIdx and suspend == 0 then - local cmdDesc = spGetUnitCmdDescs(unitID, cmdIdx, cmdIdx)[1] + local cmdDesc = spGetUnitCmdDescs(unitID, cmdIdx, cmdIdx)[1] ---@as table ---@diagnostic disable-line: need-check-nil cmdDesc.params[1] = cmdParams[1] spEditUnitCmdDesc(unitID, cmdIdx, cmdDesc) spSetUnitRulesParam(unitID, ruleName, cmdParams[1]) @@ -258,14 +262,15 @@ local function UpdatePassiveBuilders( eInc, eShare, eSent, - eRec + eRec, + ePull ) -- Early exit if no passive builders for this team if not passiveConsCount[teamID] or passiveConsCount[teamID] == 0 then return end - local passiveTeamCons = passiveCons[teamID] + local passiveTeamCons = passiveCons[teamID] ---@as table suspendBuilderPriority = spGetTeamRulesParam(teamID, "suspendbuilderpriority") if suspendBuilderPriority ~= 0 then @@ -274,8 +279,11 @@ local function UpdatePassiveBuilders( -- calculate how much expense each passive con would require -- and how much total expense the non-passive cons require - local nonPassiveConsTotalExpenseEnergy = 0 - local nonPassiveConsTotalExpenseMetal = 0 + local nonPassiveConsTotalExpenseMetal = 0.0 + local nonPassiveConsTotalExpenseEnergy = 0.0 + -- Current energy pull of non-passive and passive cons (engine GetUnitResources, not theoretical full-speed cost). + local nonPassiveConsEnergyPull = 0.0 + local passiveConsEnergyPull = 0.0 local teamBuildTargetOwners = buildTargetOwnersByTeam[teamID] local hasOwners = false @@ -290,7 +298,7 @@ local function UpdatePassiveBuilders( for builderID in pairs(passiveTeamCons) do local builtUnit = spGetUnitIsBuilding(builderID) if builtUnit then - local targetCosts = costID[builtUnit] + local targetCosts = costID[builtUnit] ---@as { [1]:number, [2]:number, [3]:number } local buildSpeed = realBuildSpeed[builderID] if targetCosts and buildSpeed then local rate = buildSpeed / targetCosts[3] @@ -315,14 +323,17 @@ local function UpdatePassiveBuilders( teamsWithOwners[teamID] = nil end - -- Second pass: check non-passive builders ONLY if we have passive builders building + -- Second pass: ONLY if we have passive builders building + -- Metal/energy (non-passive): theoretical full-speed cost for reservation gate + -- Energy pull: measured builder share of ePull via GetUnitResources (to peel + -- builders out of team pull when computing non-builder drain) if anyPassiveBuilding then local teamBuilders = canBuild[teamID] for builderID in pairs(teamBuilders) do if not passiveTeamCons[builderID] then local builtUnit = spGetUnitIsBuilding(builderID) if builtUnit then - local targetCosts = costID[builtUnit] + local targetCosts = costID[builtUnit] ---@as { [1]:number, [2]:number, [3]:number } local buildSpeed = realBuildSpeed[builderID] if targetCosts and buildSpeed then local rate = buildSpeed / targetCosts[3] @@ -334,29 +345,50 @@ local function UpdatePassiveBuilders( end end end + + local energyUse = select(4, spGetUnitResources(builderID)) + if energyUse and energyUse > 0 then + if passiveTeamCons[builderID] then + passiveConsEnergyPull = passiveConsEnergyPull + energyUse + else + nonPassiveConsEnergyPull = nonPassiveConsEnergyPull + energyUse + end + end end end - -- calculate how much expense passive cons will be allowed (using pre-fetched resource data) + -- Resource accounting for the stall budget: + -- + -- Metal: reserve theoretical full-speed metal for non-passive cons only + -- (nonPassiveConsTotalExpenseMetal). + -- + -- Energy: peel measured builder draw out of ePull (minus converters) to get + -- non-builder pull. Reserve that plus theoretical full-speed non-passive + -- con energy (nonPassiveConsTotalExpenseEnergy). Leave passive + -- measured pull out so the allocation loop can re-test each passive at + -- full realBuildSpeed. local intervalOverSpeed = interval / simSpeed local mStorEff = mStor * mShare local teamStallingMetal = mCur - mathMax(mInc * stallMarginInc, mStorEff * stallMarginSto) - 1 - + interval * (nonPassiveConsTotalExpenseMetal + mInc + mRec - mSent) / simSpeed + + intervalOverSpeed * (mInc + mRec - mSent - nonPassiveConsTotalExpenseMetal) local eStorEff = eStor * eShare + local converterEnergyUse = spGetTeamRulesParam(teamID, converterEnergyUsageParamName) or 0 + local nonConverterEnergyPull = mathMax(0, ePull - converterEnergyUse) + local nonBuilderEnergyPull = mathMax(0, nonConverterEnergyPull - nonPassiveConsEnergyPull - passiveConsEnergyPull) local teamStallingEnergy = eCur - mathMax(eInc * stallMarginInc, eStorEff * stallMarginSto) - 1 - + interval * (nonPassiveConsTotalExpenseEnergy + eInc + eRec - eSent) / simSpeed + + intervalOverSpeed * (eInc + eRec - eSent - nonBuilderEnergyPull - nonPassiveConsTotalExpenseEnergy) -- work through passive cons allocating as much expense as we have left for builderID in pairs(passiveTeamCons) do local wouldStall = false - local pMetal = _passiveMetal[builderID] + local pMetal = _passiveMetal[builderID] ---@as number? if pMetal then local passivePullMetal = pMetal * intervalOverSpeed local passivePullEnergy = _passiveEnergy[builderID] * intervalOverSpeed @@ -374,7 +406,7 @@ local function UpdatePassiveBuilders( end -- turn this passive builder on/off as appropriate - local wantedBuildSpeed = wouldStall and 0 or realBuildSpeed[builderID] + local wantedBuildSpeed = wouldStall and 0 or realBuildSpeed[builderID] ---@as number local currentSpeed = currentBuildSpeed[builderID] if currentSpeed ~= wantedBuildSpeed then spSetUnitBuildSpeed(builderID, wantedBuildSpeed) @@ -383,7 +415,7 @@ local function UpdatePassiveBuilders( -- override buildTargetOwners build speeds for a single frame; -- let them build at a tiny rate to prevent nanoframes from possibly decaying - if teamBuildTargetOwners[builderID] and currentSpeed == 0 then + if currentSpeed == 0 and teamBuildTargetOwners[builderID] then spSetUnitBuildSpeed(builderID, 0.001) end end @@ -420,7 +452,7 @@ function gadget:GameFrame(n) if n >= updateFrame[teamID] then -- Read resource data once for both interval calc and UpdatePassiveBuilders local mCur, mStor, _, mInc, _, mShare, mSent, mRec = spGetTeamResources(teamID, "metal") - local eCur, eStor, _, eInc, _, eShare, eSent, eRec = spGetTeamResources(teamID, "energy") + local eCur, eStor, ePull, eInc, _, eShare, eSent, eRec = spGetTeamResources(teamID, "energy") -- Inlined GetUpdateInterval: find max frames to fill storage for metal/energy (capped at 6) local interval = 1 if mInc > 0 then @@ -452,7 +484,8 @@ function gadget:GameFrame(n) eInc, eShare, eSent, - eRec + eRec, + ePull ) updateFrame[teamID] = n + interval end diff --git a/luarules/gadgets/unit_cancel_orders_on_share.lua b/luarules/gadgets/unit_cancel_orders_on_share.lua index 48cb8769ceb..2c8b3767f42 100644 --- a/luarules/gadgets/unit_cancel_orders_on_share.lua +++ b/luarules/gadgets/unit_cancel_orders_on_share.lua @@ -28,23 +28,23 @@ if not gadgetHandler:IsSyncedCode() then end end else -- SYNCED - local recievedMexes = {} + local receivedMexes = {} function gadget:UnitGiven(unitID, unitDefID, unitTeam, oldTeam) -- if the unit is a metal extractor, turn it on: if UnitDefs[unitDefID] and UnitDefs[unitDefID].extractsMetal and UnitDefs[unitDefID].extractsMetal > 0 then - recievedMexes[#recievedMexes + 1] = unitID + receivedMexes[#receivedMexes + 1] = unitID end end function gadget:GameFrame(n) - if n % 37 == 0 and #recievedMexes > 0 then - for i, unitID in ipairs(recievedMexes) do + if n % 37 == 0 and #receivedMexes > 0 then + for i, unitID in ipairs(receivedMexes) do if Spring.ValidUnitID(unitID) then Spring.GiveOrderToUnit(unitID, CMD.ONOFF, { 1 }, 0) end end - recievedMexes = {} + receivedMexes = {} end end end diff --git a/luarules/gadgets/unit_carrier_spawner.lua b/luarules/gadgets/unit_carrier_spawner.lua index 9aaee43fd84..69bdfeb45bc 100644 --- a/luarules/gadgets/unit_carrier_spawner.lua +++ b/luarules/gadgets/unit_carrier_spawner.lua @@ -26,7 +26,6 @@ local SendToUnsynced = SendToUnsynced local spGetUnitRulesParam = Spring.GetUnitRulesParam local spUseTeamResource = Spring.UseTeamResource local spGetTeamResources = Spring.GetTeamResources -local GetUnitCommands = Spring.GetUnitCommands local spSetUnitArmored = Spring.SetUnitArmored local spGetUnitStates = Spring.GetUnitStates local spGetUnitDefID = Spring.GetUnitDefID @@ -34,7 +33,6 @@ local spSetUnitVelocity = Spring.SetUnitVelocity local spUnitAttach = Spring.UnitAttach local spUnitDetach = Spring.UnitDetach local spSetUnitHealth = Spring.SetUnitHealth -local spSetUnitMaxHealth = Spring.SetUnitMaxHealth local spSetUnitUseAirLos = Spring.SetUnitUseAirLos local spGetGroundHeight = Spring.GetGroundHeight local spGetUnitNearestEnemy = Spring.GetUnitNearestEnemy @@ -67,14 +65,13 @@ local spGetGameFrame = Spring.GetGameFrame local mcEnable = Spring.MoveCtrl.Enable local mcDisable = Spring.MoveCtrl.Disable local mcSetPosition = Spring.MoveCtrl.SetPosition -local mcSetRotation = Spring.MoveCtrl.SetRotation -local mcSetAirMoveTypeData = Spring.MoveCtrl.SetAirMoveTypeData local mapsizeX = Game.mapSizeX local mapsizeZ = Game.mapSizeZ local random = math.random local mathMin = math.min +local floor = math.floor local sin = math.sin local cos = math.cos local diag = math.diag @@ -86,10 +83,17 @@ local PI = math.pi local GAME_SPEED = Game.gameSpeed local PRIVATE = { private = true } local CMD_CARRIER_SPAWN_ONOFF = GameCMD.CARRIER_SPAWN_ONOFF - -local noCreate = false +local CMD_ATTACK = CMD.ATTACK +local CMD_MOVE = CMD.MOVE +local CMD_STOP = CMD.STOP +local CMD_REPAIR = CMD.REPAIR +local CMD_FIRE_STATE = CMD.FIRE_STATE +local CMD_MOVE_STATE = CMD.MOVE_STATE +local CMD_STOCKPILE = CMD.STOCKPILE local spawnDefs = {} +local hasAmmoDrones = false -- some drone type has limited ammo (needs the ProjectileCreated callin) +local hasBomberDrones = false -- some drone type is a bomber (needs the UnitCmdDone callin) local shieldCollide = {} local wantedList = {} @@ -113,9 +117,7 @@ local carrierMetaList = {} local droneMetaList = {} local droneCarrierIdList = {} -local lastCarrierUpdate = 0 -local lastSpawnCheck = 0 -local lastDockCheck = 0 +local carrierUpdateList = {} -- scratch list of the carriers whose turn it is this frame local inUnitDestroyed = false local gaiaTeam @@ -125,6 +127,7 @@ local Sleep = coroutine.yield local assert = assert local coroutines = {} +local coroutineCount = 0 --TEMPORARY for debugging local totalDroneCount = 0 @@ -133,9 +136,10 @@ local totalDroneCount = 0 -- These control the frequency, in gameframes, of different actions. Increasing these will improve overall game performance at the cost of this gadgets responsiveness. local DEFAULT_UPDATE_ORDER_FREQUENCY = 60 -- Idle movement orders for drones. How frequently the drones change direction when idling around the carrier. -local CARRIER_UPDATE_FREQUENCY = 15 -- Update dronestates and orders. Increasing this will decrease responsiveness when issuing new commands. -local DEFAULT_SPAWN_CHECK_FREQUENCY = 3 -- Controls the minimum possible spawnrate. Increasing this will give less accurate spawnrates. -local DEFAULT_DOCK_CHECK_FREQUENCY = 15 -- Checks the docking queue. Increasing this will decrease docking responsiveness, and may cause some drones to dock too late. +local CARRIER_UPDATE_PERIOD = GAME_SPEED -- Frames between two updates (spawn check, dronestates and orders) of the same carrier. Carriers are spread over this period by unitID, so each frame handles a fraction of them instead of all of them at once. +local CMD_QUEUE_SCAN_DEPTH = 4 -- How many queued commands of a drone are inspected to see whether it is busy fighting or repairing. +local DOCK_ORDER_REFRESH_DISTANCE = 32 -- A drone flying back to dock gets a fresh move order once its docking piece moved this far from where it was sent. +local DOCK_APPROACH_MAX_SKIP_FRAMES = 10 -- Longest a docking drone that is still far from its dock waits between checks. -- These values can be tuned in the unitdef file. Add the section below to a weaponDef list in the unitdef file. --customparams = { @@ -255,6 +259,31 @@ for weaponDefID = 0, #WeaponDefs do shieldCollide[weaponDefID] = WeaponDefs[weaponDefID].damages[Game.armorTypes.shield] end wantedList[#wantedList + 1] = weaponDefID + + for _, ammo in pairsNext, spawnDefs[weaponDefID].droneAmmo do + if (tonumber(ammo) or 0) > 0 then + hasAmmoDrones = true + end + end + for _, dronetypeName in pairsNext, spawnDefs[weaponDefID].dronetype do + if dronetypeName == "bomber" then + hasBomberDrones = true + end + end + end +end + +-- unitDefID -> spawner weapon of unit defs that carry drones, so UnitCreated is a single lookup for every other unit +local carrierDefs = {} +for unitDefID, unitDef in pairs(UnitDefs) do + local weaponList = unitDef.weapons + for i = 1, #weaponList do + local weaponDefID = weaponList[i].weaponDef + local spawnDef = weaponDefID and spawnDefs[weaponDefID] + if spawnDef and spawnDef.radius then + carrierDefs[unitDefID] = { weaponIndex = i, weaponDefID = weaponDefID } + break + end end end @@ -285,20 +314,28 @@ local function randomPointInUnitCircle(offset) end local function startScript(fn) - local co = coroutine.create(fn) - coroutines[#coroutines + 1] = co + coroutineCount = coroutineCount + 1 + coroutines[coroutineCount] = coroutine.create(fn) end local function updateCoroutines() - local newCoroutines = {} - for i = 1, #coroutines do + if coroutineCount == 0 then + return + end + -- compact the list in place instead of rebuilding it every frame + local liveCount = 0 + for i = 1, coroutineCount do local co = coroutines[i] if coroutine.status(co) ~= "dead" then - newCoroutines[#newCoroutines + 1] = co + liveCount = liveCount + 1 + coroutines[liveCount] = co end end - coroutines = newCoroutines - for i = 1, #coroutines do + for i = liveCount + 1, coroutineCount do + coroutines[i] = nil + end + coroutineCount = liveCount + for i = 1, liveCount do assert(coroutine.resume(coroutines[i])) end end @@ -307,18 +344,72 @@ local function healUnit(unitID, healrate, resourceFrames, currentHealth, maxHeal if (resourceFrames <= 0) or not currentHealth then return true end - local healthGain = healrate * resourceFrames - local newHealth = mathMin(currentHealth + healthGain, maxHealth) - if maxHealth < newHealth then - newHealth = maxHealth - end + local newHealth = mathMin(currentHealth + healrate * resourceFrames, maxHealth) if newHealth <= 0 then spDestroyUnit(unitID, true) return false - else + end + if newHealth ~= currentHealth then -- skip the engine call when nothing changes (e.g. decayrate 0) spSetUnitHealth(unitID, newHealth) - return true end + return true +end + +-- Scans the first CMD_QUEUE_SCAN_DEPTH commands of a unit for an attack (when attackCounts) or +-- repair (when repairCounts) command. Uses the indexed current-command read, so no command tables +-- are built; Spring.GetUnitCommands allocates several tables per command, which adds up when this +-- runs for hundreds of drones every second. +-- Returns the command id and its params, or nil when no such command is queued. +local function findCombatCommand(unitID, attackCounts, repairCounts) + for index = 1, CMD_QUEUE_SCAN_DEPTH do + local cmdID, _, _, p1, p2, p3, p4 = spGetUnitCurrentCommand(unitID, index) + if not cmdID then + return nil + end + if (attackCounts and cmdID == CMD_ATTACK) or (repairCounts and cmdID == CMD_REPAIR) then + return cmdID, p1, p2, p3, p4 + end + end + return nil +end + +local function packParams(p1, p2, p3, p4) + if p2 == nil then + return { p1 } + elseif p4 == nil then + return { p1, p2, p3 } + end + return { p1, p2, p3, p4 } +end + +-- True when the unit is already executing an attack order on exactly this target (a unitID or a +-- params table), so re-issuing it would only restart the same command. +local function hasAttackOrder(unitID, target) + local cmdID, _, _, p1, p2, p3, p4 = spGetUnitCurrentCommand(unitID) + if cmdID ~= CMD_ATTACK then + return false + end + if type(target) == "table" then + return p1 == target[1] and p2 == target[2] and p3 == target[3] and p4 == target[4] + end + return p1 == target and p2 == nil +end + +-- Only sends a fire state order when the unit is not in that state already; orders are expensive +-- (AllowCommand/UnitCommand callins of every gadget plus the command queue), the state read is not. +local function setDroneFireState(unitID, fireState) + if spGetUnitStates(unitID, false) ~= fireState then + spGiveOrderToUnit(unitID, CMD_FIRE_STATE, fireState, 0) + end +end + +-- Elmos per frame a docking drone can cover at most (with margin); paces the approach checks. +local function droneApproachSpeed(unitDef) + local approachSpeed = 2 * ((unitDef and unitDef.speed) or 0) / GAME_SPEED + if approachSpeed < 1 then + approachSpeed = 1 + end + return approachSpeed end local function validCarrierAndDrone(unitID, subUnitID) @@ -346,6 +437,8 @@ local function dockUnitQueue(unitID, subUnitID) carrierMetaList[unitID].subUnitsList[subUnitID].activeDocking = true end +local RemoveDrone + local function undockUnit(unitID, subUnitID) local validDrone = validCarrierAndDrone(unitID, subUnitID) if not validDrone then @@ -624,6 +717,7 @@ local function spawnUnit(spawnData) lastLanding = 0, remainingAmmo = 0, maxAmmo = carrierData.droneAmmo[dronetypeIndex], + approachSpeed = droneApproachSpeed(subUnitDef), } carrierData.subUnitsList[subUnitID] = droneData droneCarrierIdList[subUnitID] = ownerID @@ -747,6 +841,7 @@ local function attachToNewCarrier(newCarrier, subUnitID) remainingAmmo = 0, maxAmmo = 0, originalMaxHealth = droneMaxHealth or 1, + approachSpeed = droneApproachSpeed(UnitDefs[spGetUnitDefID(subUnitID)]), } carrierMetaList[newCarrier].subUnitsList[subUnitID] = droneData totalDroneCount = totalDroneCount + 1 @@ -761,146 +856,140 @@ local function attachToNewCarrier(newCarrier, subUnitID) end function gadget:UnitCreated(unitID, unitDefID, unitTeam) + local carrierDef = carrierDefs[unitDefID] + if not carrierDef or carrierMetaList[unitID] then + return + end local unitDef = UnitDefs[unitDefID] - local weaponList = unitDef.weapons - for i = 1, #weaponList do - local weapon = weaponList[i] - local weaponDefID = weapon.weaponDef - if weaponDefID and spawnDefs[weaponDefID] then - local isAirUnit = unitDef.isAirUnit - - local spawnDef = spawnDefs[weaponDefID] - if spawnDef.radius then - local spawnData = {} - local x, y, z = spGetUnitPosition(unitID) - spawnData.x = x - spawnData.y = y - spawnData.z = z - spawnData.ownerID = unitID - spawnData.teamID = unitTeam - spawnData.surface = spawnDef.surface - - if carrierMetaList[unitID] == nil then - local dronenames = spawnDef.name - local dronetypes = spawnDef.dronetype - local dockingsections = spawnDef.dockingsections - local maxunits = spawnDef.maxunits - local startingDroneCount = spawnDef.startingDroneCount - local metalCost = spawnDef.metalPerUnit - local energyCost = spawnDef.energyPerUnit - local droneAirTime = spawnDef.droneAirTime - local droneDockTime = spawnDef.droneDockTime - local droneAmmo = spawnDef.droneAmmo - - local availableSections = {} - - local f = Spring.GetGameFrame() - - for sectionIndex, dockingpieces in pairs(dockingsections) do - local availableSectionsData = { - availablePieces = {}, - } - local availablePieces = {} - local piecenumbers = strSplit(dockingpieces) - for pieceindex, piecenumber in pairs(piecenumbers) do - availablePieces[pieceindex] = { - dockingPieceAvailable = true, - dockingPieceIndex = pieceindex, - dockingPiece = tonumber(piecenumber), - } - end - availableSectionsData.availablePieces = availablePieces - availableSections[sectionIndex] = availableSectionsData - end + local isAirUnit = unitDef.isAirUnit + local i = carrierDef.weaponIndex + local spawnDef = spawnDefs[carrierDef.weaponDefID] + + local spawnData = {} + local x, y, z = spGetUnitPosition(unitID) + spawnData.x = x + spawnData.y = y + spawnData.z = z + spawnData.ownerID = unitID + spawnData.teamID = unitTeam + spawnData.surface = spawnDef.surface + + local dronenames = spawnDef.name + local dronetypes = spawnDef.dronetype + local dockingsections = spawnDef.dockingsections + local maxunits = spawnDef.maxunits + local startingDroneCount = spawnDef.startingDroneCount + local metalCost = spawnDef.metalPerUnit + local energyCost = spawnDef.energyPerUnit + local droneAirTime = spawnDef.droneAirTime + local droneDockTime = spawnDef.droneDockTime + local droneAmmo = spawnDef.droneAmmo + + local availableSections = {} + + local f = spGetGameFrame() + + for sectionIndex, dockingpieces in pairs(dockingsections) do + local availableSectionsData = { + availablePieces = {}, + } + local availablePieces = {} + local piecenumbers = strSplit(dockingpieces) + for pieceindex, piecenumber in pairs(piecenumbers) do + availablePieces[pieceindex] = { + dockingPieceAvailable = true, + dockingPieceIndex = pieceindex, + dockingPiece = tonumber(piecenumber), + } + end + availableSectionsData.availablePieces = availablePieces + availableSections[sectionIndex] = availableSectionsData + end - local carrierData = { - dronenames = dronenames, - dronetypes = dronetypes, - radius = tonumber(spawnDef.minRadius) or 65535, - controlRadius = tonumber(spawnDef.radius) or 65535, - subUnitsList = {}, -- list of subUnitIDs owned by this unit. - subUnitCount = {}, - subInitialSpawnData = spawnData, - spawnRateFrames = tonumber(spawnDef.spawnRate) * 30 or 30, - lastSpawn = f, - lastOrderUpdate = 0, - maxunits = {}, - startingDroneCount = {}, - startingWithDrones = false, - wasBuilt = false, - metalCost = {}, - energyCost = {}, - docking = tonumber(spawnDef.docking), - dockRadius = tonumber(spawnDef.dockingRadius) or 100, - dockHelperSpeed = tonumber(spawnDef.dockingHelperSpeed) or 10, - dockArmor = tonumber(spawnDef.dockingArmor), - dockedHealRate = tonumber(spawnDef.dockingHealrate) or 0, - dockToHealThreshold = tonumber(spawnDef.dockToHealThreshold) or 30, - attackFormationSpread = tonumber(spawnDef.attackFormationSpread) or 0, - attackFormationOffset = tonumber(spawnDef.attackFormationOffset) or 0, - decayRate = tonumber(spawnDef.decayRate) or 0, - deathdecayRate = tonumber(spawnDef.deathdecayRate) or tonumber(spawnDef.decayRate) or 0, - activeDocking = false, --currently not in use - activeRecall = false, - activeSpawning = 1, - availableSections = availableSections, - carrierDeaththroe = spawnDef.carrierdeaththroe or "death", - parasite = "all", - holdfireRadius = spawnDef.holdfireRadius or 0, - droneminimumidleradius = spawnDef.droneminimumidleradius or 0, - dronebombingruns = tonumber(spawnDef.dronebombingruns) or 1, - dronebombingoffset = tonumber(spawnDef.dronebombingoffset) or 0.5, - dronebombingside = 1, - dronebomberinterval = tonumber(spawnDef.dronebomberinterval) or 2, - dronebombertimer = 0, - dronebomberminengagementrange = tonumber(spawnDef.dronebomberminengagementrange) or 200, - manualDrones = tonumber(spawnDef.manualDrones), - weaponNr = i, - stockpilelimit = tonumber(spawnDef.stockpilelimit) or 0, - usestockpile = tonumber(spawnDef.usestockpile), - stockpilecount = 0, - metalperstockpile = tonumber(spawnDef.metalperstockpile) or 0, - energyperstockpile = tonumber(spawnDef.energyperstockpile) or 0, - cobdockparam = tonumber(spawnDef.cobdockparam) or 0, - cobundockparam = tonumber(spawnDef.cobundockparam) or 0, - droneundocksequence = tonumber(spawnDef.droneundocksequence), - printerUnitDefID = nil, - droneAirTime = {}, - droneDockTime = {}, - droneAmmo = {}, - isAirUnit = isAirUnit, - } - for dronetypeIndex, _ in pairs(carrierData.dronenames) do - carrierData.subUnitCount[dronetypeIndex] = 0 - carrierData.maxunits[dronetypeIndex] = tonumber(maxunits[dronetypeIndex]) or 1 - carrierData.startingDroneCount[dronetypeIndex] = tonumber(startingDroneCount[dronetypeIndex]) - or 0 - carrierData.metalCost[dronetypeIndex] = tonumber(metalCost[dronetypeIndex]) - carrierData.energyCost[dronetypeIndex] = tonumber(energyCost[dronetypeIndex]) - carrierData.droneAirTime[dronetypeIndex] = droneAirTime[dronetypeIndex] - and tonumber(droneAirTime[dronetypeIndex]) * 30 - carrierData.droneDockTime[dronetypeIndex] = droneDockTime[dronetypeIndex] - and tonumber(droneDockTime[dronetypeIndex]) * 30 - carrierData.droneAmmo[dronetypeIndex] = tonumber(droneAmmo[dronetypeIndex]) - - if carrierData.startingDroneCount[dronetypeIndex] > 0 then - carrierData.startingWithDrones = true - end - end - carrierMetaList[unitID] = carrierData - local states = spGetUnitStates(unitID) - if states then - carrierData.cachedFireState = states.firestate - carrierData.cachedMoveState = states.movestate - end - --spSetUnitRulesParam(unitID, "is_carrier_unit", "enabled", PRIVATE) - if not carrierMetaList[unitID].usestockpile then - InsertUnitCmdDesc(unitID, 500, spawnCmd) --temporary - end - end - end + local carrierData = { + dronenames = dronenames, + dronetypes = dronetypes, + radius = tonumber(spawnDef.minRadius) or 65535, + controlRadius = tonumber(spawnDef.radius) or 65535, + subUnitsList = {}, -- list of subUnitIDs owned by this unit. + subUnitCount = {}, + subInitialSpawnData = spawnData, + spawnRateFrames = tonumber(spawnDef.spawnRate) * 30 or 30, + lastSpawn = f, + lastOrderUpdate = 0, + lastUpdateFrame = f, + maxunits = {}, + startingDroneCount = {}, + startingWithDrones = false, + wasBuilt = false, + metalCost = {}, + energyCost = {}, + docking = tonumber(spawnDef.docking), + dockRadius = tonumber(spawnDef.dockingRadius) or 100, + dockHelperSpeed = tonumber(spawnDef.dockingHelperSpeed) or 10, + dockArmor = tonumber(spawnDef.dockingArmor), + dockedHealRate = tonumber(spawnDef.dockingHealrate) or 0, + dockToHealThreshold = tonumber(spawnDef.dockToHealThreshold) or 30, + attackFormationSpread = tonumber(spawnDef.attackFormationSpread) or 0, + attackFormationOffset = tonumber(spawnDef.attackFormationOffset) or 0, + decayRate = tonumber(spawnDef.decayRate) or 0, + deathdecayRate = tonumber(spawnDef.deathdecayRate) or tonumber(spawnDef.decayRate) or 0, + activeDocking = false, --currently not in use + activeRecall = false, + activeSpawning = 1, + availableSections = availableSections, + carrierDeaththroe = spawnDef.carrierdeaththroe or "death", + parasite = "all", + holdfireRadius = spawnDef.holdfireRadius or 0, + droneminimumidleradius = spawnDef.droneminimumidleradius or 0, + dronebombingruns = tonumber(spawnDef.dronebombingruns) or 1, + dronebombingoffset = tonumber(spawnDef.dronebombingoffset) or 0.5, + dronebombingside = 1, + dronebomberinterval = tonumber(spawnDef.dronebomberinterval) or 2, + dronebombertimer = 0, + dronebomberminengagementrange = tonumber(spawnDef.dronebomberminengagementrange) or 200, + manualDrones = tonumber(spawnDef.manualDrones), + weaponNr = i, + stockpilelimit = tonumber(spawnDef.stockpilelimit) or 0, + usestockpile = tonumber(spawnDef.usestockpile), + stockpilecount = 0, + metalperstockpile = tonumber(spawnDef.metalperstockpile) or 0, + energyperstockpile = tonumber(spawnDef.energyperstockpile) or 0, + cobdockparam = tonumber(spawnDef.cobdockparam) or 0, + cobundockparam = tonumber(spawnDef.cobundockparam) or 0, + droneundocksequence = tonumber(spawnDef.droneundocksequence), + printerUnitDefID = nil, + droneAirTime = {}, + droneDockTime = {}, + droneAmmo = {}, + isAirUnit = isAirUnit, + } + for dronetypeIndex, _ in pairs(carrierData.dronenames) do + carrierData.subUnitCount[dronetypeIndex] = 0 + carrierData.maxunits[dronetypeIndex] = tonumber(maxunits[dronetypeIndex]) or 1 + carrierData.startingDroneCount[dronetypeIndex] = tonumber(startingDroneCount[dronetypeIndex]) or 0 + carrierData.metalCost[dronetypeIndex] = tonumber(metalCost[dronetypeIndex]) + carrierData.energyCost[dronetypeIndex] = tonumber(energyCost[dronetypeIndex]) + carrierData.droneAirTime[dronetypeIndex] = droneAirTime[dronetypeIndex] + and tonumber(droneAirTime[dronetypeIndex]) * 30 + carrierData.droneDockTime[dronetypeIndex] = droneDockTime[dronetypeIndex] + and tonumber(droneDockTime[dronetypeIndex]) * 30 + carrierData.droneAmmo[dronetypeIndex] = tonumber(droneAmmo[dronetypeIndex]) + + if carrierData.startingDroneCount[dronetypeIndex] > 0 then + carrierData.startingWithDrones = true end end + carrierMetaList[unitID] = carrierData + local states = spGetUnitStates(unitID) + if states then + carrierData.cachedFireState = states.firestate + carrierData.cachedMoveState = states.movestate + end + --spSetUnitRulesParam(unitID, "is_carrier_unit", "enabled", PRIVATE) + if not carrierData.usestockpile then + InsertUnitCmdDesc(unitID, 500, spawnCmd) --temporary + end end function gadget:UnitTaken(unitID, unitDefID, unitTeam, newTeam) @@ -921,44 +1010,48 @@ function gadget:UnitGiven(unitID, unitDefID, unitTeam, oldTeam) end end -function gadget:UnitCmdDone(unitID, unitDefID, unitTeam, cmdID, cmdParams, cmdOpts, cmdTag) - local carrierUnitID = droneCarrierIdList[unitID] - if carrierUnitID and carrierMetaList[carrierUnitID] then - if carrierMetaList[carrierUnitID].subUnitsList[unitID] then - local droneMetaData = carrierMetaList[carrierUnitID].subUnitsList[unitID] - local bomberStage = droneMetaData.bomberStage - local fighterStage = droneMetaData.fighterStage - local droneType = droneMetaData.dronetype - if droneType == "bomber" and (cmdID == CMD.MOVE or cmdID == CMD.ATTACK) and bomberStage > 0 then - if droneMetaData.bomberStage == 1 then - end - if - not carrierMetaList[carrierUnitID].docking - and bomberStage >= 4 + carrierMetaList[carrierUnitID].dronebombingruns - then - bomberStage = 0 - elseif bomberStage < 3 then - bomberStage = bomberStage + 1 - end - droneMetaData.bomberStage = bomberStage +if hasBomberDrones then + function gadget:UnitCmdDone(unitID, unitDefID, unitTeam, cmdID, cmdParams, cmdOpts, cmdTag) + local carrierUnitID = droneCarrierIdList[unitID] + local carrierMetaData = carrierUnitID and carrierMetaList[carrierUnitID] + local droneMetaData = carrierMetaData and carrierMetaData.subUnitsList[unitID] + if not droneMetaData or droneMetaData.dronetype ~= "bomber" then + return + end + local bomberStage = droneMetaData.bomberStage + if (cmdID == CMD_MOVE or cmdID == CMD_ATTACK) and bomberStage > 0 then + if not carrierMetaData.docking and bomberStage >= 4 + carrierMetaData.dronebombingruns then + bomberStage = 0 + elseif bomberStage < 3 then + bomberStage = bomberStage + 1 end + droneMetaData.bomberStage = bomberStage end end end -function gadget:ProjectileCreated(proID, proOwnerID, proWeaponDefID) - if proOwnerID then +if hasAmmoDrones or hasBomberDrones then + function gadget:ProjectileCreated(proID, proOwnerID, proWeaponDefID) + if not proOwnerID then + return + end local carrierUnitID = droneCarrierIdList[proOwnerID] - local droneMetaData = ( - carrierUnitID - and carrierMetaList[carrierUnitID] - and carrierMetaList[carrierUnitID].subUnitsList[proOwnerID] - ) or droneMetaList[proOwnerID] - if droneMetaData and droneMetaData.maxAmmo > 0 then + local droneMetaData + if carrierUnitID then + local carrierMetaData = carrierMetaList[carrierUnitID] + droneMetaData = carrierMetaData and carrierMetaData.subUnitsList[proOwnerID] + end + if not droneMetaData then + droneMetaData = droneMetaList[proOwnerID] + if not droneMetaData then + return + end + end + if droneMetaData.maxAmmo > 0 then local ammo = droneMetaData.remainingAmmo - 1 if ammo <= 0 then - spGiveOrderToUnit(proOwnerID, CMD.FIRE_STATE, 0, 0) - spGiveOrderToUnit(proOwnerID, CMD.STOP, {}, 0) + spGiveOrderToUnit(proOwnerID, CMD_FIRE_STATE, 0, 0) + spGiveOrderToUnit(proOwnerID, CMD_STOP, {}, 0) if carrierUnitID then dockUnitQueue(carrierUnitID, proOwnerID) end @@ -966,13 +1059,13 @@ function gadget:ProjectileCreated(proID, proOwnerID, proWeaponDefID) droneMetaData.remainingAmmo = ammo end - if droneMetaData and carrierUnitID then + if carrierUnitID and droneMetaData.dronetype == "bomber" then local bomberStage = droneMetaData.bomberStage local lastBombing = droneMetaData.lastBombing - if droneMetaData.dronetype == "bomber" and bomberStage > 0 then + if bomberStage > 0 then local currentTime = spGetGameSeconds() if (currentTime - lastBombing) >= 4 then - Spring.MoveCtrl.SetAirMoveTypeData(proOwnerID, "maxRudder", droneMetaData.originalmaxrudder) + mcSetAirMoveTypeData(proOwnerID, "maxRudder", droneMetaData.originalmaxrudder) bomberStage = bomberStage + 1 lastBombing = spGetGameSeconds() end @@ -1171,21 +1264,14 @@ local function updateStandaloneDrones(frame) then spDestroyUnit(unitID, false) else - local cQueue = GetUnitCommands(unitID, 4) - local engaged = false - for j = 1, (cQueue and #cQueue or 0) do - if cQueue[j].id == CMD.ATTACK then - -- if currently fighting - engaged = true - break - end - end + -- if currently fighting + local engaged = findCombatCommand(unitID, true, false) ~= nil droneData.engaged = engaged if not engaged and ((DEFAULT_UPDATE_ORDER_FREQUENCY + droneData.lastOrderUpdate) < frame) then local idleRadius = droneData.idleRadius * 0.2 droneData.lastOrderUpdate = frame - rx, rz = randomPointInUnitCircle(5) + local rx, rz = randomPointInUnitCircle(5) spGiveOrderToUnit( unitID, CMD.MOVE, @@ -1220,7 +1306,7 @@ local function updateCarrier(carrierID, carrierMetaData, frame) local attackOrder = false local fightOrder = false local setTargetOrder = false - local agressiveDrones = false + local aggressiveDrones = false local cachedFireState = carrierMetaData.cachedFireState or 2 local cachedMoveState = carrierMetaData.cachedMoveState or 1 @@ -1228,7 +1314,7 @@ local function updateCarrier(carrierID, carrierMetaData, frame) if cachedFireState == 0 then idleRadius = carrierMetaData.holdfireRadius elseif cachedFireState == 2 then - agressiveDrones = true + aggressiveDrones = true end if cachedMoveState == 0 then idleRadius = 0 @@ -1241,7 +1327,7 @@ local function updateCarrier(carrierID, carrierMetaData, frame) idleRadius = carrierMetaData.droneminimumidleradius end - local weapontargettype, _, weapontarget = Spring.GetUnitWeaponTarget(carrierID, carrierMetaData.weaponNr) + local weapontargettype, _, weapontarget = spGetUnitWeaponTarget(carrierID, carrierMetaData.weaponNr) --Handles an attack order given to the carrier. if not recallDrones and cmdID == CMD.ATTACK or weapontarget then @@ -1298,7 +1384,8 @@ local function updateCarrier(carrierID, carrierMetaData, frame) end local rx, rz - local resourceFrames = (frame - previousHealFrame) / 30 + local resourceFrames = (frame - (carrierMetaData.lastUpdateFrame or frame)) / GAME_SPEED + carrierMetaData.lastUpdateFrame = frame local attackFormationPosition = 0 local attackFormationSide = 0 @@ -1330,7 +1417,7 @@ local function updateCarrier(carrierID, carrierMetaData, frame) local droneCurrentHealth, droneMaxHealth = spGetUnitHealth(subUnitID) local droneAlive = true - if droneDocked and droneData.maxAmmo > 0 then + if droneDocked and droneData.maxAmmo > 0 and droneData.remainingAmmo ~= droneData.maxAmmo then droneData.remainingAmmo = droneData.maxAmmo spSetUnitUseAirLos(subUnitID, carrierMetaData.isAirUnit) end @@ -1377,15 +1464,17 @@ local function updateCarrier(carrierID, carrierMetaData, frame) end if droneAlive and carrierMetaList[carrierID] then if droneType == "printer" or droneType == "passenger" then + elseif droneData.activeDocking and not droneDocked and droneType ~= "bomber" then + -- flying back to dock: the landing coroutine owns this drone's orders until it is attached, + -- any order given here would only be overridden by it and delay the docking elseif droneData and droneType == "turret" then - spGiveOrderToUnit(subUnitID, CMD.FIRE_STATE, cachedFireState, 0) + setDroneFireState(subUnitID, cachedFireState) elseif droneData and droneDistance then if (attackOrder or setTargetOrder or fightOrder) and not droneInFormation then -- drones fire at will if carrier has an attack/target order -- a drone bomber probably should not do this - if droneType == "bomber" or droneData.activeDocking then - else - spGiveOrderToUnit(subUnitID, CMD.FIRE_STATE, 2, 0) + if droneType ~= "bomber" and not droneData.activeDocking then + setDroneFireState(subUnitID, 2) end end if @@ -1516,16 +1605,17 @@ local function updateCarrier(carrierID, carrierMetaData, frame) end else if fightOrder then - local cQueue = GetUnitCommands(subUnitID, 4) - for j = 1, (cQueue and #cQueue or 0) do - if cQueue[j].id == CMD.ATTACK and cachedFireState > 0 then - idleTarget = cQueue[j].params - break + if cachedFireState > 0 then + local attackCmd, p1, p2, p3, p4 = findCombatCommand(subUnitID, true, false) + if attackCmd then + idleTarget = packParams(p1, p2, p3, p4) end end if idleTarget then - spGiveOrderToUnit(subUnitID, CMD.ATTACK, idleTarget, 0) + if not hasAttackOrder(subUnitID, idleTarget) then + spGiveOrderToUnit(subUnitID, CMD_ATTACK, idleTarget, 0) + end else local figthRadius = carrierMetaData.radius * 0.2 rx, rz = randomPointInUnitCircle(5) @@ -1551,8 +1641,8 @@ local function updateCarrier(carrierID, carrierMetaData, frame) spGiveOrderToUnit(subUnitID, CMD.LOAD_UNITS, target) end end - else - spGiveOrderToUnit(subUnitID, CMD.ATTACK, target, 0) + elseif not hasAttackOrder(subUnitID, target) then + spGiveOrderToUnit(subUnitID, CMD_ATTACK, target, 0) end end end @@ -1603,21 +1693,11 @@ local function updateCarrier(carrierID, carrierMetaData, frame) and not (droneType == "bomber") and not (droneType == "abductor") then - -- return to carrier unless in combat - local cQueue = GetUnitCommands(subUnitID, 4) - local engaged = false - for j = 1, (cQueue and #cQueue or 0) do - if cQueue[j].id == CMD.ATTACK and cachedFireState > 0 then - -- if currently fighting AND not on hold fire - engaged = true - if agressiveDrones then - idleTarget = cQueue[j].params - end - break - elseif cQueue[j].id == CMD.REPAIR then - engaged = true - break - end + -- return to carrier unless in combat (fighting only counts when not on hold fire) + local combatCmd, p1, p2, p3, p4 = findCombatCommand(subUnitID, cachedFireState > 0, true) + local engaged = combatCmd ~= nil + if combatCmd == CMD_ATTACK and aggressiveDrones then + idleTarget = packParams(p1, p2, p3, p4) end droneData.engaged = engaged -- if not engaged and ((frame % DEFAULT_UPDATE_ORDER_FREQUENCY) == 0) then @@ -1638,15 +1718,7 @@ local function updateCarrier(carrierID, carrierMetaData, frame) { carrierx, carriery, carrierz, carrierMetaData.radius }, 0 ) - local cQueue = GetUnitCommands(subUnitID, 4) - local engaged = false - for j = 1, (cQueue and #cQueue or 0) do - if cQueue[j].id == CMD.REPAIR then - engaged = true - break - end - end - if not engaged then + if not findCombatCommand(subUnitID, false, true) then spGiveOrderToUnit( subUnitID, CMD.MOVE, @@ -1656,7 +1728,9 @@ local function updateCarrier(carrierID, carrierMetaData, frame) end else if idleTarget then - spGiveOrderToUnit(subUnitID, CMD.ATTACK, idleTarget, 0) + if not hasAttackOrder(subUnitID, idleTarget) then + spGiveOrderToUnit(subUnitID, CMD_ATTACK, idleTarget, 0) + end else if droneType == "fighter" then spGiveOrderToUnit( @@ -1705,180 +1779,184 @@ function gadget:UnitCommand( if inUnitCommand then return end + local carrierMetaData = carrierMetaList[unitID] + if not carrierMetaData then + return + end inUnitCommand = true - if carrierMetaList[unitID] then - if cmdID == CMD.FIRE_STATE then - carrierMetaList[unitID].cachedFireState = cmdParams[1] - elseif cmdID == CMD.MOVE_STATE then - carrierMetaList[unitID].cachedMoveState = cmdParams[1] - end + if cmdID == CMD_FIRE_STATE then + carrierMetaData.cachedFireState = cmdParams[1] + elseif cmdID == CMD_MOVE_STATE then + carrierMetaData.cachedMoveState = cmdParams[1] end - if carrierMetaList[unitID] and cmdID == CMD.STOP then - for subUnitID, value in pairsNext, carrierMetaList[unitID].subUnitsList do + if cmdID == CMD_STOP then + local px, py, pz = spGetUnitPosition(unitID) + for subUnitID in pairsNext, carrierMetaData.subUnitsList do if unitID == droneCarrierIdList[subUnitID] then spGiveOrderToUnit(subUnitID, cmdID, cmdParams, cmdOptions) - local px, py, pz = spGetUnitPosition(unitID) - spGiveOrderToUnit(subUnitID, CMD.MOVE, { px, py, pz }, 0) + spGiveOrderToUnit(subUnitID, CMD_MOVE, { px, py, pz }, 0) end end - elseif carrierMetaList[unitID] and (cmdID ~= CMD.MOVE and cmdID ~= CMD.FIRE_STATE and cmdID ~= CMD.STOCKPILE) then - carrierMetaList[unitID].activeRecall = false - local f = Spring.GetGameFrame() - updateCarrier(unitID, carrierMetaList[unitID], f) + elseif cmdID ~= CMD_MOVE and cmdID ~= CMD_FIRE_STATE and cmdID ~= CMD_STOCKPILE then + carrierMetaData.activeRecall = false + updateCarrier(unitID, carrierMetaData, spGetGameFrame()) end inUnitCommand = false end -local function dockUnits(dockingqueue, queuestart, queueend) - for i = queuestart, queueend do - local unitID = dockingqueue[i].ownerID - local subUnitID = dockingqueue[i].subunitID - local ox, oy, oz = spGetUnitPosition(unitID) +-- Coroutine body that brings one drone back onto its docking piece and attaches it. +-- Resumed once per frame (see updateCoroutines) until the drone is docked or either unit is gone. +local function landLoop(unitID, subUnitID, droneMetaData) + local carrierMetaData = carrierMetaList[unitID] + if not carrierMetaData then + return + end + local pieceNumber = droneMetaData.dockingPiece + local dronetype = droneMetaData.dronetype + local droneDocked = droneMetaData.docked + local approachSpeed = droneMetaData.approachSpeed or 1 + local orderedX, orderedZ -- dock position the drone was last sent to, nil until the first approach order + local dockingSnapRange + + while not droneDocked do + local px, py, pz = spGetUnitPiecePosDir(unitID, pieceNumber) local subx, suby, subz = spGetUnitPosition(subUnitID) - local dockingSnapRange - - if unitID and subUnitID and carrierMetaList[unitID] then - if carrierMetaList[unitID].subUnitsList[subUnitID] then - local droneMetaData = carrierMetaList[unitID].subUnitsList[subUnitID] - if droneMetaData.dockingPiece then - local pieceNumber = droneMetaData.dockingPiece - local dronetype = droneMetaData.dronetype - local droneDocked = droneMetaData.docked - local function landLoop() - if not carrierMetaList[unitID] then - return - elseif not droneMetaData then - return - end - while not droneDocked do - local px, py, pz = spGetUnitPiecePosDir(unitID, pieceNumber) - subx, suby, subz = spGetUnitPosition(subUnitID) - local distance = diag((px - subx), (pz - subz)) - local heightDifference = diag(py - suby) - - if not distance then - return - end - if distance < 25 and droneMetaData.isAirUnit then - local landingspeed = carrierMetaList[unitID].dockHelperSpeed - if 0.2 * heightDifference > landingspeed then - landingspeed = 0.2 * heightDifference - end - local magnitude = diag((subx - px), (suby - py), (subz - pz)) - if magnitude == 0 then - magnitude = 0.0001 - end - local vx, vy, vz = px - subx, py - suby, pz - subz - vx, vy, vz = - landingspeed * vx / magnitude, - landingspeed * vy / magnitude, - landingspeed * vz / magnitude - spSetUnitVelocity(subUnitID, vx, vy, vz) - elseif distance < carrierMetaList[unitID].dockRadius then - local landingspeed = carrierMetaList[unitID].dockHelperSpeed - local magnitude = diag((subx - px), (suby - py), (subz - pz)) - if magnitude == 0 then - magnitude = 0.0001 - end - local vx, vy, vz = px - subx, py - suby, pz - subz - vx, vy, vz = - landingspeed * vx / magnitude, - landingspeed * vy / magnitude, - landingspeed * vz / magnitude - Spring.MoveCtrl.Enable(subUnitID) - mcSetPosition(subUnitID, subx + vx, suby, subz + vz) - Spring.MoveCtrl.Disable(subUnitID) - spSetUnitVelocity(subUnitID, vx, 0, vz) - heightDifference = 0 - else - if dronetype == "bomber" then - spGiveOrderToUnit(subUnitID, CMD.MOVE, { px, py, pz }, 0) - else - spGiveOrderToUnit(subUnitID, CMD.STOP, {}, 0) - spGiveOrderToUnit(subUnitID, CMD.MOVE, { px, py, pz }, 0) - end - end + local distance = diag((px - subx), (pz - subz)) + local heightDifference = diag(py - suby) + local skipFrames = 0 - carrierMetaList[unitID].activeDocking = true - if carrierMetaList[unitID].dockHelperSpeed == 0 then - dockingSnapRange = carrierMetaList[unitID].dockRadius - else - dockingSnapRange = carrierMetaList[unitID].dockHelperSpeed - end - - if - distance < dockingSnapRange - and heightDifference < dockingSnapRange - and droneDocked ~= true - then - spUnitAttach(unitID, subUnitID, pieceNumber) - spGiveOrderToUnit(subUnitID, CMD.STOP, {}, 0) - spGiveOrderToUnit(subUnitID, CMD.FIRE_STATE, 0, 0) - Spring.MoveCtrl.Disable(subUnitID) - spSetUnitVelocity(subUnitID, 0, 0, 0) - if not carrierMetaList[unitID].manualDrones then - setDroneNoSelect(subUnitID, true) - end - spSetUnitUseAirLos(subUnitID, carrierMetaList[unitID].isAirUnit) - droneDocked = true - droneMetaData.docked = true - droneMetaData.activeDocking = false - droneMetaData.bomberStage = 0 - if carrierMetaList[unitID].dockArmor then - spSetUnitArmored(subUnitID, true, carrierMetaList[unitID].dockArmor) - end - local pieceAngle = nil - local _, pieceAngleResult = - spCallCOBScript(unitID, "DroneDocked", 5, pieceAngle, pieceNumber) - spCallCOBScript( - subUnitID, - "Docked", - 0, - carrierMetaList[unitID].cobdockparam, - pieceNumber, - pieceAngleResult - ) - - if dronetype == "abductor" then - local transportedUnit = Spring.GetUnitIsTransporting(subUnitID) - if transportedUnit[1] then - local transportedUnitDefID = Spring.GetUnitDefID(transportedUnit[1]) - if transportedUnitDefID then - for dronetypeIndex, dronename in pairs(carrierMetaList[unitID].dronenames) do - if carrierMetaList[unitID].dronetypes[dronetypeIndex] == "printer" then - carrierMetaList[unitID].printerUnitDefID = transportedUnitDefID - spDestroyUnit(transportedUnit[1]) - end - end - end - end - end - if dronetype == "turret" then - else - Spring.SetUnitCOBValue(subUnitID, COB.ACTIVATION, 0) - end - end + if not distance then + return + end + if distance < 25 and droneMetaData.isAirUnit then + local landingspeed = carrierMetaData.dockHelperSpeed + if 0.2 * heightDifference > landingspeed then + landingspeed = 0.2 * heightDifference + end + local magnitude = diag((subx - px), (suby - py), (subz - pz)) + if magnitude == 0 then + magnitude = 0.0001 + end + local vx, vy, vz = px - subx, py - suby, pz - subz + vx, vy, vz = landingspeed * vx / magnitude, landingspeed * vy / magnitude, landingspeed * vz / magnitude + spSetUnitVelocity(subUnitID, vx, vy, vz) + elseif distance < carrierMetaData.dockRadius then + local landingspeed = carrierMetaData.dockHelperSpeed + local magnitude = diag((subx - px), (suby - py), (subz - pz)) + if magnitude == 0 then + magnitude = 0.0001 + end + local vx, vy, vz = px - subx, py - suby, pz - subz + vx, vy, vz = landingspeed * vx / magnitude, landingspeed * vy / magnitude, landingspeed * vz / magnitude + mcEnable(subUnitID) + mcSetPosition(subUnitID, subx + vx, suby, subz + vz) + mcDisable(subUnitID) + spSetUnitVelocity(subUnitID, vx, 0, vz) + heightDifference = 0 + else + -- Still far away: send the drone to the dock and only refresh that order when something + -- else took over its queue or the carrier moved. Re-issuing STOP+MOVE every frame for + -- every approaching drone used to dominate this gadget's frame time. + local cmdID, _, _, cx, _, cz = spGetUnitCurrentCommand(subUnitID) + local onApproach = cmdID == CMD_MOVE + and orderedX ~= nil + and cz ~= nil + and (cx - orderedX) * (cx - orderedX) + (cz - orderedZ) * (cz - orderedZ) < 1 + if + not onApproach + or (px - orderedX) * (px - orderedX) + (pz - orderedZ) * (pz - orderedZ) + > DOCK_ORDER_REFRESH_DISTANCE * DOCK_ORDER_REFRESH_DISTANCE + then + if dronetype ~= "bomber" then + spGiveOrderToUnit(subUnitID, CMD_STOP, {}, 0) + end + spGiveOrderToUnit(subUnitID, CMD_MOVE, { px, py, pz }, 0) + orderedX, orderedZ = px, pz + end + -- no point in checking again before the drone can possibly reach the docking radius + skipFrames = floor((distance - carrierMetaData.dockRadius) / approachSpeed) + if skipFrames > DOCK_APPROACH_MAX_SKIP_FRAMES then + skipFrames = DOCK_APPROACH_MAX_SKIP_FRAMES + end + end - Sleep() + carrierMetaData.activeDocking = true + if carrierMetaData.dockHelperSpeed == 0 then + dockingSnapRange = carrierMetaData.dockRadius + else + dockingSnapRange = carrierMetaData.dockHelperSpeed + end - if not carrierMetaList[unitID] then - return - elseif not droneMetaData then - return - else - local droneCurrentHealth = spGetUnitHealth(subUnitID) - if not droneCurrentHealth then - return - elseif droneCurrentHealth <= 0 then - return - end + if distance < dockingSnapRange and heightDifference < dockingSnapRange and droneDocked ~= true then + spUnitAttach(unitID, subUnitID, pieceNumber) + spGiveOrderToUnit(subUnitID, CMD_STOP, {}, 0) + spGiveOrderToUnit(subUnitID, CMD_FIRE_STATE, 0, 0) + mcDisable(subUnitID) + spSetUnitVelocity(subUnitID, 0, 0, 0) + if not carrierMetaData.manualDrones then + setDroneNoSelect(subUnitID, true) + end + spSetUnitUseAirLos(subUnitID, carrierMetaData.isAirUnit) + droneDocked = true + droneMetaData.docked = true + droneMetaData.activeDocking = false + droneMetaData.bomberStage = 0 + if carrierMetaData.dockArmor then + spSetUnitArmored(subUnitID, true, carrierMetaData.dockArmor) + end + local pieceAngle = nil + local _, pieceAngleResult = spCallCOBScript(unitID, "DroneDocked", 5, pieceAngle, pieceNumber) + spCallCOBScript(subUnitID, "Docked", 0, carrierMetaData.cobdockparam, pieceNumber, pieceAngleResult) + + if dronetype == "abductor" then + local transportedUnit = Spring.GetUnitIsTransporting(subUnitID) + if transportedUnit[1] then + local transportedUnitDefID = Spring.GetUnitDefID(transportedUnit[1]) + if transportedUnitDefID then + for dronetypeIndex, dronename in pairs(carrierMetaData.dronenames) do + if carrierMetaData.dronetypes[dronetypeIndex] == "printer" then + carrierMetaData.printerUnitDefID = transportedUnitDefID + spDestroyUnit(transportedUnit[1]) end end end - - startScript(landLoop) end end + if dronetype ~= "turret" then + spSetUnitCOBValue(subUnitID, COB.ACTIVATION, 0) + end + end + + repeat + Sleep() + if + carrierMetaList[unitID] ~= carrierMetaData + or carrierMetaData.subUnitsList[subUnitID] ~= droneMetaData + then + return + end + local droneCurrentHealth = spGetUnitHealth(subUnitID) + if not droneCurrentHealth or droneCurrentHealth <= 0 then + return + end + skipFrames = skipFrames - 1 + until skipFrames < 0 + end +end + +local function dockUnits(dockingqueue, queuestart, queueend) + for i = queuestart, queueend do + local unitID = dockingqueue[i].ownerID + local subUnitID = dockingqueue[i].subunitID + local carrierMetaData = unitID and carrierMetaList[unitID] + local droneMetaData = carrierMetaData and subUnitID and carrierMetaData.subUnitsList[subUnitID] + if droneMetaData and droneMetaData.dockingPiece then + startScript(function() + landLoop(unitID, subUnitID, droneMetaData) + if not droneMetaData.docked then + droneMetaData.activeDocking = false -- landing aborted, the drone may be queued again later + end + end) end end end @@ -1900,81 +1978,84 @@ function gadget:StockpileChanged(unitID, unitDefID, unitTeam, weaponNum, oldCoun end end -function gadget:GameFrame(f) - updateCoroutines() - if f % GAME_SPEED ~= 0 then - return +local function checkCarrierSpawn(unitID, carrierMetaData, f) + local isDoneBuilding = not spGetUnitIsBeingBuilt(unitID) + if isDoneBuilding then + carrierMetaData.wasBuilt = true end - if (DEFAULT_SPAWN_CHECK_FREQUENCY + lastSpawnCheck) < f then - lastSpawnCheck = f - for unitID, _ in pairs(safe(carrierMetaList)) do - local isDoneBuilding = not spGetUnitIsBeingBuilt(unitID) - if isDoneBuilding then - carrierMetaList[unitID].wasBuilt = true - end - if carrierMetaList[unitID].startingWithDrones and carrierMetaList[unitID].wasBuilt and isDoneBuilding then - local spawnData = carrierMetaList[unitID].subInitialSpawnData - local x, y, z = spGetUnitPosition(unitID) - spawnData.x = x - spawnData.y = y - spawnData.z = z - if x then - spawnUnit(spawnData) - end - elseif carrierMetaList[unitID].spawnRateFrames == 0 then - elseif - ( - (carrierMetaList[unitID].spawnRateFrames + carrierMetaList[unitID].lastSpawn) < f - and carrierMetaList[unitID].activeSpawning == 1 - and isDoneBuilding - ) and not carrierMetaList[unitID].usestockpile - then - local spawnData = carrierMetaList[unitID].subInitialSpawnData - local x, y, z = spGetUnitPosition(unitID) - spawnData.x = x - spawnData.y = y - spawnData.z = z - if x then - spawnUnit(spawnData) - carrierMetaList[unitID].lastSpawn = f - end - end + if carrierMetaData.startingWithDrones and carrierMetaData.wasBuilt and isDoneBuilding then + local spawnData = carrierMetaData.subInitialSpawnData + local x, y, z = spGetUnitPosition(unitID) + spawnData.x = x + spawnData.y = y + spawnData.z = z + if x then + spawnUnit(spawnData) + end + elseif carrierMetaData.spawnRateFrames == 0 then + elseif + (carrierMetaData.spawnRateFrames + carrierMetaData.lastSpawn) < f + and carrierMetaData.activeSpawning == 1 + and isDoneBuilding + and not carrierMetaData.usestockpile + then + local spawnData = carrierMetaData.subInitialSpawnData + local x, y, z = spGetUnitPosition(unitID) + spawnData.x = x + spawnData.y = y + spawnData.z = z + if x then + spawnUnit(spawnData) + carrierMetaData.lastSpawn = f end end +end + +function gadget:GameFrame(f) + updateCoroutines() - if (CARRIER_UPDATE_FREQUENCY + lastCarrierUpdate) < f then - lastCarrierUpdate = f - for unitID, _ in pairsNext, safe(carrierMetaList) do - local carrierMetaData = carrierMetaList[unitID] + -- Each carrier gets its spawn check and drone update once per CARRIER_UPDATE_PERIOD frames, on + -- the frame matching its unitID, so the work is spread over the period instead of all carriers + -- (and all their drone orders) landing in the same frame. + local phase = f % CARRIER_UPDATE_PERIOD + local count = 0 + for unitID in pairsNext, carrierMetaList do + if unitID % CARRIER_UPDATE_PERIOD == phase then + count = count + 1 + carrierUpdateList[count] = unitID + end + end + for i = 1, count do + local unitID = carrierUpdateList[i] + local carrierMetaData = carrierMetaList[unitID] + if carrierMetaData then + checkCarrierSpawn(unitID, carrierMetaData, f) + carrierMetaData = carrierMetaList[unitID] if carrierMetaData then -- updates can chain-kill carriers updateCarrier(unitID, carrierMetaData, f) end end + end + + if f % GAME_SPEED == 0 then updateStandaloneDrones(f) previousHealFrame = f end - if (DEFAULT_DOCK_CHECK_FREQUENCY + lastDockCheck) < f then - lastDockCheck = f - if carrierQueuedDockingCount > 0 then -- Initiate docking for units in the docking queue and reset the queue. - local availableDockingCount = (carrierAvailableDockingCount - #coroutines) - local carrierActiveDockingList = {} - local carrierDockingCount = 0 - if (carrierQueuedDockingCount - dockingQueueOffset) > availableDockingCount then - carrierActiveDockingList = carrierDockingList - dockUnits( - carrierActiveDockingList, - (dockingQueueOffset + 1), - (dockingQueueOffset + availableDockingCount) - ) - dockingQueueOffset = dockingQueueOffset + availableDockingCount - else - carrierActiveDockingList = carrierDockingList - carrierDockingCount = carrierQueuedDockingCount - carrierQueuedDockingCount = 0 - dockUnits(carrierActiveDockingList, (dockingQueueOffset + 1), carrierDockingCount) - dockingQueueOffset = 0 - end + if carrierQueuedDockingCount > 0 then + -- Start the landing coroutines for the queued drones, at most carrierAvailableDockingCount at a time. + local availableDockingCount = carrierAvailableDockingCount - coroutineCount + if availableDockingCount < 0 then + availableDockingCount = 0 + end + if (carrierQueuedDockingCount - dockingQueueOffset) > availableDockingCount then + dockUnits(carrierDockingList, dockingQueueOffset + 1, dockingQueueOffset + availableDockingCount) + dockingQueueOffset = dockingQueueOffset + availableDockingCount + else + local queueEnd = carrierQueuedDockingCount + carrierQueuedDockingCount = 0 + dockUnits(carrierDockingList, dockingQueueOffset + 1, queueEnd) + dockingQueueOffset = 0 end end end diff --git a/luarules/gadgets/unit_commando_watch.lua b/luarules/gadgets/unit_commando_watch.lua index 6d0c6d5123f..bd3993b6595 100644 --- a/luarules/gadgets/unit_commando_watch.lua +++ b/luarules/gadgets/unit_commando_watch.lua @@ -21,21 +21,29 @@ end local MAPSIZEX = Game.mapSizeX local MAPSIZEZ = Game.mapSizeZ -local MINE2 = UnitDefNames.cormine4.id local mines = {} local MINE_BLAST = {} MINE_BLAST[WeaponDefNames.mine_light.id] = true MINE_BLAST[WeaponDefNames.mine_medium.id] = true MINE_BLAST[WeaponDefNames.mine_heavy.id] = true -local isBuilding = {} -local isCommando = {} +local isMine = {} +local isParatrooper = {} +local isMineResistant = {} +local isStealthsTransport = {} for udid, ud in pairs(UnitDefs) do - if string.find(ud.name, "cormando") then - isCommando[udid] = true + local cp = ud.customParams + if cp.mine then + isMine[udid] = true end - if ud.isBuilding then - isBuilding[udid] = true + if cp.paratrooper then + isParatrooper[udid] = true + end + if cp.mine_resistant then + isMineResistant[udid] = true + end + if cp.stealths_transport then + isStealthsTransport[udid] = true end end @@ -51,19 +59,17 @@ function gadget:UnitPreDamaged( attackerDefID, attackerTeam ) - if isCommando[unitDefID] then - if weaponID < 0 then - local x, y, z = Spring.GetUnitPosition(unitID) - if x < 0 or z < 0 or x > MAPSIZEX or z > MAPSIZEZ then - Spring.DestroyUnit(unitID) - return damage, 1 - end - x, y, z = Spring.GetUnitVelocity(unitID) - Spring.AddUnitImpulse(unitID, x * -0.66, y * -0.66, z * -0.66) - return damage * 0.12, 0 - elseif MINE_BLAST[weaponID] then - return damage * 0.12, 0.24 + if isParatrooper[unitDefID] and weaponID < 0 then + local x, y, z = Spring.GetUnitPosition(unitID) + if x < 0 or z < 0 or x > MAPSIZEX or z > MAPSIZEZ then + Spring.DestroyUnit(unitID) + return damage, 1 end + x, y, z = Spring.GetUnitVelocity(unitID) + Spring.AddUnitImpulse(unitID, x * -0.66, y * -0.66, z * -0.66) + return damage * 0.12, 0 + elseif isMineResistant[unitDefID] and MINE_BLAST[weaponID] then + return damage * 0.12, 0.24 elseif mines[unitID] and (attackerID == mines[unitID]) then return 0, 0 end @@ -71,7 +77,7 @@ function gadget:UnitPreDamaged( end function gadget:UnitCreated(unitID, unitDefID, unitTeam, builderID) - if builderID and unitDefID == MINE2 and isCommando[Spring.GetUnitDefID(builderID)] then + if builderID and isMine[unitDefID] and isMineResistant[Spring.GetUnitDefID(builderID)] then mines[unitID] = builderID end end @@ -85,13 +91,13 @@ function gadget:UnitFinished(unitID, unitDefID, unitTeam) end function gadget:UnitLoaded(unitID, unitDefID, unitTeam, transportID, transportTeam) - if isCommando[unitDefID] then + if isStealthsTransport[unitDefID] then Spring.SetUnitStealth(transportID, true) end end function gadget:UnitUnloaded(unitID, unitDefID, teamID, transportID) - if isCommando[unitDefID] then + if isStealthsTransport[unitDefID] then Spring.SetUnitStealth(transportID, false) end end diff --git a/luarules/gadgets/unit_continuous_aim.lua b/luarules/gadgets/unit_continuous_aim.lua index c38c4bcd99d..07f802f4c09 100644 --- a/luarules/gadgets/unit_continuous_aim.lua +++ b/luarules/gadgets/unit_continuous_aim.lua @@ -17,227 +17,33 @@ if not gadgetHandler:IsSyncedCode() then end local spSetUnitWeaponState = Spring.SetUnitWeaponState -local tableCopy = table.copy -local convertedUnitsNames = { - -- value is reaimtime in frames, engine default is 15 - armfav = 3, - armbeamer = 3, - armpw = 2, - armpwt4 = 2, - corakt4 = 2, - armflea = 2, - armrock = 2, - armham = 2, - armwar = 6, - armjeth = 2, - corfav = 3, - corak = 2, - corthud = 2, - corstorm = 2, - corcrash = 5, - legkark = 2, - corkark = 2, - cordeadeye = 2, - armsnipe = 2, - armfido = 3, - armfboy = 2, - armfast = 2, - armamph = 3, - armmav = 2, - armspid = 3, - armsptk = 5, - armzeus = 3, - coramph = 3, - corcan = 2, - corhrk = 5, - cormando = 2, - cormort = 2, - corpyro = 2, - cortermite = 2, - armraz = 6, - armmar = 3, - armbanth = 1, - corkorg = 1, - armvang = 3, - armcrus = 5, - corsala = 6, - corsiegebreaker = 5, - legerailtank = 9, - - -- the following units get a faster reaimtime to counteract their turret acceleration - armthor = 4, - armflash = 6, - corgator = 6, - armdecade = 6, - coresupp = 6, - corhlt = 5, - corfhlt = 5, - cordoom = 5, - corshiva = 5, - corcat = 5, - corkarg = 3, - corkarganetht4 = 3, - corbhmth = 5, - armguard = 5, - armamb = 5, - corpun = 5, - cortoast = 5, - corbats = 5, - corblackhy = 6, - corscreamer = 5, - corcom = 5, - armcom = 5, - cordecom = 5, - armdecom = 5, - legcom = 5, - legdecom = 5, - legcomlvl2 = 5, - legcomlvl3 = 5, - legcomlvl4 = 5, - legcomlvl5 = 5, - legcomlvl6 = 5, - legcomlvl7 = 5, - legcomlvl8 = 5, - legcomlvl9 = 5, - legcomlvl10 = 5, - legah = 5, - legbal = 5, - legbastion = 5, - legcen = 3, - legfloat = 5, - leggat = 5, - leggob = 2, - leggobt3 = 5, - leginc = 1, - cordemon = 6, - corcrwh = 7, - leglob = 5, - legmos = 5, - leghades = 5, - leghelios = 5, - legheavydrone = 5, - legkeres = 5, - legrail = 5, - legbar = 5, - legcomoff = 5, - legcomt2off = 5, - legcomt2com = 5, - legstr = 3, - legamph = 4, - legaheattank = 4, - legbart = 5, - legmrv = 5, - legsco = 5, - leegmech = 5, - legionnaire = 5, - legafigdef = 5, - legvenator = 5, - legmed = 5, - legaskirmtank = 5, - legaheattank = 3, - legeheatraymech = 1, - legtriariusdrone = 1, - legnavydestro = 4, - legeheatraymech_old = 1, - legbunk = 3, - legrwall = 4, - legjav = 1, - legeshotgunmech = 3, - legehovertank = 4, - armanavaldefturret = 4, - leganavyflagship = 4, - leganavyantiswarm = 5, - leganavycruiser = 5, -} ---add entries for scavboss -local scavengerBossV4Table = { - "scavengerbossv4_veryeasy", - "scavengerbossv4_easy", - "scavengerbossv4_normal", - "scavengerbossv4_hard", - "scavengerbossv4_veryhard", - "scavengerbossv4_epic", - "scavengerbossv4_veryeasy_scav", - "scavengerbossv4_easy_scav", - "scavengerbossv4_normal_scav", - "scavengerbossv4_hard_scav", - "scavengerbossv4_veryhard_scav", - "scavengerbossv4_epic_scav", -} -for _, name in pairs(scavengerBossV4Table) do - convertedUnitsNames[name] = 4 -end ---if Spring.GetModOptions().emprework then ---convertedUnitsNames['armdfly'] = 50 ---end --- convert unitname -> unitDefID -local convertedUnits = {} -for name, params in pairs(convertedUnitsNames) do - if UnitDefNames[name] then - convertedUnits[UnitDefNames[name].id] = params - end -end -convertedUnitsNames = nil +-- customparams.reaimtime is the reaim time in frames (engine default is 15) +-- customparams.reaim_spam additionally degrades the reaim time as a team keeps building the unit +local convertedUnits = {} --{unitDefID = reaimTime} +local spamUnitsTeams = {} --{unitDefID = {teamID = totalcreated,...}} +local spamUnitsTeamsReaimTimes = {} --{unitDefID = {teamID = currentReAimTime,...}} +local unitWeapons = {} -local spamUnitsTeamsNames = { --{unitDefID = {teamID = totalcreated,...}} - armpw = {}, - armflea = {}, - armfav = {}, - corak = {}, - corfav = {}, -} --- convert unitname -> unitDefID -local spamUnitsTeams = {} -for name, params in pairs(spamUnitsTeamsNames) do - if UnitDefNames[name] then - spamUnitsTeams[UnitDefNames[name].id] = params +for unitDefID, unitDef in pairs(UnitDefs) do + local reaimTime = tonumber(unitDef.customParams.reaimtime) + if reaimTime and #unitDef.weapons > 0 then + convertedUnits[unitDefID] = reaimTime + unitWeapons[unitDefID] = {} + for id, _ in pairs(unitDef.weapons) do + unitWeapons[unitDefID][id] = true -- no need to store weapondefid + end + if unitDef.customParams.reaim_spam then + spamUnitsTeams[unitDefID] = {} + spamUnitsTeamsReaimTimes[unitDefID] = {} + end end end -spamUnitsTeamsNames = nil - -local spamUnitsTeamsReaimTimes = {} --{unitDefID = {teamID = currentReAimTime,...}} -- for every spamThreshold'th spammable unit type built by this team, increase reaimtime by 1 for that team local spamThreshold = 100 local maxReAimTime = 15 --- add for scavengers copies -local convertedUnitsCopy = tableCopy(convertedUnits) -for id, v in pairs(convertedUnitsCopy) do - if UnitDefNames[UnitDefs[id].name .. "_scav"] then - convertedUnits[UnitDefNames[UnitDefs[id].name .. "_scav"].id] = v - end -end - -local spamUnitsTeamsCopy = tableCopy(spamUnitsTeams) -for id, v in pairs(spamUnitsTeamsCopy) do - if UnitDefNames[UnitDefs[id].name .. "_scav"] then - spamUnitsTeams[UnitDefNames[UnitDefs[id].name .. "_scav"].id] = {} - end -end - -for unitDefID, _ in pairs(spamUnitsTeams) do - spamUnitsTeamsReaimTimes[unitDefID] = {} -end - -local unitWeapons = {} -for unitDefID, _ in pairs(convertedUnits) do - local unitDef = UnitDefs[unitDefID] - if unitDef then - local weapons = unitDef.weapons - if #weapons > 0 then - unitWeapons[unitDefID] = {} - for id, _ in pairs(weapons) do - unitWeapons[unitDefID][id] = true -- no need to store weapondefid - end - else - -- units with no weapons shouldn't even be here - convertedUnits[unitDefID] = nil - end - end -end - function gadget:UnitCreated(unitID, unitDefID, teamID) if convertedUnits[unitDefID] then local currentReaimTime = convertedUnits[unitDefID] diff --git a/luarules/gadgets/unit_corpse_link.lua b/luarules/gadgets/unit_corpse_link.lua index 004774d64f1..e2c4162dd1e 100644 --- a/luarules/gadgets/unit_corpse_link.lua +++ b/luarules/gadgets/unit_corpse_link.lua @@ -20,11 +20,9 @@ end local CORPSE_LINK_TIMEOUT = Game.gameSpeed * 3 -- should be longer than the longest death animation local UPDATE_INTERVAL = Game.gameSpeed +-- unitDefID -> { [unitID] = { x, y, z, timeout } } local corpseRegistryByDefID = {} - -local function getPositionHash(x, z) - return string.format("%f:%f", math.floor(x), math.floor(z)) -end +local distance3dSquared = math.distance3dSquared local function GetFeatureResurrectDefID(featureID) local resurrectUnitName = Spring.GetFeatureResurrect(featureID) @@ -55,14 +53,37 @@ local function GetCorpsePriorUnitID(featureID) end local x, y, z = Spring.GetFeaturePosition(featureID) - local positionHash = getPositionHash(x, z) - local corpseLink = unitDefLink[positionHash] - if not corpseLink then + if not x then + return + end + + -- Snap to the closest pending death of this unitDef. Exact-position matching + -- breaks when death animations carry the wreck away from UnitDestroyed coords. + local bestUnitID + local bestDistSq + for unitID, corpseLink in pairs(unitDefLink) do + local distSq = distance3dSquared(corpseLink.x, corpseLink.y, corpseLink.z, x, y, z) + if bestDistSq == nil or distSq < bestDistSq then + bestDistSq = distSq + bestUnitID = unitID + end + end + + if not bestUnitID then return end - corpseLink[positionHash] = nil - return corpseLink.unitID + unitDefLink[bestUnitID] = nil + return bestUnitID +end + +local function ConsumeCorpseLink(unitID) + for _, unitDefLink in pairs(corpseRegistryByDefID) do + if unitDefLink[unitID] then + unitDefLink[unitID] = nil + return + end + end end function gadget:UnitDestroyed(unitID, unitDefID) @@ -76,9 +97,10 @@ function gadget:UnitDestroyed(unitID, unitDefID) return end - local positionHash = getPositionHash(x, z) - unitDefLink[positionHash] = { - unitID = unitID, + unitDefLink[unitID] = { + x = x, + y = y, + z = z, timeout = Spring.GetGameFrame() + CORPSE_LINK_TIMEOUT, } end @@ -89,10 +111,10 @@ function gadget:GameFrame(frame) end -- FIXME: could be sorted by timeout, so that we wouldn't have to iterate them all - for unitDefID, unitDefLink in pairs(corpseRegistryByDefID) do - for positionHash, corpseLink in pairs(unitDefLink) do + for _, unitDefLink in pairs(corpseRegistryByDefID) do + for unitID, corpseLink in pairs(unitDefLink) do if corpseLink.timeout < frame then - unitDefLink[positionHash] = nil + unitDefLink[unitID] = nil end end end @@ -106,7 +128,13 @@ function gadget:Initialize() originalFeatureCreated = gadgetHandler.FeatureCreated gadgetHandler.FeatureCreated = function(self, featureID, allyTeam, sourceID) - sourceID = sourceID or GG.GetCorpsePriorUnitID(featureID) + if sourceID then + -- Engine already linked this wreck; drop our fallback entry so it + -- cannot be snapped to by a later feature. + ConsumeCorpseLink(sourceID) + else + sourceID = GG.GetCorpsePriorUnitID(featureID) + end originalFeatureCreated(self, featureID, allyTeam, sourceID) end end diff --git a/luarules/gadgets/unit_custom_weapons_behaviours.lua b/luarules/gadgets/unit_custom_weapons_behaviours.lua index 83d7caf0a0d..83e4dcbeef3 100644 --- a/luarules/gadgets/unit_custom_weapons_behaviours.lua +++ b/luarules/gadgets/unit_custom_weapons_behaviours.lua @@ -66,7 +66,6 @@ local weaponCustomParamKeys = {} -- [effect] = { [key] = conversion function } local weaponDefEffect = {} local projectiles = {} -local projectilesData = {} local gameFrame = 0 @@ -166,8 +165,8 @@ local function getTargetPositionWithError(projectileID) end ---Translates TargetType integers to the ProjectileTargetType byte-integers needed in SetProjectileTarget. ----@param projectileID integer ----@param target integer|xyz? +---@param projectileID ProjectileID +---@param target UnitOrPosition? ---@param targetType TargetType local function setProjectileTarget(projectileID, target, targetType) if targetType == 1 then @@ -191,7 +190,7 @@ do team = -1, } - ---@return integer weaponDefID + ---@return WeaponDefID weaponDefID ---@return ProjectileParams projectileParams ---@return number parentSpeed getProjectileArgs = function(params, projectileID) @@ -397,8 +396,8 @@ weaponCustomParamKeys.guidance = { ---@class GuidanceEffectResult ---@field [1] boolean isFiring ---@field [2] TargetType guidanceType ----@field [3] boolean isUserTarget, nil when guidanceType is `0` ----@field [4] integer|xyz guidanceTarget, nil when guidanceType is `0` +---@field [3] boolean? isUserTarget, nil when guidanceType is `0` +---@field [4] (UnitOrPosition|ProjectileID)? guidanceTarget, nil when guidanceType is `0` local guidanceResults = {} ---@type table diff --git a/luarules/gadgets/unit_death_animations.lua b/luarules/gadgets/unit_death_animations.lua index d043093ddc3..c7cbc5057a3 100644 --- a/luarules/gadgets/unit_death_animations.lua +++ b/luarules/gadgets/unit_death_animations.lua @@ -22,40 +22,10 @@ local spGiveOrderToUnit = Spring.GiveOrderToUnit local spMoveCtrlEnable = Spring.MoveCtrl.Enable local spMoveCtrlDisable = Spring.MoveCtrl.Disable local spMoveCtrlSetVelocity = Spring.MoveCtrl.SetVelocity -local stringFind = string.find -local tableCopy = table.copy - -local units = { - corkarg = true, - corthud = true, - corstorm = true, - corsumo = true, - armraz = true, - armpw = true, - armck = true, - armrectr = true, - armrock = true, - armfast = true, - armzeus = true, - armfido = true, - armham = true, - corak = true, - corck = true, -} -local unitsCopy = tableCopy(units) -for name, v in pairs(unitsCopy) do - units[name .. "_scav"] = true -end local hasDeathAnim = {} for udid, ud in pairs(UnitDefs) do - if units[ud.name] then - hasDeathAnim[udid] = true - end -- almost all raptors have dying anims - if - stringFind(ud.name, "raptor", 1, true) - or (ud.customParams.subfolder and ud.customParams.subfolder == "other/raptors") - then + if ud.customParams.hasdeathanimation or ud.customParams.israptor then hasDeathAnim[udid] = true end end diff --git a/luarules/gadgets/unit_hats.lua b/luarules/gadgets/unit_hats.lua index eda353dc78d..54f0c0a304e 100644 --- a/luarules/gadgets/unit_hats.lua +++ b/luarules/gadgets/unit_hats.lua @@ -428,21 +428,12 @@ local spCallCOBScript = Spring.CallCOBScript local spGetGaiaTeamID = Spring.GetGaiaTeamID local stringSub = string.sub -local unitDefCanWearHats = { - [UnitDefNames.corcom.id] = true, - [UnitDefNames.cordecom.id] = true, - [UnitDefNames.armcom.id] = true, - [UnitDefNames.armdecom.id] = true, -} - -if Spring.GetModOptions().experimentallegionfaction then - unitDefCanWearHats[UnitDefNames.legcom.id] = true - unitDefCanWearHats[UnitDefNames.legdecom.id] = true -end - +local unitDefCanWearHats = {} local unitDefHat = {} for udid, ud in pairs(UnitDefs) do - --almost all raptors have dying anims + if ud.customParams.canwearcosmetics and not ud.customParams.isscavenger then + unitDefCanWearHats[udid] = true + end if ud.customParams.subfolder and ud.customParams.subfolder == "other/hats" then unitDefHat[udid] = true end @@ -516,7 +507,7 @@ function gadget:GameFrame(gf) if list[pick].implementation == "unit" then local units = spGetTeamUnits(teamID) or {} for k = 1, #units do - if not unitDefHat[units[k]] then + if not unitDefHat[spGetUnitDefID(units[k])] then local unitPosX, unitPosY, unitPosZ = spGetUnitPosition(units[k]) CreateAndGiveHat(list[pick].unitDefID, unitPosX, unitPosY, unitPosZ, teamID) end @@ -525,8 +516,8 @@ function gadget:GameFrame(gf) local units = spGetTeamUnits(teamID) or {} for k = 1, #units do local unitID = units[k] - if not unitDefHat[unitID] then - local unitDefID = spGetUnitDefID(unitID) + local unitDefID = spGetUnitDefID(unitID) + if not unitDefHat[unitDefID] then if stringSub(UnitDefs[unitDefID].name, 1, 3) == "arm" then local scriptEnv = spGetUnitScriptEnv(unitID) if scriptEnv then @@ -694,7 +685,7 @@ function gadget:UnitGiven(unitID, unitDefID, unitTeam) end end if DEBUG then - Spring.Echo("Hat was given, but found noone to put it onto, destroying", hatID) + Spring.Echo("Hat was given, but found no one to put it onto, destroying", hatID) end Spring.DestroyUnit(hatID) end diff --git a/luarules/gadgets/unit_infestor_spawn.lua b/luarules/gadgets/unit_infestor_spawn.lua index c10123b0705..d1c461d377a 100644 --- a/luarules/gadgets/unit_infestor_spawn.lua +++ b/luarules/gadgets/unit_infestor_spawn.lua @@ -19,14 +19,13 @@ if not gadgetHandler:IsSyncedCode() then return false end +-- both the builder and the new unit must carry customparams.guards_own_builder local infestor = {} -- setup -if UnitDefNames.leginfestor then - infestor[UnitDefNames.leginfestor.id] = true - - if UnitDefNames.leginfestor_scav then - infestor[UnitDefNames.leginfestor_scav.id] = true +for unitDefID, unitDef in pairs(UnitDefs) do + if unitDef.customParams.guards_own_builder then + infestor[unitDefID] = true end end diff --git a/luarules/gadgets/unit_interceptors.lua b/luarules/gadgets/unit_interceptors.lua index 913699d1cbb..1fd1646aef0 100644 --- a/luarules/gadgets/unit_interceptors.lua +++ b/luarules/gadgets/unit_interceptors.lua @@ -3,7 +3,7 @@ local gadget = gadget ---@type Gadget function gadget:GetInfo() return { name = "Don't target flyover nukes", - desc = "Antinukes can target flyover nukes, this gadget ensures that they dont.", + desc = "Antinukes can target flyover nukes, this gadget ensures that they don't.", author = "Beherith", date = "2023.11.09", license = "GNU GPL, v2 or later", diff --git a/luarules/gadgets/unit_intergrated_hats.lua b/luarules/gadgets/unit_intergrated_hats.lua index e12d84721ca..a792310c893 100644 --- a/luarules/gadgets/unit_intergrated_hats.lua +++ b/luarules/gadgets/unit_intergrated_hats.lua @@ -27,59 +27,18 @@ end local hatCounts = {} local unitCount = 0 do - local hats - - if BAR.Utilities.Gametype.GetCurrentHolidays().aprilfools then - hats = "april" - end - - if BAR.Utilities.Gametype.GetCurrentHolidays().halloween then - hats = "halloween" - end - - if hats then - -- count of how many hats a unit has for the hat mode - -- unit models should be swapped out to the appropate models via all defs post - local hatCountsTemp = {} - local hatTable = { - april = { -- objects3d/units/events/aprilfools, AprilFools hats - corak = 7, - corstorm = 7, - corck = 6, - corack = 6, - --correap=6, - corllt = 8, - corhllt = 8, - cordemon = 4, - armpw = 7, - armcv = 5, - armrock = 6, - armbull = 6, - armllt = 6, - corwin = 7, - armwin = 6, - armham = 5, - --corthud=6, - }, - halloween = { - corcom = 2, - }, - } - - hatCountsTemp = hatTable[hats] - -- if we failed to find hats - if not hatCountsTemp then - return false + -- customparams.holidayhatcount is stamped in alldefs_post next to the holiday model swap + -- (unitbasedefs/holiday_models.lua), so it only exists while the matching holiday is active + local anyHats = false + for unitDefID, unitDef in pairs(UnitDefs) do + local numberOfHats = tonumber(unitDef.customParams.holidayhatcount) + if numberOfHats and numberOfHats > 0 then + hatCounts[unitDefID] = numberOfHats + anyHats = true end + end - -- make sure we didn't blunder unit names, or the unit in question is loaded - for unitName, hatsNo in pairs(hatCountsTemp) do - local tmp = UnitDefNames[unitName] - if tmp and tmp.id then - hatCounts[tmp.id] = hatsNo - end - end - else + if not anyHats then return false end end diff --git a/luarules/gadgets/unit_juno_damage.lua b/luarules/gadgets/unit_juno_damage.lua index 1987ceeb312..37dfb267bc8 100644 --- a/luarules/gadgets/unit_juno_damage.lua +++ b/luarules/gadgets/unit_juno_damage.lua @@ -20,113 +20,17 @@ if gadgetHandler:IsSyncedCode() then ---------------------------------------------------------------- -- Config ---------------------------------------------------------------- - local tokillUnitsNames = { - armarad = true, - armaser = true, - armason = true, - armeyes = true, - armfrad = true, - armjam = true, - armjamt = true, - armmark = true, - armrad = true, - armseer = true, - armsjam = true, - armsonar = true, - armveil = true, - corarad = true, - corason = true, - coreter = true, - coreyes = true, - corfrad = true, - corjamt = true, - corrad = true, - legjam = true, - legrad = true, - corshroud = true, - corsjam = true, - corsonar = true, - corspec = true, - corvoyr = true, - corvrad = true, - legarad = true, - legajam = true, - legavrad = true, - legavjam = true, - legaradk = true, - legajamk = true, - legfrad = true, - - armmine1 = true, - armmine2 = true, - armmine3 = true, - armfmine3 = true, - cormine1 = true, - cormine2 = true, - cormine3 = true, - cormine4 = true, - corfmine3 = true, - legmine1 = true, - legmine2 = true, - legmine3 = true, - - corfav = true, - armfav = true, - armflea = true, - legscout = true, - raptor_land_swarmer_brood_t2_v1 = true, - raptor_land_kamikaze_basic_t2_v1 = true, - raptor_land_kamikaze_emp_t2_v1 = true, - raptor_land_kamikaze_basic_t4_v1 = true, - raptor_land_kamikaze_emp_t4_v1 = true, - scavmist = true, - scavmistxl = true, - scavmistxxl = true, - } - -- convert unitname -> unitDefID + -- customparams.juno_kill (or customparams.mine) marks units destroyed by the juno pulse; + -- customparams.juno_deny marks units also destroyed by the lingering denial ring local tokillUnits = {} - for name, params in pairs(tokillUnitsNames) do - if UnitDefNames[name] then - tokillUnits[UnitDefNames[name].id] = params - end - end - tokillUnitsNames = nil - - local todenyUnitsNames = { - corfav = true, - armfav = true, - armflea = true, - legscout = true, - raptor_land_swarmer_brood_t2_v1 = true, - raptor_land_kamikaze_basic_t2_v1 = true, - raptor_land_kamikaze_emp_t2_v1 = true, - raptor_land_kamikaze_basic_t4_v1 = true, - raptor_land_kamikaze_emp_t4_v1 = true, - scavmist = true, - scavmistxl = true, - scavmistxxl = true, - } - -- convert unitname -> unitDefID local todenyUnits = {} - for name, params in pairs(todenyUnitsNames) do - if UnitDefNames[name] then - todenyUnits[UnitDefNames[name].id] = params + for unitDefID, unitDef in pairs(UnitDefs) do + local cp = unitDef.customParams + if cp.juno_kill or cp.mine then + tokillUnits[unitDefID] = true end - end - todenyUnitsNames = nil - - for udid, ud in pairs(UnitDefs) do - for id, v in pairs(tokillUnits) do - if string.find("_scav", ud.name) and string.sub(UnitDefs[id].name, 1, -5) == ud.name then - --if string.find(ud.name, UnitDefs[id].name) then - tokillUnits[udid] = v - end - end - for id, v in pairs(todenyUnits) do - if string.find("_scav", ud.name) and string.sub(UnitDefs[id].name, 1, -5) == ud.name then - --if string.find(ud.name, UnitDefs[id].name) then - todenyUnits[udid] = v - end + if cp.juno_deny then + todenyUnits[unitDefID] = true end end @@ -230,6 +134,7 @@ if gadgetHandler:IsSyncedCode() then local counter = 1 --index each explosion of juno missile with this counter function gadget:Initialize() + Spring.SetGameRulesParam("juno_area_denial_radius", radius) -- read by gui_attack_aoe.lua if WeaponDefNames.armjuno_juno_pulse then Script.SetWatchExplosion(WeaponDefNames.armjuno_juno_pulse.id, true) end @@ -261,7 +166,7 @@ if gadgetHandler:IsSyncedCode() then function gadget:GameFrame(frame) --if frame == 10 then --seems that SendToUnsynced has to happen after - --SendToUnsynced("RecieveConstants", width, radius, effectlength, fadetime) + --SendToUnsynced("ReceiveConstants", width, radius, effectlength, fadetime) --end if DEBUG_JUNO_IMPACT and frame % debugIntervalFrames == 0 then diff --git a/luarules/gadgets/unit_juno_damage_mini.lua b/luarules/gadgets/unit_juno_damage_mini.lua index b030816e5a2..d07f3091afb 100644 --- a/luarules/gadgets/unit_juno_damage_mini.lua +++ b/luarules/gadgets/unit_juno_damage_mini.lua @@ -24,113 +24,17 @@ if gadgetHandler:IsSyncedCode() then ---------------------------------------------------------------- -- Config ---------------------------------------------------------------- - local tokillUnitsNames = { - armarad = true, - armaser = true, - armason = true, - armeyes = true, - armfrad = true, - armjam = true, - armjamt = true, - armmark = true, - armrad = true, - armseer = true, - armsjam = true, - armsonar = true, - armveil = true, - corarad = true, - corason = true, - coreter = true, - coreyes = true, - corfrad = true, - corjamt = true, - corrad = true, - legjam = true, - legrad = true, - corshroud = true, - corsjam = true, - corsonar = true, - corspec = true, - corvoyr = true, - corvrad = true, - legarad = true, - legajam = true, - legavrad = true, - legavjam = true, - legaradk = true, - legajamk = true, - legfrad = true, - - armmine1 = true, - armmine2 = true, - armmine3 = true, - armfmine3 = true, - cormine1 = true, - cormine2 = true, - cormine3 = true, - cormine4 = true, - corfmine3 = true, - legmine1 = true, - legmine2 = true, - legmine3 = true, - - corfav = true, - armfav = true, - armflea = true, - legscout = true, - raptor_land_swarmer_brood_t2_v1 = true, - raptor_land_kamikaze_basic_t2_v1 = true, - raptor_land_kamikaze_emp_t2_v1 = true, - raptor_land_kamikaze_basic_t4_v1 = true, - raptor_land_kamikaze_emp_t4_v1 = true, - scavmist = true, - scavmistxl = true, - scavmistxxl = true, - } - -- convert unitname -> unitDefID + -- customparams.juno_kill (or customparams.mine) marks units destroyed by the juno pulse; + -- customparams.juno_deny marks units also destroyed by the lingering denial ring local tokillUnits = {} - for name, params in pairs(tokillUnitsNames) do - if UnitDefNames[name] then - tokillUnits[UnitDefNames[name].id] = params - end - end - tokillUnitsNames = nil - - local todenyUnitsNames = { - corfav = true, - armfav = true, - armflea = true, - legscout = true, - raptor_land_swarmer_brood_t2_v1 = true, - raptor_land_kamikaze_basic_t2_v1 = true, - raptor_land_kamikaze_emp_t2_v1 = true, - raptor_land_kamikaze_basic_t4_v1 = true, - raptor_land_kamikaze_emp_t4_v1 = true, - scavmist = true, - scavmistxl = true, - scavmistxxl = true, - } - -- convert unitname -> unitDefID local todenyUnits = {} - for name, params in pairs(todenyUnitsNames) do - if UnitDefNames[name] then - todenyUnits[UnitDefNames[name].id] = params + for unitDefID, unitDef in pairs(UnitDefs) do + local cp = unitDef.customParams + if cp.juno_kill or cp.mine then + tokillUnits[unitDefID] = true end - end - todenyUnitsNames = nil - - for udid, ud in pairs(UnitDefs) do - for id, v in pairs(tokillUnits) do - if string.find("_scav", ud.name) and string.sub(UnitDefs[id].name, 1, -5) == ud.name then - --if string.find(ud.name, UnitDefs[id].name) then - tokillUnits[udid] = v - end - end - for id, v in pairs(todenyUnits) do - if string.find("_scav", ud.name) and string.sub(UnitDefs[id].name, 1, -5) == ud.name then - --if string.find(ud.name, UnitDefs[id].name) then - todenyUnits[udid] = v - end + if cp.juno_deny then + todenyUnits[unitDefID] = true end end @@ -192,6 +96,7 @@ if gadgetHandler:IsSyncedCode() then local counter = 1 --index each explosion of juno missile with this counter function gadget:Initialize() + Spring.SetGameRulesParam("juno_mini_area_denial_radius", radius) if WeaponDefNames.legcib_juno_pulse_mini then Script.SetWatchExplosion(WeaponDefNames.legcib_juno_pulse_mini.id, true) end @@ -215,7 +120,7 @@ if gadgetHandler:IsSyncedCode() then function gadget:GameFrame(frame) --if frame == 10 then --seems that SendToUnsynced has to happen after - --SendToUnsynced("RecieveConstants", width, radius, effectlength, fadetime) + --SendToUnsynced("ReceiveConstants", width, radius, effectlength, fadetime) --end local curtime = SpGetGameSeconds() diff --git a/luarules/gadgets/unit_juno_rework_damage.lua b/luarules/gadgets/unit_juno_rework_damage.lua index d48e16dc29e..2a46a789498 100644 --- a/luarules/gadgets/unit_juno_rework_damage.lua +++ b/luarules/gadgets/unit_juno_rework_damage.lua @@ -25,59 +25,20 @@ if gadgetHandler:IsSyncedCode() then -- Config ---------------------------------------------------------------- - local tokillUnitsNames = { - corfav = true, - armfav = true, - armflea = true, - legscout = true, - raptor_land_swarmer_brood_t2_v1 = true, - raptor_land_kamikaze_basic_t2_v1 = true, - raptor_land_kamikaze_emp_t2_v1 = true, - raptor_land_kamikaze_basic_t4_v1 = true, - raptor_land_kamikaze_emp_t4_v1 = true, - } - - --emp these - local toStunUnitsNames = { --this could maybe use customparams later, at least in part to detect mines - armarad = true, - armaser = true, - armason = true, - armfrad = true, - armjam = true, - armjamt = true, - armmark = true, - armrad = true, - armseer = true, - armsjam = true, - armsonar = true, - armveil = true, - corarad = true, - corason = true, - coreter = true, - corfrad = true, - corjamt = true, - corrad = true, - corshroud = true, - corsjam = true, - corsonar = true, - corspec = true, - corvoyr = true, - corvrad = true, - - coreyes = true, - armeyes = true, - armmine1 = true, - armmine2 = true, - armmine3 = true, - cormine1 = true, - cormine2 = true, - cormine3 = true, - armfmine3 = true, - corfmine3 = true, - legmine1 = true, - legmine2 = true, - legmine3 = true, - } + -- customparams.juno_deny marks units destroyed by the pulse and the lingering denial ring; + -- other customparams.juno_kill units (sensors) and customparams.mine mines get EMP'd instead + local tokillUnits = {} + local todenyUnits = {} + local toStunUnits = {} + for unitDefID, unitDef in pairs(UnitDefs) do + local cp = unitDef.customParams + if cp.juno_deny then + tokillUnits[unitDefID] = true + todenyUnits[unitDefID] = true + elseif cp.juno_kill or cp.mine then + toStunUnits[unitDefID] = true + end + end local stunDuration = Spring.GetModOptions().emprework and 32 or 30 --hornet todo, might leave this to be decided by EMP settings and just max it out? @@ -88,41 +49,6 @@ if gadgetHandler:IsSyncedCode() then leggob = true, } - local todenyUnitsNames = { - corfav = true, - armfav = true, - armflea = true, - raptor_land_swarmer_brood_t2_v1 = true, - raptor_land_kamikaze_basic_t2_v1 = true, - raptor_land_kamikaze_emp_t2_v1 = true, - raptor_land_kamikaze_basic_t4_v1 = true, - raptor_land_kamikaze_emp_t4_v1 = true, - } - - -- convert unitname -> unitDefID - local tokillUnits = {} - for name, params in pairs(tokillUnitsNames) do - if UnitDefNames[name] then - tokillUnits[UnitDefNames[name].id] = params - end - end - tokillUnitsNames = nil - -- convert unitname -> unitDefID - local todenyUnits = {} - for name, params in pairs(todenyUnitsNames) do - if UnitDefNames[name] then - todenyUnits[UnitDefNames[name].id] = params - end - end - todenyUnitsNames = nil - -- convert unitname -> unitDefID - local toStunUnits = {} - for name, params in pairs(toStunUnitsNames) do - if UnitDefNames[name] then - toStunUnits[UnitDefNames[name].id] = params - end - end - toStunUnitsNames = nil --[[ --WiP, works but has bug outlined below, out of time to chase in circles for now local toTarpitUnits = {} @@ -134,27 +60,6 @@ if gadgetHandler:IsSyncedCode() then toTarpitUnitsNames = nil --]] - for udid, ud in pairs(UnitDefs) do - for id, v in pairs(tokillUnits) do - if string.find("_scav", ud.name) and string.sub(UnitDefs[id].name, 1, -5) == ud.name then - --if string.find(ud.name, UnitDefs[id].name) then - tokillUnits[udid] = v - end - end - for id, v in pairs(todenyUnits) do - if string.find("_scav", ud.name) and string.sub(UnitDefs[id].name, 1, -5) == ud.name then - --if string.find(ud.name, UnitDefs[id].name) then - todenyUnits[udid] = v - end - end - for id, v in pairs(toStunUnits) do - if string.find("_scav", ud.name) and string.sub(UnitDefs[id].name, 1, -5) == ud.name then - --if string.find(ud.name, UnitDefs[id].name) then - toStunUnits[udid] = v - end - end - end - --config -- see also in unsynced local radius = 450 --outer radius of area denial ring local width = 30 --width of area denial ring @@ -236,6 +141,7 @@ if gadgetHandler:IsSyncedCode() then local counter = 1 --index each explosion of juno missile with this counter function gadget:Initialize() + Spring.SetGameRulesParam("juno_area_denial_radius", radius) -- read by gui_attack_aoe.lua if WeaponDefNames.armjuno_juno_pulse then Script.SetWatchExplosion(WeaponDefNames.armjuno_juno_pulse.id, true) end @@ -265,7 +171,7 @@ if gadgetHandler:IsSyncedCode() then function gadget:GameFrame(frame) --if frame == 10 then --seems that SendToUnsynced has to happen after - --SendToUnsynced("RecieveConstants", width, radius, effectlength, fadetime) + --SendToUnsynced("ReceiveConstants", width, radius, effectlength, fadetime) --end local curtime = SpGetGameSeconds() diff --git a/luarules/gadgets/unit_mex_upgrade_reclaimer.lua b/luarules/gadgets/unit_mex_upgrade_reclaimer.lua index 94beaece006..92da3d77cbe 100644 --- a/luarules/gadgets/unit_mex_upgrade_reclaimer.lua +++ b/luarules/gadgets/unit_mex_upgrade_reclaimer.lua @@ -89,7 +89,7 @@ function gadget:UnitDestroyed(unitID, unitDefID, unitTeam, attackerID, attackerD end function gadget:UnitFinished(unitID, unitDefID, unitTeam) - -- on completion open up yardmap to allow for another mex to built ontop + -- on completion open up yardmap to allow for another mex to built on top if isMex[unitDefID] then Spring.SetUnitCOBValue(unitID, COB.YARD_OPEN, 1) -- if there's a mex below this one reclaim it, and donate this one to the owner of the previous mex diff --git a/luarules/gadgets/unit_nanoradarpos.lua b/luarules/gadgets/unit_nanoradarpos.lua index 2cc4ff3e4d4..0a10cdb2b32 100644 --- a/luarules/gadgets/unit_nanoradarpos.lua +++ b/luarules/gadgets/unit_nanoradarpos.lua @@ -15,14 +15,14 @@ end if gadgetHandler:IsSyncedCode() then local isNano = {} for unitDefID, defs in pairs(UnitDefs) do - if string.find(defs.name, "nanotc") then + if defs.customParams.isnanoturret then isNano[unitDefID] = true end end function gadget:UnitCreated(uid, udid) if isNano[udid] then - Spring.SetUnitPosErrorParams(udid, 0, 0, 0, 0, 0, 0, math.huge) + Spring.SetUnitPosErrorParams(uid, 0, 0, 0, 0, 0, 0, math.huge) end end end diff --git a/luarules/gadgets/unit_no_land_damage.lua b/luarules/gadgets/unit_no_land_damage.lua index 08c97223841..814f3c82282 100644 --- a/luarules/gadgets/unit_no_land_damage.lua +++ b/luarules/gadgets/unit_no_land_damage.lua @@ -21,13 +21,12 @@ end local GetUnitBasePosition = Spring.GetUnitBasePosition -local weapons = { "armair_torpedo", "armseap_weapon" } -local NO_LAND_DAMAGE = {} -for wdid, wd in pairs(WeaponDefNames) do - for _, wname in pairs(weapons) do - if string.find(wd.name, wname) then - NO_LAND_DAMAGE[wdid] = true - end +-- weapondef customparams.land_damage_mult scales damage against targets above water +local LAND_DAMAGE_MULT = {} +for weaponDefID, wd in pairs(WeaponDefs) do + local mult = wd.customParams and tonumber(wd.customParams.land_damage_mult) + if mult then + LAND_DAMAGE_MULT[weaponDefID] = mult end end @@ -43,15 +42,11 @@ function gadget:UnitPreDamaged( attackerDefID, attackerTeam ) - if NO_LAND_DAMAGE[weaponID] then - if select(2, GetUnitBasePosition(unitID)) > 0 then - return (damage * 0.2), 1 - else - return damage, 1 - end - else - return damage, 1 + local mult = LAND_DAMAGE_MULT[weaponID] + if mult and select(2, GetUnitBasePosition(unitID)) > 0 then + return (damage * mult), 1 end + return damage, 1 end -------------------------------------------------------------------------------- diff --git a/luarules/gadgets/unit_onlytargetcategory.lua b/luarules/gadgets/unit_onlytargetcategory.lua index 64cd6c503bb..8a8fba34ae0 100644 --- a/luarules/gadgets/unit_onlytargetcategory.lua +++ b/luarules/gadgets/unit_onlytargetcategory.lua @@ -20,8 +20,6 @@ for udid, unitDef in pairs(UnitDefs) do unitCategories[udid] = unitDef.modCategories end - local skip = false - local add = false for wid, weapon in ipairs(unitDef.weapons) do if weapon.onlyTargets then local disregard = false diff --git a/luarules/gadgets/unit_paralyze_damage_limit.lua b/luarules/gadgets/unit_paralyze_damage_limit.lua index 86d8fc16fc6..3642002426e 100644 --- a/luarules/gadgets/unit_paralyze_damage_limit.lua +++ b/luarules/gadgets/unit_paralyze_damage_limit.lua @@ -21,15 +21,8 @@ local modOptions = Spring.GetModOptions() local maxTime = modOptions.emprework == true and 10 or 20 --- bug fixed -local excluded = { - -- mobile units that are excluded from the maxTime limit - [UnitDefNames.armscab.id] = true, - [UnitDefNames.cormabm.id] = true, - [UnitDefNames.corcarry.id] = true, - [UnitDefNames.armcarry.id] = true, - [UnitDefNames.armantiship.id] = true, - [UnitDefNames.corantiship.id] = true, -} +-- mobile units carrying customparams.paralyzetime_uncapped are excluded from the maxTime limit +local excluded = {} local isBuilding = {} local unitOhms = {} -- rework related @@ -105,13 +98,11 @@ local function EvaluateCustomStunCondition(unitDef, unitConditionKey, unitCondit end for udid, ud in pairs(UnitDefs) do - for id, v in pairs(excluded) do - if string.find(ud.name, UnitDefs[id].name) then - excluded[udid] = v - end - if ud.isBuilding then - isBuilding[udid] = true - end + if ud.customParams.paralyzetime_uncapped then + excluded[udid] = true + end + if ud.isBuilding then + isBuilding[udid] = true end -- Precompute our fixed_stun_duration and paralyzetime_exceptions to save on computation during the game diff --git a/luarules/gadgets/unit_prevent_lab_hax2.lua b/luarules/gadgets/unit_prevent_lab_hax2.lua index 5ce004f267c..2891e179fd0 100644 --- a/luarules/gadgets/unit_prevent_lab_hax2.lua +++ b/luarules/gadgets/unit_prevent_lab_hax2.lua @@ -13,7 +13,6 @@ function gadget:GetInfo() end if gadgetHandler:IsSyncedCode() then - local builder = {} local destroyQueue = {} local numtodestroy = 0 diff --git a/luarules/gadgets/unit_prevent_nanoframe_blocking_hax.lua b/luarules/gadgets/unit_prevent_nanoframe_blocking_hax.lua index 88ce790a2a0..c0cf7def16e 100644 --- a/luarules/gadgets/unit_prevent_nanoframe_blocking_hax.lua +++ b/luarules/gadgets/unit_prevent_nanoframe_blocking_hax.lua @@ -16,46 +16,107 @@ if not gadgetHandler:IsSyncedCode() then return end +local spGetUnitBlocking = Spring.GetUnitBlocking +local spSetUnitBlocking = Spring.SetUnitBlocking +local spGetUnitNeutral = Spring.GetUnitNeutral +local spSetUnitNeutral = Spring.SetUnitNeutral +local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt +local spValidUnitID = Spring.ValidUnitID + local blockingBuildProgress = 0.05 +-- Build progress is polled from GameFrame instead of reacting to every build +-- step: each tracked nanoframe is checked once every CHECK_INTERVAL frames +-- (spread over frames by unitID), and GameFrame is switched off entirely while +-- nothing is tracked. A nanoframe may therefore keep its non-blocking state for +-- up to CHECK_INTERVAL frames after crossing the threshold. +local CHECK_INTERVAL = 15 + local newNanoFrameNeutralState = {} -- hash table, unitID -> original neutral state local CMD_ATTACK = CMD.ATTACK -local function AddNanoFrame(unitID) - local a, b, c, d, e, f, g = Spring.GetUnitBlocking(unitID) - Spring.SetUnitBlocking(unitID, a, b, false, d, e, f, g) -- non-blocking for projectiles +local slots = {} -- frame slot -> array of tracked unitIDs polled on that slot +for i = 0, CHECK_INTERVAL - 1 do + slots[i] = {} +end +local slotPos = {} -- unitID -> index inside its slot +local trackedCount = 0 - local neutral = Spring.GetUnitNeutral(unitID) +local function track(unitID, neutral) newNanoFrameNeutralState[unitID] = neutral - Spring.SetUnitNeutral(unitID, true) + local slot = slots[unitID % CHECK_INTERVAL] + local n = #slot + 1 + slot[n] = unitID + slotPos[unitID] = n + trackedCount = trackedCount + 1 + if trackedCount == 1 then + gadgetHandler:UpdateCallIn("GameFrame") + end +end + +local function untrack(unitID) + if newNanoFrameNeutralState[unitID] == nil then + return + end + newNanoFrameNeutralState[unitID] = nil + local slot = slots[unitID % CHECK_INTERVAL] + local pos = slotPos[unitID] + local n = #slot + local last = slot[n] + slot[pos] = last + slotPos[last] = pos + slot[n] = nil + slotPos[unitID] = nil + trackedCount = trackedCount - 1 + if trackedCount == 0 then + gadgetHandler:RemoveCallIn("GameFrame") + end +end + +local function AddNanoFrame(unitID) + local a, b, c, d, e, f, g = spGetUnitBlocking(unitID) + spSetUnitBlocking(unitID, a, b, false, d, e, f, g) -- non-blocking for projectiles + + local neutral = spGetUnitNeutral(unitID) + spSetUnitNeutral(unitID, true) + track(unitID, neutral) end local function removeNanoFrame(unitID) - if Spring.ValidUnitID(unitID) then - local a, b, c, d, e, f, g = Spring.GetUnitBlocking(unitID) - Spring.SetUnitBlocking(unitID, a, b, true, d, e, f, g) -- blocking for projectiles + if spValidUnitID(unitID) then + local a, b, c, d, e, f, g = spGetUnitBlocking(unitID) + spSetUnitBlocking(unitID, a, b, true, d, e, f, g) -- blocking for projectiles local neutral = newNanoFrameNeutralState[unitID] -- If a unit has already been set to neutral=false, don't overwrite that here - if Spring.GetUnitNeutral(unitID) then - Spring.SetUnitNeutral(unitID, neutral) + if spGetUnitNeutral(unitID) then + spSetUnitNeutral(unitID, neutral) end end - newNanoFrameNeutralState[unitID] = nil + untrack(unitID) end function gadget:UnitCreated(unitID, unitDefID, unitTeam, builderID) if builderID then - local _, _, projectileBlocking = Spring.GetUnitBlocking(unitID) + local _, _, projectileBlocking = spGetUnitBlocking(unitID) if projectileBlocking then AddNanoFrame(unitID) end end end -function gadget:UnitBuildStepPost(unitID) +function gadget:UnitFinished(unitID) if newNanoFrameNeutralState[unitID] ~= nil then - local _, buildProgress = Spring.GetUnitIsBeingBuilt(unitID) + removeNanoFrame(unitID) + end +end + +function gadget:GameFrame(n) + local slot = slots[n % CHECK_INTERVAL] + -- iterate backwards so swap-removal never skips an entry + for i = #slot, 1, -1 do + local unitID = slot[i] + local _, buildProgress = spGetUnitIsBeingBuilt(unitID) if buildProgress and buildProgress >= blockingBuildProgress then removeNanoFrame(unitID) end @@ -73,16 +134,26 @@ function gadget:AllowCommand(unitID, unitDefID, teamID, cmdID, cmdParams, cmdOpt end function gadget:UnitDestroyed(unitID, unitDefID, unitTeam, builderID) - newNanoFrameNeutralState[unitID] = nil + untrack(unitID) end function gadget:Initialize() gadgetHandler:RegisterAllowCommand(CMD_ATTACK) - -- handle luarules reload + -- handle luarules reload: pick up nanoframes still below the threshold local units = Spring.GetAllUnits() for _, unitID in ipairs(units) do - local unitDefID = Spring.GetUnitDefID(unitID) - local unitTeam = Spring.GetUnitTeam(unitID) - gadget:UnitCreated(unitID, unitDefID, unitTeam) + local beingBuilt, buildProgress = spGetUnitIsBeingBuilt(unitID) + if beingBuilt and buildProgress < blockingBuildProgress then + local _, _, projectileBlocking = spGetUnitBlocking(unitID) + if projectileBlocking then + AddNanoFrame(unitID) + else + -- already marked before the reload; its original neutral state is lost + track(unitID, false) + end + end + end + if trackedCount == 0 then + gadgetHandler:RemoveCallIn("GameFrame") end end diff --git a/luarules/gadgets/unit_prevent_unload_hax.lua b/luarules/gadgets/unit_prevent_unload_hax.lua index 191dc463d27..2a78652d5e5 100644 --- a/luarules/gadgets/unit_prevent_unload_hax.lua +++ b/luarules/gadgets/unit_prevent_unload_hax.lua @@ -18,10 +18,11 @@ end local frameMargin = 10 -local isCommando = {} +-- paradropped units (customparams.paratrooper) keep the transport's momentum on unload +local isParatrooper = {} for udid, ud in pairs(UnitDefs) do - if string.find(ud.name, "cormando") then - isCommando[udid] = true + if ud.customParams.paratrooper then + isParatrooper[udid] = true end end @@ -39,8 +40,7 @@ function gadget:UnitUnloaded(unitID, unitDefID, teamID, transportID) if unitID == nil or unitDefID == nil or transportID == nil then return end - --FIXME: is this exception for commando this really necessary? - if isCommando[unitDefID] then + if isParatrooper[unitDefID] then local x, y, z = SpGetUnitVelocity(transportID) if x > 10 then x = 10 diff --git a/luarules/gadgets/unit_reactive_armor.lua b/luarules/gadgets/unit_reactive_armor.lua index 8ae364b0b00..022c311de0c 100644 --- a/luarules/gadgets/unit_reactive_armor.lua +++ b/luarules/gadgets/unit_reactive_armor.lua @@ -21,8 +21,8 @@ end local armorBreakMethod = "ReactiveArmorBreak" local armorRestoreMethod = "ReactiveArmorRestore" -local unitCombatDuration = math.round(5 * Game.gameSpeed) -- Also sets the minimum `reactive_armor_restore`. -local unitUpdateInterval = math.round((1 / 6) * Game.gameSpeed) +local unitCombatDuration = math.round(5 * Game.gameSpeed, 0) -- Also sets the minimum `reactive_armor_restore`. +local unitUpdateInterval = math.round((1 / 6) * Game.gameSpeed, 0) -- Localization @@ -43,8 +43,8 @@ local armoredUnitDefs = {} for unitDefID, unitDef in pairs(UnitDefs) do if unitDef.customParams.reactive_armor_health and unitDef.customParams.reactive_armor_restore then local params = { - health = tonumber(unitDef.customParams.reactive_armor_health), - frames = tonumber(unitDef.customParams.reactive_armor_restore) * gameSpeed, + health = (tonumber(unitDef.customParams.reactive_armor_health) or 0), + frames = (tonumber(unitDef.customParams.reactive_armor_restore) or 0) * gameSpeed, first = true, } @@ -65,7 +65,7 @@ end -- since that is the first time the game gives us info on them. local function checkReactiveArmor(unitID, unitDefID, params) local hasMethod - local lusEnv = Spring.UnitScript.GetScriptEnv(unitID) + local lusEnv = Spring.UnitScript.GetScriptEnv(unitID) ---@as table? if lusEnv then hasMethod = function(name) @@ -149,7 +149,7 @@ local function checkReactiveArmor(unitID, unitDefID, params) -- Fix for the different argument types used between COB and LUS. params.call = (not lusEnv and callFromCob) or function(unitID, funcName, ...) - callFromLus(unitID, lusEnv[funcName], ...) + callFromLus(unitID, lusEnv[funcName], ...) ---@diagnostic disable-line: need-check-nil end return true @@ -164,7 +164,7 @@ local regenerateFrame = table_new(0, 2 ^ 6) -- Next frame that the unit will beg local gameFrame = 0 local combatEndFrame = gameFrame + unitCombatDuration ----@return table<"countdown"|integer, integer> restoreFrames +---@return { countdown : integer, [integer] : true }|{ countdown : integer, [0] : true } restoreFrames local function getArmorRestoreFrames(defData, duration) local armorPieceCount = defData.pieces local restoreDuration = duration or defData.frames @@ -261,7 +261,7 @@ local function doArmorDamage(unitID, defData, damage) end local function restoreUnitArmor(unitID, piece) - local defData = armoredUnitDefs[spGetUnitDefID(unitID)] + local defData = armoredUnitDefs[spGetUnitDefID(unitID)] ---@type table if piece ~= true then defData.call(unitID, defData[armorRestoreMethod][piece]) @@ -317,7 +317,7 @@ end local function showDebugInfo(unitID) local info = getUnitDebugInfo(unitID) - if info.unitCountdown then + if tonumber(info.unitCountdown) then local display = ("hp:%s res:%s"):format(tostring(info.armorHealth), tostring(info.unitCountdown)) Spring.MarkerAddPoint(info.x, info.y, info.z, display) Spring.Echo("Reactive Armor", info) @@ -352,7 +352,13 @@ function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) end function gadget:UnitDamaged(unitID, unitDefID, unitTeam, damage, paralyzer) - if not paralyzer and damage > 0 and armoredUnitDefs[unitDefID] then + if not armoredUnitDefs[unitDefID] then + return + end + ---@cast paralyzer boolean -- todo: odd + if paralyzer then + regenerateFrame[unitID] = combatEndFrame + elseif damage > 0 then doArmorDamage(unitID, armoredUnitDefs[unitDefID], damage) end end @@ -365,7 +371,7 @@ end ---@return boolean changed `false` when the unit has no reactive armor, its armor is ---already broken, or a repair was requested while already at full armor. GG.AddReactiveArmorDamage = function(unitID, damage) - local unitDefData = armoredUnitDefs[spGetUnitDefID(unitID)] + local unitDefData = armoredUnitDefs[spGetUnitDefID(unitID)] ---@as table if unitDefData and damage ~= 0 then return doArmorDamage(unitID, unitDefData, damage) else @@ -401,8 +407,8 @@ function gadget:Initialize() return end - local armorHealth = spGetUnitRulesParam(unitID, "reactiveArmorHealth") - local armorFrames = spGetUnitRulesParam(unitID, "reactiveArmorFrames") + local armorHealth = spGetUnitRulesParam(unitID, "reactiveArmorHealth") ---@as number|false + local armorFrames = spGetUnitRulesParam(unitID, "reactiveArmorFrames") ---@as number|false local combatUntil = spGetUnitRulesParam(unitID, "unitIsInCombatUntil") gadget:UnitFinished(unitID, unitDefID, unitTeam) spSetUnitRulesParam(unitID, "reactiveArmorFrames", nil) diff --git a/luarules/gadgets/unit_scenario_loadout.lua b/luarules/gadgets/unit_scenario_loadout.lua index 7b1dbcdeb11..8953c91874e 100644 --- a/luarules/gadgets/unit_scenario_loadout.lua +++ b/luarules/gadgets/unit_scenario_loadout.lua @@ -40,9 +40,6 @@ local function rot_to_facing(rotation) return 2 end -local startMetal = 1000 -local startEnergy = 1000 -local teamList = {} local additionalStorage = {} local gaiaTeamID = Spring.GetGaiaTeamID() @@ -93,7 +90,7 @@ function gadget:GamePreload() + (UnitDefNames[unit.name].energyStorage or 0) end end - if string.find(unit.name, "nanotc") then + if UnitDefNames[unit.name].customParams.isnanoturret then nanoturretunitIDs[unitID] = true end if unit.neutral == true or unit.neutral == "true" then @@ -153,17 +150,4 @@ function gadget:GameFrame(n) end gadgetHandler:RemoveGadget() end - --[[ periodic checking isn't very good - if n %17 == 7 then - local teamList = Spring.GetTeamList() - for i = 1, #teamList do - local teamID = teamList[i] - local m, mstore = Spring.GetTeamResources(teamID, "metal") - local e, estore = Spring.GetTeamResources(teamID, "energy") - if mstore < 500 then Spring.SetTeamResource(teamID, 'ms', 500) end - if estore < 500 then Spring.SetTeamResource(teamID, 'es', 500) end - end - end - ]] - -- end diff --git a/luarules/gadgets/unit_script.lua b/luarules/gadgets/unit_script.lua index 0c055ac2259..5ba8f49c377 100644 --- a/luarules/gadgets/unit_script.lua +++ b/luarules/gadgets/unit_script.lua @@ -701,7 +701,7 @@ local function ScriptInclude(filename) end end --- memoize it so we don't need to decompress and parse the .lua file everytime.. +-- memoize it so we don't need to decompress and parse the .lua file every time.. local function MemoizedInclude(filename, env) local chunk = include_cache[filename] or ScriptInclude(filename) if chunk then diff --git a/luarules/gadgets/unit_shield_behaviour.lua b/luarules/gadgets/unit_shield_behaviour.lua index 794904cc65d..84b35569024 100644 --- a/luarules/gadgets/unit_shield_behaviour.lua +++ b/luarules/gadgets/unit_shield_behaviour.lua @@ -14,7 +14,7 @@ if not gadgetHandler:IsSyncedCode() then return false end ----@alias ShieldPreDamagedCallback fun(projectileID:integer, attackerID:integer, shieldWeaponIndex:integer, shieldUnitID:integer, bounceProjectile:boolean, beamWeaponIndex:integer?, beamUnitID:integer?, startX:number?, startY:number?, startZ:number?, hitX:number, hitY:number, hitZ:number): boolean? (default := `false`) +---@alias ShieldPreDamagedCallback fun(projectileID:ProjectileID, attackerID:UnitID, shieldWeaponIndex:integer, shieldUnitID:UnitID, bounceProjectile:boolean, beamWeaponIndex:integer?, beamUnitID:UnitID?, startX:number?, startY:number?, startZ:number?, hitX:number, hitY:number, hitZ:number): boolean? (default := `false`) local mathMax = math.max local mathMin = math.min @@ -992,7 +992,7 @@ end ---@param x number ---@param y number ---@param z number ----@param shieldUnitID integer +---@param shieldUnitID UnitID ---@return boolean? local function isInShield(x, y, z, shieldUnitID) local sx, sy, sz, sr = getUnitShieldPosition(shieldUnitID) diff --git a/luarules/gadgets/unit_stack_hack_fix.lua b/luarules/gadgets/unit_stack_hack_fix.lua index 80faf3c11c1..0e1f9d3873c 100644 --- a/luarules/gadgets/unit_stack_hack_fix.lua +++ b/luarules/gadgets/unit_stack_hack_fix.lua @@ -3,7 +3,7 @@ local gadget = gadget ---@type Gadget function gadget:GetInfo() return { name = "Anti Stacking Hax", - desc = "123", + desc = "Nudges nano turrets apart when they end up stacked on top of another structure", author = "Damgam", date = "2021", license = "GNU GPL, v2 or later", @@ -16,93 +16,201 @@ if not gadgetHandler:IsSyncedCode() then return false end +local spGetUnitDefID = Spring.GetUnitDefID +local spGetUnitAllyTeam = Spring.GetUnitAllyTeam +local spGetUnitTransporter = Spring.GetUnitTransporter +local spGetUnitPosition = Spring.GetUnitPosition +local spGetUnitsInCylinder = Spring.GetUnitsInCylinder +local spGetGroundHeight = Spring.GetGroundHeight +local spSetUnitPosition = Spring.SetUnitPosition +local mathRandom = math.random + local mapsizeX = Game.mapSizeX local mapsizeZ = Game.mapSizeZ -local isAffectedUnit = {} +-- Fully event driven: turrets are static and only end up stacked when a +-- structure is created or unloaded on top of them, so those events put the +-- turrets around the new unit on a "hot" list. Hot turrets are nudged every +-- frame until no immobile ally is left inside their search radius. GameFrame +-- is switched off entirely while the hot list is empty, so an idle base costs +-- nothing per frame. +local WAKE_MARGIN = 32 + +local searchRadius = {} -- unitDefID -> search radius (nano turrets only) +local minDepthLimit = {} -- unitDefID -> -minWaterDepth (target ground height must be below this) +local maxDepthLimit = {} -- unitDefID -> -maxWaterDepth (target ground height must be above this) local canMove = {} +local maxSearchRadius = 0 for udid, ud in pairs(UnitDefs) do - if string.find(ud.id, "nanotc") then - isAffectedUnit[udid] = { - math.floor(((ud.xsize + ud.zsize) * 0.5) * 6), - ud.minWaterDepth, - ud.maxWaterDepth, - } + if ud.customParams.isnanoturret then + local radius = math.floor(((ud.xsize + ud.zsize) * 0.5) * 6) + searchRadius[udid] = radius + minDepthLimit[udid] = -ud.minWaterDepth + maxDepthLimit[udid] = -ud.maxWaterDepth + if radius > maxSearchRadius then + maxSearchRadius = radius + end end if ud.canMove then canMove[udid] = true end end +local wakeRadius = maxSearchRadius + WAKE_MARGIN -local affectedUnits = {} +local turretDefID = {} -- unitID -> unitDefID for every live nano turret -function gadget:UnitCreated(unitID, unitDefID) - if isAffectedUnit[unitDefID] then - table.insert(affectedUnits, { unitID, unitDefID }) +local hot = {} -- array of unitIDs checked every frame +local hotCount = 0 +local hotPos = {} -- unitID -> index inside hot, nil when not hot + +local function addHot(unitID) + if hotPos[unitID] then + return + end + hotCount = hotCount + 1 + hot[hotCount] = unitID + hotPos[unitID] = hotCount + if hotCount == 1 then + gadgetHandler:UpdateCallIn("GameFrame") end end -function gadget:UnitDestroyed(unitID, unitDefID, unitTeam, attackerID, attackerDefID, attackerTeam, weaponDefID) - if isAffectedUnit[unitDefID] then - for i = 1, #affectedUnits do - if affectedUnits[i][1] and affectedUnits[i][1] == unitID then - table.remove(affectedUnits, i) +local function removeHot(unitID) + local pos = hotPos[unitID] + if not pos then + return + end + local last = hot[hotCount] + hot[pos] = last + hotPos[last] = pos + hot[hotCount] = nil + hotCount = hotCount - 1 + hotPos[unitID] = nil +end + +-- Nudges the turret away from the nearest immobile ally inside its search +-- radius. Returns true when such an ally exists, i.e. recheck next frame. +local function checkTurret(unitID, unitDefID) + local x, _, z = spGetUnitPosition(unitID) + local radius = searchRadius[unitDefID] + local allyTeam = spGetUnitAllyTeam(unitID) + local units = spGetUnitsInCylinder(x, z, radius) + local ax, az, nearestSq = nil, nil, radius * radius + 1 + for i = 1, #units do + local other = units[i] + if other ~= unitID and not canMove[spGetUnitDefID(other)] and spGetUnitAllyTeam(other) == allyTeam then + local ox, _, oz = spGetUnitPosition(other) + local ddx, ddz = ox - x, oz - z + local distSq = ddx * ddx + ddz * ddz + if distSq < nearestSq then + ax, az, nearestSq = ox, oz, distSq end end end + if not ax then + return false + end + if spGetUnitTransporter(unitID) then + return true + end + + local dx, dz = 0, 0 + local r = mathRandom(1, 3) + if r == 1 then + if x == ax or z == az then + local testRange = radius * 2 + dx = mathRandom(-testRange, testRange) + dz = mathRandom(-testRange, testRange) + end + elseif r == 2 then + if x > ax then + dx = mathRandom(1, 10) + elseif x < ax then + dx = -mathRandom(1, 10) + end + else + if z > az then + dz = mathRandom(1, 10) + elseif z < az then + dz = -mathRandom(1, 10) + end + end + if dx == 0 and dz == 0 then + return true + end + + local tx, tz = x + dx, z + dz + if tx < 0 or tx > mapsizeX or tz < 0 or tz > mapsizeZ then + return true + end + local ty = spGetGroundHeight(tx, tz) + if ty < minDepthLimit[unitDefID] and ty > maxDepthLimit[unitDefID] then + spSetUnitPosition(unitID, tx, tz) + end + return true end -function gadget:GameFrame(n) - for i = 1, #affectedUnits do - local unitID = affectedUnits[i][1] - local unitDefID = affectedUnits[i][2] - local nearestAlly = Spring.GetUnitNearestAlly(unitID, isAffectedUnit[unitDefID][1]) - if nearestAlly then - if not canMove[Spring.GetUnitDefID(nearestAlly)] then - if not Spring.GetUnitTransporter(unitID) then - local x, _, z = Spring.GetUnitPosition(unitID) - local ax, _, az = Spring.GetUnitPosition(nearestAlly) - local r = math.random(1, 3) - local movementTargetX = 0 - local movementTargetZ = 0 - - if r == 1 then - if x == ax or z == az then - local testRange = isAffectedUnit[unitDefID][1] * 2 - movementTargetX = math.random(-testRange, testRange) - movementTargetZ = math.random(-testRange, testRange) - end - elseif r == 2 then - if x > ax then - movementTargetX = math.random(1, 10) - end - if x < ax then - movementTargetX = -math.random(1, 10) - end - elseif r == 3 then - if z > az then - movementTargetZ = math.random(1, 10) - end - if z < az then - movementTargetZ = -math.random(1, 10) - end - end - local movementTargetY = Spring.GetGroundHeight(x + movementTargetX, z + movementTargetZ) - local aboveMinWaterDepth = -isAffectedUnit[unitDefID][2] > movementTargetY - local belowMaxWaterDepth = -isAffectedUnit[unitDefID][3] < movementTargetY - - local onMap = true - if x + movementTargetX > mapsizeX or x + movementTargetX < 0 then - onMap = false - elseif z + movementTargetZ > mapsizeZ or z + movementTargetZ < 0 then - onMap = false - end - - if aboveMinWaterDepth and belowMaxWaterDepth and onMap then - Spring.SetUnitPosition(unitID, x + movementTargetX, z + movementTargetZ) - end - end - end +-- An immobile unit that just appeared may be sitting on top of a turret. +local function wakeTurretsNear(unitID, unitDefID) + if canMove[unitDefID] or next(turretDefID) == nil then + return + end + local x, _, z = spGetUnitPosition(unitID) + if not x then + return + end + local units = spGetUnitsInCylinder(x, z, wakeRadius) + for i = 1, #units do + local uid = units[i] + if turretDefID[uid] then + addHot(uid) + end + end +end + +function gadget:Initialize() + local units = Spring.GetAllUnits() + for i = 1, #units do + local unitID = units[i] + local unitDefID = spGetUnitDefID(unitID) + if searchRadius[unitDefID] then + turretDefID[unitID] = unitDefID + addHot(unitID) end end + if hotCount == 0 then + gadgetHandler:RemoveCallIn("GameFrame") + end +end + +function gadget:UnitCreated(unitID, unitDefID) + if searchRadius[unitDefID] then + turretDefID[unitID] = unitDefID + addHot(unitID) + end + wakeTurretsNear(unitID, unitDefID) +end + +function gadget:UnitUnloaded(unitID, unitDefID) + wakeTurretsNear(unitID, unitDefID) +end + +function gadget:UnitDestroyed(unitID, unitDefID) + if turretDefID[unitID] then + removeHot(unitID) + turretDefID[unitID] = nil + end +end + +function gadget:GameFrame() + -- iterate backwards so swap-removal never skips an entry + for i = hotCount, 1, -1 do + local unitID = hot[i] + if not checkTurret(unitID, turretDefID[unitID]) then + removeHot(unitID) + end + end + if hotCount == 0 then + gadgetHandler:RemoveCallIn("GameFrame") + end end diff --git a/luarules/gadgets/unit_stealthy_passengers.lua b/luarules/gadgets/unit_stealthy_passengers.lua index be039c670ed..e04deb5d4bd 100644 --- a/luarules/gadgets/unit_stealthy_passengers.lua +++ b/luarules/gadgets/unit_stealthy_passengers.lua @@ -18,17 +18,12 @@ end local spGetUnitDefID = Spring.GetUnitDefID local spSetUnitStealth = Spring.SetUnitStealth -local stringFind = string.find local stealthyUnits = {} -local stealthyTransports = { - [UnitDefNames.armdfly.id] = true, -} +local stealthyTransports = {} for udid, ud in pairs(UnitDefs) do - for id, v in pairs(stealthyTransports) do - if stringFind(ud.name, UnitDefs[id].name, 1, true) then - stealthyTransports[udid] = v - end + if ud.customParams.stealths_passengers then + stealthyTransports[udid] = true end if ud.stealth then stealthyUnits[udid] = true diff --git a/luarules/gadgets/unit_stomp.lua b/luarules/gadgets/unit_stomp.lua index bd107a42643..4b69620b074 100644 --- a/luarules/gadgets/unit_stomp.lua +++ b/luarules/gadgets/unit_stomp.lua @@ -16,22 +16,10 @@ if not gadgetHandler:IsSyncedCode() then return end -local stompable = { - armfav = true, - corfav = true, - armflea = true, - corak = true, - armpw = true, - leggob = true, -} -local stompableCopy = table.copy(stompable) -for name, v in pairs(stompableCopy) do - stompable[name .. "_scav"] = true -end local stompableDefs = {} for udid, ud in pairs(UnitDefs) do - if stompable[ud.name] then - stompableDefs[udid] = ud + if ud.customParams.stompable then + stompableDefs[udid] = true end end diff --git a/luarules/gadgets/unit_target_on_the_move.lua b/luarules/gadgets/unit_target_on_the_move.lua index 7eb7daf5cce..86632007be1 100644 --- a/luarules/gadgets/unit_target_on_the_move.lua +++ b/luarules/gadgets/unit_target_on_the_move.lua @@ -60,12 +60,13 @@ if gadgetHandler:IsSyncedCode() then local CMD_FIGHT = CMD.FIGHT local CMD_GUARD = CMD.GUARD local CMD_WAIT = CMD.WAIT + local CMD_MANUALFIRE = CMD.MANUALFIRE local OPT_INTERNAL = CMD.OPT_INTERNAL local FIRESTATE_RETURNFIRE = CMD.FIRESTATE_RETURNFIRE local isAttackCommand = { [CMD_ATTACK] = true, - [CMD.MANUALFIRE] = true, + [CMD_MANUALFIRE] = true, [CMD.AREA_ATTACK] = true, [GameCMD.AREA_ATTACK_GROUND] = true, } @@ -76,8 +77,6 @@ if gadgetHandler:IsSyncedCode() then local WATERWEAPON = 0 do - local allowNonAttackerUnit = { legpede = true } -- Fastpass for units that don't have an attack command for other reasons. - local function hasTargeting(weapon, canManualFire) local weaponDef = WeaponDefs[weapon.weaponDef] return weapon.slavedTo == 0 @@ -87,7 +86,8 @@ if gadgetHandler:IsSyncedCode() then end local function canSetTarget(unitDef) - if (unitDef.canAttack or allowNonAttackerUnit[unitDef.name]) and unitDef.maxWeaponRange > 0 then + -- customparams.allow_set_target: fastpass for units that don't have an attack command for other reasons + if (unitDef.canAttack or unitDef.customParams.allow_set_target) and unitDef.maxWeaponRange > 0 then local canManualFire = unitDef.canManualFire for _, weapon in pairs(unitDef.weapons) do if hasTargeting(weapon, canManualFire) then @@ -242,11 +242,6 @@ if gadgetHandler:IsSyncedCode() then return type(target) ~= "number" or not isAlliedUnit(teamID, target) end - local function inAttackCommand(unitID) - local inCommand = spGetUnitCurrentCommand(unitID) - return inCommand and isAttackCommand[inCommand] - end - local function inReturnFire(unitID) return spGetUnitStates(unitID, false) == FIRESTATE_RETURNFIRE end @@ -260,16 +255,22 @@ if gadgetHandler:IsSyncedCode() then return bit_and(cmdOptions, OPT_INTERNAL) ~= 0 end - local function hasUserTarget(unitID, unitData) - for weaponNum, check in pairs(unitData.weapons) do - if check then - local _, isUserTarget = spGetUnitWeaponTarget(unitID, weaponNum) - if isUserTarget then - return true - end + + local function restoreCommandTarget(unitID) + local inCommand, options, _, param1, param2, param3 = spGetUnitCurrentCommand(unitID) + if not inCommand or not isAttackCommand[inCommand] then + return false + end + if inCommand == CMD_ATTACK or inCommand == CMD_MANUALFIRE then + local manualFire = inCommand == CMD_MANUALFIRE + local userTarget = not hasAutoTarget(options) + if param2 then + spSetUnitTarget(unitID, param1, param2, param3, manualFire, userTarget) + else + spSetUnitTarget(unitID, param1, manualFire, userTarget) end end - return false + return true end local function hasTargetPrecedence(unitID, unitData) @@ -280,6 +281,8 @@ if gadgetHandler:IsSyncedCode() then return true elseif param2 or inCommand ~= CMD_ATTACK then return false + elseif not param1 then + return true end local nextCommand, _, _, nextParam1 = spGetUnitCurrentCommand(unitID, 2) @@ -294,7 +297,7 @@ if gadgetHandler:IsSyncedCode() then return false end - return hasAutoTarget(options) or not hasUserTarget(unitID, unitData) + return hasAutoTarget(options) or not testTarget(unitID, unitData.teamID, unitData.weapons, param1) end local function setTargetActive(unitID, unitData, targetIndex) @@ -319,7 +322,7 @@ if gadgetHandler:IsSyncedCode() then unitData.activeTarget = false unitData.currentIndex = 1 spSetUnitRulesParam(unitID, "unitTargetID", nil) - if not inAttackCommand(unitID) then + if not restoreCommandTarget(unitID) then spSetUnitTarget(unitID, nil) end SendToUnsynced("targetIndex", unitID, 1, false) @@ -361,7 +364,7 @@ if gadgetHandler:IsSyncedCode() then end local function removeUnit(unitID, keeptrack) - if activeTargets[unitID] and not inAttackCommand(unitID) then + if activeTargets[unitID] and not restoreCommandTarget(unitID) then spSetUnitTarget(unitID, nil) end activeTargets[unitID] = nil @@ -372,10 +375,22 @@ if gadgetHandler:IsSyncedCode() then setTargetData[unitID] = nil pausedTargets[unitID] = nil SendToUnsynced("targetList", unitID, 0) -- clear command gfx + spSetUnitRulesParam(unitID, "hasPriorityTarget", nil) end spSetUnitRulesParam(unitID, "unitTargetID", nil) end + local function pauseTargetting(unitID) + pausedTargets[unitID] = activeTargets[unitID] + removeUnit(unitID, true) + end + + local function unpauseTargetting(unitID) + activeTargets[unitID] = pausedTargets[unitID] + pausedTargets[unitID] = nil + addToQueue(unitID) + end + local function addUnitTargets(unitID, unitDefID, targetList, append) if not spValidUnitID(unitID) then return @@ -426,12 +441,15 @@ if gadgetHandler:IsSyncedCode() then end setTargetData[unitID] = data + spSetUnitRulesParam(unitID, "hasPriorityTarget", 1) activeTargets[unitID] = data pausedTargets[unitID] = nil addToQueue(unitID) sendTargetsToUnsynced(unitID) - if not data.activeTarget and testTarget(unitID, data.teamID, data.weapons, targets[1].target) then + if not hasTargetPrecedence(unitID, data) then + pauseTargetting(unitID) + elseif not data.activeTarget and testTarget(unitID, data.teamID, data.weapons, targets[1].target) then setTargetActive(unitID, data, 1) end end @@ -530,7 +548,7 @@ if gadgetHandler:IsSyncedCode() then ---A single entry in a unit's target queue, as tracked on the synced side. ---@class UnitTargetEntry - ---@field target UnitID|Position3D Either a target unitID or a `{x, y, z}` ground position. + ---@field target UnitOrPosition ---@field alwaysSeen boolean? Target does not need to stay in sensor range to be kept. ---@field ignoreStop boolean? Target survives a Stop command. ---@field userTarget boolean? Target was set by the player rather than by Lua. @@ -538,7 +556,7 @@ if gadgetHandler:IsSyncedCode() then ---Returns the unit's currently active target. ---@param unitID UnitID - ---@return UnitID|Position3D|nil target A unitID, a `{x, y, z}` ground position, or `nil` when untargeted. + ---@return UnitOrPosition? target `nil` when untargeted. function GG.GetUnitTarget(unitID) local unitData = activeTargets[unitID] local targetData = unitData and unitData.targets[unitData.currentIndex] @@ -807,17 +825,6 @@ if gadgetHandler:IsSyncedCode() then --tracy.ZoneEnd() end - local function pauseTargetting(unitID) - pausedTargets[unitID] = activeTargets[unitID] - removeUnit(unitID, true) - end - - local function unpauseTargetting(unitID) - activeTargets[unitID] = pausedTargets[unitID] - pausedTargets[unitID] = nil - addToQueue(unitID) - end - function gadget:UnitCommand(unitID, unitDefID, teamID, cmdID, cmdParams, cmdOptions, cmdTag) if cmdID == CMD_STOP and setTargetData[unitID] then removeWithStop(unitID) @@ -966,8 +973,8 @@ if gadgetHandler:IsSyncedCode() then end end - -- Since v103 Attack commands override the unit target on any frame, not just slow updates. - -- So we try to override the target again, every single frame, to prevent target jittering. + -- Weapons re-read the unit target on any frame, and an Attack command will replace it whenever + -- the unit is able to fire. So we re-apply the target every frame to prevent target jittering. function gadget:GameFrame(frame) teamQueryCaches = {} if frame % 15 == 0 then @@ -1069,7 +1076,7 @@ else -- UNSYNCED ---An entry in the unsynced mirror of a unit's target queue, kept for drawing. ---@class UnitTargetEntryUnsynced - ---@field target UnitID|Position3D Either a target unitID or a `{x, y, z}` ground position. + ---@field target UnitOrPosition ---@field userTarget boolean? Target was set by the player rather than by Lua. ---Returns the unsynced mirror of the unit's target queue. diff --git a/luarules/gadgets/unit_timeslow.lua b/luarules/gadgets/unit_timeslow.lua index a87622b658c..c83b095e14a 100644 --- a/luarules/gadgets/unit_timeslow.lua +++ b/luarules/gadgets/unit_timeslow.lua @@ -21,18 +21,9 @@ if not gadgetHandler:IsSyncedCode() then end local spValidUnitID = Spring.ValidUnitID -local spGiveOrderToUnit = Spring.GiveOrderToUnit local spGetUnitHealth = Spring.GetUnitHealth local spSetUnitRulesParam = Spring.SetUnitRulesParam -local spGetUnitTeam = Spring.GetUnitTeam -local spSetUnitTarget = Spring.SetUnitTarget -local spGetUnitNearestEnemy = Spring.GetUnitNearestEnemy - -local CMD_ATTACK = CMD.ATTACK -local CMD_REMOVE = CMD.REMOVE -local CMD_MOVE = CMD.MOVE -local CMD_FIGHT = CMD.FIGHT -local CMD_SET_WANTED_MAX_SPEED = CMD.SET_WANTED_MAX_SPEED + local LOS_ACCESS = { inlos = true } local gaiaTeamID = Spring.GetGaiaTeamID() diff --git a/luarules/gadgets/unit_tombstones.lua b/luarules/gadgets/unit_tombstones.lua index af0db0b1364..2b58064ae85 100644 --- a/luarules/gadgets/unit_tombstones.lua +++ b/luarules/gadgets/unit_tombstones.lua @@ -16,16 +16,13 @@ if not gadgetHandler:IsSyncedCode() then return end +-- customparams.tombstone names the tombstone featuredef; scav copies inherit the +-- param but never dropped tombstones, so they stay excluded local isCommander = {} for defID, def in ipairs(UnitDefs) do - if def.customParams.iscommander ~= nil and not string.find(def.name, "scav") then - if string.sub(def.name, 1, 6) == "corcom" and FeatureDefNames.corstone then - isCommander[defID] = FeatureDefNames.corstone.id - elseif string.sub(def.name, 1, 6) == "armcom" and FeatureDefNames.armstone then - isCommander[defID] = FeatureDefNames.armstone.id - elseif string.sub(def.name, 1, 6) == "legcom" and FeatureDefNames.legstone then - isCommander[defID] = FeatureDefNames.legstone.id - end + local tombstone = def.customParams.tombstone + if tombstone and not def.customParams.isscavenger and FeatureDefNames[tombstone] then + isCommander[defID] = FeatureDefNames[tombstone].id end end diff --git a/luarules/gadgets/unit_transportable_nanos.lua b/luarules/gadgets/unit_transportable_nanos.lua index ade75bc8c28..38e2936fdb7 100644 --- a/luarules/gadgets/unit_transportable_nanos.lua +++ b/luarules/gadgets/unit_transportable_nanos.lua @@ -22,24 +22,14 @@ local spGetUnitDefID = Spring.GetUnitDefID local spGetUnitIsTransporting = Spring.GetUnitIsTransporting local spValidUnitID = Spring.ValidUnitID local spGetGroundNormal = Spring.GetGroundNormal -local stringFind = string.find local CMD_LOAD_UNITS = CMD.LOAD_UNITS local CMD_UNLOAD_UNITS = CMD.UNLOAD_UNITS -local Nanos = { - [UnitDefNames.cornanotc.id] = true, - [UnitDefNames.armnanotc.id] = true, -} -if Spring.GetModOptions().experimentallegionfaction then - Nanos[UnitDefNames.legnanotc.id] = true -end +local Nanos = {} for udid, ud in pairs(UnitDefs) do - for id in pairs(Nanos) do - if stringFind(ud.name, UnitDefs[id].name, 1, true) then - Nanos[udid] = true - break - end + if ud.customParams.isnanoturret then + Nanos[udid] = true end end diff --git a/luarules/gadgets/unit_transports_air_speed.lua b/luarules/gadgets/unit_transports_air_speed.lua index 26b3380d2dd..f4d67816dbc 100644 --- a/luarules/gadgets/unit_transports_air_speed.lua +++ b/luarules/gadgets/unit_transports_air_speed.lua @@ -16,7 +16,6 @@ if not gadgetHandler:IsSyncedCode() then return end -local TRANSPORTED_MASS_SPEED_PENALTY = 0.2 -- higher makes unit slower local FRAMES_PER_SECOND = Game.gameSpeed local airTransports = {} @@ -35,9 +34,7 @@ for unitDefID, unitDef in pairs(UnitDefs) do unitSpeed[unitDefID] = unitDef.speed end -local massUsageFraction = 0 local allowedSpeed = 0 -local currentMassUsage = 0 local spGetUnitVelocity = Spring.GetUnitVelocity local spSetUnitVelocity = Spring.SetUnitVelocity @@ -69,41 +66,6 @@ local function updateAllowedSpeed(transportId) end end ---Old complex weight calc for posterity: ---[[local function updateAllowedSpeed(transportId) - local uDefID = spGetUnitDefID(transportId) - - -- get sum of mass and size for all transported units - currentMassUsage = 0 - local units = spGetUnitIsTransporting(transportId) - local tunitdefid - local tunitdefcustom - local iscom = false - local transportspeedmult = 0.0 - if units then - for _,tUnitId in pairs(units) do - tunitdefid = spGetUnitDefID(tUnitId) - tunitdefcustom = UnitDefs[tunitdefid].customParams - if (tunitdefcustom ~=nil) then - transportspeedmult = tunitdefcustom.transportspeedmult ~=nil and tunitdefcustom.transportspeedmult or transportspeedmult--use custom if present (can be tweaked) - iscom = tunitdefcustom.iscommander=='1' - end - - currentMassUsage = currentMassUsage + unitMass[tunitdefid] - end - massUsageFraction = (currentMassUsage / unitTransportMass[uDefID]) - - if (iscom) then - - allowedSpeed = unitSpeed[uDefID] * (1 - massUsageFraction * (TRANSPORTED_MASS_SPEED_PENALTY+transportspeedmult)) / FRAMES_PER_SECOND - else - allowedSpeed = unitSpeed[uDefID] * (1 - massUsageFraction * TRANSPORTED_MASS_SPEED_PENALTY) / FRAMES_PER_SECOND - --Spring.Echo("unit "..transportUnitDef.name.." is air transport at "..(massUsageFraction*100).."%".." load, curSpeed="..vw.." allowedSpeed="..allowedSpeed) - end - airTransportMaxSpeeds[transportId] = allowedSpeed - end -end]] - -- add transports to table when they load a unit function gadget:UnitLoaded(unitId, unitDefId, unitTeam, transportId, transportTeam) if canFly[spGetUnitDefID(transportId)] and not airTransports[transportId] then diff --git a/luarules/gadgets/unit_wanted_speed.lua b/luarules/gadgets/unit_wanted_speed.lua index 0dff30931e4..5148b2ca5da 100644 --- a/luarules/gadgets/unit_wanted_speed.lua +++ b/luarules/gadgets/unit_wanted_speed.lua @@ -45,7 +45,6 @@ end local units = {} local moveTypeByDefID = {} -local moveType = 0 do --local moveData = {} --local moveType = 0 diff --git a/luarules/gadgets/unit_xmas.lua b/luarules/gadgets/unit_xmas.lua index ab18bfe9132..b4cd2742292 100644 --- a/luarules/gadgets/unit_xmas.lua +++ b/luarules/gadgets/unit_xmas.lua @@ -60,21 +60,13 @@ for _, teamID in ipairs(Spring.GetTeamList()) do end end +-- every commander wreck gets swapped for the xmas wreck (scav commanders drop out +-- because iscommander is replaced with isscavcommander on their defs) local isComWreck = {} -local xmasComwreckDefID -for fdefID, def in ipairs(FeatureDefs) do - if - def.name == "armcom_dead" - or def.name == "corcom_dead" - or def.name == "legcom_dead" - or def.name == "legcomlvl2_dead" - or def.name == "legcomlvl3_dead" - or def.name == "legcomlvl4_dead" - then - isComWreck[fdefID] = true - end - if def.name == "xmascomwreck" then - xmasComwreckDefID = fdefID +local xmasComwreckDefID = FeatureDefNames.xmascomwreck and FeatureDefNames.xmascomwreck.id +for unitDefID, unitDef in ipairs(UnitDefs) do + if unitDef.customParams.iscommander and unitDef.corpse and FeatureDefNames[unitDef.corpse] then + isComWreck[FeatureDefNames[unitDef.corpse].id] = true end end diff --git a/luarules/gadgets/unit_zombies.lua b/luarules/gadgets/unit_zombies.lua deleted file mode 100644 index 192f5f6f9c5..00000000000 --- a/luarules/gadgets/unit_zombies.lua +++ /dev/null @@ -1,1753 +0,0 @@ -function gadget:GetInfo() - return { - name = "Zombies", - desc = "Resurrects corpses as Scavengers or hostile Gaia Zombies", - author = "SethDGamre, code snippets/inspiration from Rafal", - date = "March 2024", - license = "GNU GPL, v2 or later", - layer = 2, -- after game_team_resources.lua - enabled = true, - } -end - --- To customize zombie respawn time, use customParams.zombie_respawn_time (seconds): --- < 0 never respawn as a zombie --- 0 respawn instantly --- > 0 custom respawn delay in seconds --- this overrides default timing based on unit power, difficulty, and gamestate. - -if not gadgetHandler:IsSyncedCode() then - return false -end - -local modOptions = Spring.GetModOptions() - -local ZOMBIE_GUARD_RADIUS = 500 -- Radius for zombies to guard allies -local ZOMBIE_MAX_ORDER_ATTEMPTS = 10 -local ZOMBIE_MAX_ORDERS_ISSUED = 2 -local ZOMBIE_FACTORY_BUILD_COUNT = 20 -local ZOMBIE_GUARD_CHANCE = 0.75 -- Chance a zombie will guard allies -local REFRESH_ORDERS_CHANCE = 0.005 -local WARNING_TIME = Game.gameSpeed * 15 -- Frames to start warning before reanimation -local TIMER_NEAR_MAX_THRESHOLD = Game.gameSpeed * 5 -- Frames to start warning before reanimation -local ZOMBIE_REZ_FRAME_PARAM = "zombie_rez_frame" -local WAS_ZOMBIE_PARAM = "wasZombie" -local PUBLIC_RULES_PARAM_ACCESS = { public = true } -local WAS_ZOMBIE_TIMEOUT_FRAMES = Game.gameSpeed * 3 - -local ZOMBIE_MAX_XP = 2 -- Maximum experience value for zombies, skewed towards median - -local standardTechToRezPowerSpeeds = { - [0.5] = 1, - [1] = 1, - [1.5] = 3, - [2] = 8, - [2.5] = 25, - [3] = 42, - [3.5] = 63, - [4] = 83, - [4.5] = 104, -} - -local harderTechToRezPowerSpeeds = { - [0.5] = 1, - [1] = 2, - [1.5] = 5, - [2] = 12, - [2.5] = 38, - [3] = 64, - [3.5] = 86, - [4] = 108, - [4.5] = 130, -} - ----One of the zombie difficulty presets, matching the keys of `zombieModeConfigs`. ----@alias ZombieMode "normal"|"hard"|"nightmare"|"akumu" - -local zombieModeConfigs = { - normal = { - techToRezPowerSpeeds = standardTechToRezPowerSpeeds, - rezMin = 90, - rezMax = 180, - countMin = 1, - countMax = 1, - zombieCorpses = false, - }, - hard = { - techToRezPowerSpeeds = harderTechToRezPowerSpeeds, - rezMin = 60, - rezMax = 180, - countMin = 1, - countMax = 1, - zombieCorpses = false, - }, - nightmare = { - techToRezPowerSpeeds = harderTechToRezPowerSpeeds, - rezMin = 60, - rezMax = 120, - countMin = 2, - countMax = 6, - zombieCorpses = false, - }, - akumu = { - techToRezPowerSpeeds = harderTechToRezPowerSpeeds, - rezMin = 60, - rezMax = 120, - countMin = 2, - countMax = 8, - zombieCorpses = true, - }, -} - ----@type ZombieMode -local currentZombieMode = "normal" -local currentZombieConfig = zombieModeConfigs.normal - -local ZOMBIE_ORDER_CHECK_INTERVAL = Game.gameSpeed * 3 -- How often (in frames) to check if zombies need new orders -local ZOMBIE_CHECK_INTERVAL = Game.gameSpeed -- How often (in frames) everything else is checked -local STUCK_CHECK_INTERVAL = Game.gameSpeed * 12 -- How often (in frames) to check if zombies are stuck -local REZ_SPEED_UPDATE_INTERVAL = Game.gameSpeed * 60 - -local STUCK_DISTANCE = 50 -- How far (in units) a zombie can move before being considered stuck -local MAX_NOGO_ZONES = 10 -- How many no-go zones a zombie can have before being considered stuck -local NOGO_ZONE_RADIUS = 600 -- How far (in units) a no-go zone is -local NOGO_ZONE_RADIUS_SQ = NOGO_ZONE_RADIUS * NOGO_ZONE_RADIUS -local ENEMY_ATTACK_DISTANCE = 1000 -- How far (in units) a zombie will detect and choose to attack an enemy -local ORDER_DISTANCE = 800 -- How far (in units) a zombie moves per order - -local CMD_REPEAT = CMD.REPEAT -local CMD_MOVE_STATE = CMD.MOVE_STATE -local CMD_GUARD = CMD.GUARD -local CMD_FIRE_STATE = CMD.FIRE_STATE -local CMD_MOVE = CMD.MOVE -local CMD_CAPTURE = CMD.CAPTURE -local CMD_FIGHT = CMD.FIGHT -local CMD_OPT_SHIFT = { "shift" } - -local FIRE_STATE_FIRE_AT_ALL = 3 -local FIRE_STATE_RETURN_FIRE = 1 -local MOVE_STATE_HOLD_POSITION = 0 -local ENABLE_REPEAT = 1 -local NULL_ATTACKER = -1 -local ENVIRONMENTAL_DAMAGE_ID = Game.envDamageTypes.GroundCollision -local WATER_DAMAGE_DEF_ID = Game.envDamageTypes.Water -local UNAUTHORIZED_TEXT = "You are not authorized to use zombie commands" --i18n library doesn't exist in gadget space. - -local MAP_SIZE_X = Game.mapSizeX -local MAP_SIZE_Z = Game.mapSizeZ - -local spGetUnitRotation = Spring.GetUnitRotation -local spGetUnitNearestEnemy = Spring.GetUnitNearestEnemy -local spValidUnitID = Spring.ValidUnitID -local spGetGroundHeight = Spring.GetGroundHeight -local spGetUnitPosition = Spring.GetUnitPosition -local spGetUnitBasePosition = Spring.GetUnitBasePosition -local spGetFeaturePosition = Spring.GetFeaturePosition -local spGetGameRulesParam = Spring.GetGameRulesParam -local spCreateUnit = Spring.CreateUnit -local spTransferUnit = Spring.TransferUnit -local spGetUnitDefID = Spring.GetUnitDefID -local spGetUnitTeam = Spring.GetUnitTeam -local spGetAllUnits = Spring.GetAllUnits -local spGetGameFrame = Spring.GetGameFrame -local spGetAllFeatures = Spring.GetAllFeatures -local spGiveOrderToUnit = Spring.GiveOrderToUnit -local spGetUnitCommandCount = Spring.GetUnitCommandCount -local spDestroyFeature = Spring.DestroyFeature -local spGetUnitIsDead = Spring.GetUnitIsDead -local spGiveOrderArrayToUnit = Spring.GiveOrderArrayToUnit -local spGetUnitsInCylinder = Spring.GetUnitsInCylinder -local spSetTeamResource = Spring.SetTeamResource -local spGetUnitHealth = Spring.GetUnitHealth -local spSetUnitHealth = Spring.SetUnitHealth -local spSetUnitRulesParam = Spring.SetUnitRulesParam -local spGetUnitRulesParam = Spring.GetUnitRulesParam -local spSetFeatureRulesParam = Spring.SetFeatureRulesParam -local spGetFeatureRulesParam = Spring.GetFeatureRulesParam -local spGetFeatureDefID = Spring.GetFeatureDefID -local spTestMoveOrder = Spring.TestMoveOrder -local spSpawnCEG = Spring.SpawnCEG -local spGetFeatureResources = Spring.GetFeatureResources -local spGetFeatureHealth = Spring.GetFeatureHealth -local spDestroyUnit = Spring.DestroyUnit -local spGetUnitDirection = Spring.GetUnitDirection -local spCreateFeature = Spring.CreateFeature -local spSpawnExplosion = Spring.SpawnExplosion -local spPlaySoundFile = Spring.PlaySoundFile -local spGetFeatureRadius = Spring.GetFeatureRadius -local spGetUnitCurrentCommand = Spring.GetUnitCurrentCommand -local spGetFactoryCommands = Spring.GetFactoryCommands -local spAddTeamResource = Spring.AddTeamResource -local spSetUnitExperience = Spring.SetUnitExperience -local spGetUnitExperience = Spring.GetUnitExperience -local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt -local spGetUnitHeight = Spring.GetUnitHeight -local random = math.random -local distance2dSquared = math.distance2dSquared -local pi = math.pi -local tau = 2 * pi -local cos = math.cos -local sin = math.sin -local floor = math.floor -local clamp = math.clamp -local ceil = math.ceil - -local teams = Spring.GetTeamList() -local scavTeamID -local gaiaTeamID = Spring.GetGaiaTeamID() -local readAsGaia = { ctrl = gaiaTeamID, read = gaiaTeamID, select = gaiaTeamID } -for _, teamID in ipairs(teams) do - local teamLuaAI = Spring.GetTeamLuaAI(teamID) - if teamLuaAI and string.find(teamLuaAI, "ScavengersAI") then - scavTeamID = teamID - end -end - -local ordersEnabled = true -local gameFrame = 0 -local adjustedRezPowerSpeed = currentZombieConfig.techToRezPowerSpeeds[1] -local currentTechLevel = nil -local isIdleMode = false -local autoSpawningEnabled = true - -local extraDefs = {} -local factoriesWithCombatOptions = {} -local zombiesBeingBuilt = {} -local zombieCorpseDefs = {} -local zombieWatch = {} -local corpseCheckFrames = {} -local corpsesData = {} -local wereZombies = {} -local pendingUnitXp = {} -local pendingZombieCaptures = {} -local heapingZombies = {} -local zombieHeapDefs = {} -local fightingDefs = {} -local unitDefWithWeaponRanges = {} -local capturingUnits = {} -local aaOnlyUnits = {} -local antiUnderWaterOnlyUnits = {} -local flyingUnits = {} -local unitDefs = UnitDefs -local unitDefNames = UnitDefNames -local featureDefNames = FeatureDefNames -local featureDefs = FeatureDefs - -local warningEffects = { - "scavmist", - "scavradiation-lightning", -} -local spawnEffects = { - "xploelc2", - "xploelc3", -} - -for unitDefID, unitDef in pairs(unitDefs) do - local corpseDefName = unitDef.corpse - if featureDefNames[corpseDefName] then - local corpseDefID = featureDefNames[corpseDefName].id - local corpseDefData = { unitDefID = unitDefID } - local customRespawnTime = tonumber(unitDef.customParams and unitDef.customParams.zombie_respawn_time) - if customRespawnTime then - if customRespawnTime < 0 then - corpseDefData.neverRespawn = true - else - corpseDefData.customRespawnTime = customRespawnTime - end - end - zombieCorpseDefs[corpseDefID] = corpseDefData - - local zombieDefData = {} - local deathExplosionName = unitDef.deathExplosion - local explosionDefID = WeaponDefNames[deathExplosionName].id - zombieDefData.explosionDefID = explosionDefID - - local heapDefName = featureDefs[corpseDefID].deathFeatureID - if heapDefName then - zombieDefData.heapDefID = heapDefName - end - - zombieHeapDefs[unitDefID] = zombieDefData - end - - if unitDef.weapons and #unitDef.weapons > 0 then - for i = 1, #unitDef.weapons do - local weaponDef = WeaponDefs[unitDef.weapons[i].weaponDef] - if weaponDef and weaponDef.range and weaponDef.range > 0 then - unitDefWithWeaponRanges[unitDefID] = weaponDef.range - break - end - end - end - - if unitDef.canFight then - fightingDefs[unitDefID] = true - end - - if unitDef.canRepair then - capturingUnits[unitDefID] = true - end - - if unitDef.weapons and #unitDef.weapons > 0 then - local hasWeapons = false - local allWeaponsAA = true - local allWeaponsUnderwater = true - local hasNonUnderwaterWeapons = false - - for i = 1, #unitDef.weapons do - local weaponDefID = unitDef.weapons[i].weaponDef - if weaponDefID then - local weaponDef = WeaponDefs[weaponDefID] - if - weaponDef - and weaponDef.range - and weaponDef.range > 0 - and not (weaponDef.customParams and weaponDef.customParams.bogus) - then - hasWeapons = true - - local isAAWeapon = false - if unitDef.weapons[i].onlyTargets and unitDef.weapons[i].onlyTargets.vtol then - isAAWeapon = true - end - - local isUnderwaterOnly = weaponDef.waterWeapon or false - - if not isAAWeapon then - allWeaponsAA = false - end - - if not isUnderwaterOnly then - allWeaponsUnderwater = false - hasNonUnderwaterWeapons = true - end - end - end - end - - if hasWeapons and allWeaponsAA then - aaOnlyUnits[unitDefID] = true - end - - if hasWeapons and allWeaponsUnderwater and not hasNonUnderwaterWeapons then - antiUnderWaterOnlyUnits[unitDefID] = true - end - end -end - -for unitDefID, unitDef in pairs(unitDefs) do - extraDefs[unitDefID] = {} - if unitDef.speed > 0 then - extraDefs[unitDefID].isMobile = true - elseif #unitDef.buildOptions > 0 then - local combatOptions = {} - for i = 1, #unitDef.buildOptions do - local optionDefID = unitDef.buildOptions[i] - if unitDefWithWeaponRanges[optionDefID] then - combatOptions[#combatOptions + 1] = optionDefID - end - end - if #combatOptions > 0 then - factoriesWithCombatOptions[unitDefID] = combatOptions - end - end -end - -local function initializeZombie(unitID, unitDefID) - local x, y, z = spGetUnitPosition(unitID) - zombieWatch[unitID] = { unitDefID = unitDefID, lastX = x, lastY = y, lastZ = z, noGoZones = {}, isStuck = false } -end - -local function isZombie(unitID) - local isZombieRulesParam = spGetUnitRulesParam(unitID, "zombie") - return isZombieRulesParam and isZombieRulesParam == 1 -end - -local function setGaiaStorage() - local metalStorageToSet = 1000000 - local energyStorageToSet = 1000000 - - local _, currentMetalStorage = Spring.GetTeamResources(gaiaTeamID, "metal") - if currentMetalStorage and currentMetalStorage < metalStorageToSet then - spSetTeamResource(gaiaTeamID, "ms", metalStorageToSet) - end - - local _, currentEnergyStorage = Spring.GetTeamResources(gaiaTeamID, "energy") - if currentEnergyStorage and currentEnergyStorage < energyStorageToSet then - spSetTeamResource(gaiaTeamID, "es", energyStorageToSet) - end -end - -local function getUnitRezPower(unitDef) - return math.max(1, unitDef.power or 1) -end - -local function calculateSpawnDelayFrames(unitPower) - local spawnSeconds = floor(unitPower / adjustedRezPowerSpeed) - spawnSeconds = clamp(spawnSeconds, currentZombieConfig.rezMin, currentZombieConfig.rezMax) - return spawnSeconds * Game.gameSpeed -end - -local function getRezPowerSpeedForTechLevel(config, techLevel) - local speeds = config.techToRezPowerSpeeds - if speeds[techLevel] then - return speeds[techLevel] - end - return speeds[1] -end - -local function rebuildZombieCorpseSpawnDelays() - for _, corpseDefData in pairs(zombieCorpseDefs) do - if corpseDefData.neverRespawn then - corpseDefData.spawnDelayFrames = nil - elseif corpseDefData.customRespawnTime then - corpseDefData.spawnDelayFrames = floor(corpseDefData.customRespawnTime * Game.gameSpeed) - else - local unitDef = unitDefs[corpseDefData.unitDefID] - if unitDef then - corpseDefData.spawnDelayFrames = calculateSpawnDelayFrames(getUnitRezPower(unitDef)) - end - end - end -end - -local function updateAdjustedRezPowerSpeed() - local techLevel = 1 - adjustedRezPowerSpeed = getRezPowerSpeedForTechLevel(currentZombieConfig, techLevel) - if GG.PowerLib and GG.PowerLib.HighestPlayerTeamPower and GG.PowerLib.TechGuesstimate then - local highestPowerData = GG.PowerLib.HighestPlayerTeamPower() - if highestPowerData and highestPowerData.power then - techLevel = GG.PowerLib.TechGuesstimate(highestPowerData.power) - adjustedRezPowerSpeed = getRezPowerSpeedForTechLevel(currentZombieConfig, techLevel) - end - end - - currentTechLevel = techLevel -end - -local function updateRezSpeed() - updateAdjustedRezPowerSpeed() - rebuildZombieCorpseSpawnDelays() -end - ----Applies a preset's tuning to the live zombie config, falling back to `normal` ----for an unknown mode. ----@param mode ZombieMode -local function applyZombieModeSettings(mode) - local config = zombieModeConfigs[mode] - ---@diagnostic disable-next-line: unnecessary-if - if not config then - config = zombieModeConfigs.normal - end - - currentZombieMode = mode - currentZombieConfig = config - - updateRezSpeed() -end - -local function calculateHealthRatio(featureID) - local partialReclaimRatio = 1 - local damagedReductionRatio = 1 - local currentMetal, maxMetal = spGetFeatureResources(featureID) - if currentMetal and maxMetal and currentMetal ~= 0 and maxMetal ~= 0 then - partialReclaimRatio = currentMetal / maxMetal - end - local health, maxHealth = spGetFeatureHealth(featureID) - if health and maxHealth and health ~= 0 and maxHealth ~= 0 then - damagedReductionRatio = health / maxHealth - end - local healthRatio = (partialReclaimRatio + damagedReductionRatio) * 0.5 --average the two ratios to skew the result towards maximum health - return healthRatio -end - ---we use this instead of spGetUnitNearestAlly to make sure the unit is not guarding something on terrain it cannot traverse (like boats/land) -local function GetUnitNearestReachableAlly(unitID, unitDefID, range) - local bestAllyID - local bestDistanceSquared - if spGetUnitIsBeingBuilt(unitID) then - return nil - end - - local x, y, z = spGetUnitPosition(unitID) - if not x or not z then - return nil - end - - local readAsGaia = { ctrl = gaiaTeamID, read = gaiaTeamID, select = gaiaTeamID } - local gaiaUnits = CallAsTeam(readAsGaia, spGetUnitsInCylinder, x, z, range, Spring.ALLY_UNITS) - - for i = 1, #gaiaUnits do - local allyID = gaiaUnits[i] - local allyDefID = spGetUnitDefID(allyID) - local currentCommand = spGetUnitCurrentCommand(allyID) - if - (allyID ~= unitID) - and fightingDefs[allyDefID] - and currentCommand ~= CMD_GUARD - and extraDefs[allyDefID].isMobile - then - local ox, oy, oz = spGetUnitPosition(allyID) - if ox and oy and oz then - local currentDistanceSquared = distance2dSquared(x, z, ox, oz) - if - spTestMoveOrder(unitDefID, ox, oy, oz) - and ((bestDistanceSquared == nil) or (currentDistanceSquared < bestDistanceSquared)) - then - bestAllyID = allyID - bestDistanceSquared = currentDistanceSquared - end - end - end - end - return bestAllyID -end - -local function issueRandomFactoryBuildOrders(unitID, unitDefID) - local combatOptions = factoriesWithCombatOptions[unitDefID] - - if not combatOptions or #combatOptions == 0 then - return - end - - local builds = {} - for i = 1, ZOMBIE_FACTORY_BUILD_COUNT do - builds[#builds + 1] = { -combatOptions[random(1, #combatOptions)], 0, 0 } - end - - if #builds > 0 then - spGiveOrderArrayToUnit(unitID, builds) - end -end - -local function warningCEG(featureID, x, y, z) - local radius = spGetFeatureRadius(featureID) - - local selectedEffect = warningEffects[random(#warningEffects)] - if selectedEffect == "scavradiation-lightning" and GG.SpawnEnvironmentalLightning then - GG.SpawnEnvironmentalLightning("scavradiation", x, y, z) - else - spSpawnCEG(selectedEffect, x, y, z, 0, 0, 0, radius * 0.25) - end - spSpawnCEG("scaspawn-trail", x, y, z, 0, 0, 0, radius) -end - -local function playSpawnSound(x, y, z) - local selectedEffect = spawnEffects[random(#spawnEffects)] - spPlaySoundFile(selectedEffect, 0.5, x, y, z, 0) -end - --- for some reason, engine gives us the LEFT direction as the yaw instead of the forwards direction. This gets and corrects it. -local function getActualForwardsYaw(unitID) - return select(2, spGetUnitRotation(unitID)) + (pi / 2) -end - -local function canAttackTarget(attackerID, attackerDefID, targetID, targetYPosition) - if aaOnlyUnits[attackerDefID] then - local targetDef = unitDefs[targetID] - if targetDef and targetDef.canFly and aaOnlyUnits[attackerDefID] then - return true - end - elseif antiUnderWaterOnlyUnits[attackerDefID] then - if targetYPosition <= 0 then - return true - end - elseif targetYPosition + spGetUnitHeight(targetID) >= 0 and not flyingUnits[targetID] then - return true - end - return false -end - -local function updateOrders(unitID, unitDefID, closestKnownEnemy, currentCommand) - if not spValidUnitID(unitID) or spGetUnitIsDead(unitID) then - zombieWatch[unitID] = nil - return - end - local isAlreadyGuarding = currentCommand and currentCommand == CMD_GUARD - local nearAlly - if not closestKnownEnemy and currentCommand ~= CMD_MOVE and not isAlreadyGuarding and fightingDefs[unitDefID] then - nearAlly = GetUnitNearestReachableAlly(unitID, unitDefID, ZOMBIE_GUARD_RADIUS) - end - local weaponRange = unitDefWithWeaponRanges[unitDefID] - local data = zombieWatch[unitID] - - if capturingUnits[unitDefID] and closestKnownEnemy and not data.isStuck then - local enemyDefID = spGetUnitDefID(closestKnownEnemy) - if enemyDefID and unitDefs[enemyDefID].capturable ~= false then - spGiveOrderToUnit(unitID, CMD_CAPTURE, { closestKnownEnemy }, 0) - else - data.isStuck = true - end - elseif not data.isStuck and nearAlly and not closestKnownEnemy and random() < ZOMBIE_GUARD_CHANCE then - spGiveOrderToUnit(unitID, CMD_GUARD, { nearAlly }, 0) - elseif extraDefs[unitDefID].isMobile then - local x, y, z = spGetUnitPosition(unitID) - local ordersIssued = 0 - for attempts = 1, ZOMBIE_MAX_ORDER_ATTEMPTS do - local inNoGoZone = false - local attemptX, attemptY, attemptZ - if not data.isStuck and closestKnownEnemy and weaponRange then - local enemyX, enemyY, enemyZ = spGetUnitPosition(closestKnownEnemy) - if enemyX and canAttackTarget(unitID, unitDefID, closestKnownEnemy, enemyY) then - local CLOSER_VARIANCE = 0.5 - weaponRange = weaponRange * CLOSER_VARIANCE - local dx = x - enemyX - local dz = z - enemyZ - - local distance = math.sqrt(dx * dx + dz * dz) - - if distance > 0 then - local normalizedDx = dx / distance - local normalizedDz = dz / distance - - attemptX = enemyX + normalizedDx * weaponRange - attemptZ = enemyZ + normalizedDz * weaponRange - attemptY = spGetGroundHeight(attemptX, attemptZ) - end - end - closestKnownEnemy = nil - else - if isAlreadyGuarding then - break - end - if data.isStuck or attempts == ZOMBIE_MAX_ORDER_ATTEMPTS then - local randomAngle = random() * tau - attemptX = x + ORDER_DISTANCE * cos(randomAngle) - attemptZ = z + ORDER_DISTANCE * sin(randomAngle) - else - local ANGLE_COMPOUNDER = 1.5 - local biasDirection = (random() > 0.5) and 1 or -1 - local baseAngleOffset = pi / 4 - local angleOffset = baseAngleOffset * (ANGLE_COMPOUNDER ^ (attempts - 1)) - local movementAngle = getActualForwardsYaw(unitID) + (biasDirection * angleOffset) - - attemptX = x + ORDER_DISTANCE * cos(movementAngle) - attemptZ = z + ORDER_DISTANCE * sin(movementAngle) - end - - if attemptX < 0 or attemptX > MAP_SIZE_X or attemptZ < 0 or attemptZ > MAP_SIZE_Z then - data.isStuck = true - end - - if attemptX then - attemptY = spGetGroundHeight(attemptX, attemptZ) - end - end - if attemptX then - for _, zone in ipairs(data.noGoZones) do - local dx = attemptX - zone.x - local dz = attemptZ - zone.z - if (dx * dx + dz * dz) < NOGO_ZONE_RADIUS_SQ then - inNoGoZone = true - break - end - end - end - if attemptX and attemptY then - local POSITION_VARIANCE = 50 - attemptX = attemptX + random(-POSITION_VARIANCE, POSITION_VARIANCE) - attemptZ = attemptZ + random(-POSITION_VARIANCE, POSITION_VARIANCE) - if not inNoGoZone and spTestMoveOrder(unitDefID, attemptX, attemptY, attemptZ) then - spGiveOrderToUnit(unitID, CMD_MOVE, { attemptX, attemptY, attemptZ }, CMD_OPT_SHIFT) - ordersIssued = ordersIssued + 1 - if ordersIssued >= ZOMBIE_MAX_ORDERS_ISSUED then - break - end - end - end - end - end - - if factoriesWithCombatOptions[unitDefID] then - local factoryCommands = spGetFactoryCommands(unitID, -1) or {} - local currentCommandCount = #factoryCommands - if currentCommandCount < ZOMBIE_FACTORY_BUILD_COUNT then - issueRandomFactoryBuildOrders(unitID, unitDefID) - end - end -end - -local function setCorpseRezRulesParam(featureID, spawnFrame) - spSetFeatureRulesParam(featureID, ZOMBIE_REZ_FRAME_PARAM, spawnFrame, PUBLIC_RULES_PARAM_ACCESS) -end - -local function clearCorpseRezRulesParam(featureID) - spSetFeatureRulesParam(featureID, ZOMBIE_REZ_FRAME_PARAM, nil, PUBLIC_RULES_PARAM_ACCESS) -end - -local function wasZombieCorpse(featureID, corpseData) - if corpseData and corpseData.wasZombie then - return true - end - local wasZombieParam = spGetFeatureRulesParam(featureID, WAS_ZOMBIE_PARAM) - return wasZombieParam == 1 -end - -local function resetSpawn(featureID, featureData, featureDefData) - local newFrame = featureData.tamperedFrame + featureData.spawnDelayFrames - featureData.spawnFrame = newFrame - featureData.creationFrame = featureData.tamperedFrame - featureData.tamperedFrame = nil - setCorpseRezRulesParam(featureID, newFrame) - corpseCheckFrames[newFrame] = corpseCheckFrames[newFrame] or {} - corpseCheckFrames[newFrame][#corpseCheckFrames[newFrame] + 1] = featureID -end - -local function getScavVariantUnitDefID(unitDefID) - local unitDef = unitDefs[unitDefID] - if not unitDef then - return unitDefID - end - - if string.find(unitDef.name, "_scav") then - return unitDefID - end - - local scavUnitDefName = unitDef.name .. "_scav" - local scavUnitDef = unitDefNames[scavUnitDefName] - return scavUnitDef and scavUnitDef.id or unitDefID -end - -local function setZombieStates(unitID, unitDefID) - if factoriesWithCombatOptions[unitDefID] then - spGiveOrderToUnit(unitID, CMD_REPEAT, ENABLE_REPEAT, 0) - end - spGiveOrderToUnit(unitID, CMD_MOVE_STATE, MOVE_STATE_HOLD_POSITION, 0) - if ordersEnabled then - spGiveOrderToUnit(unitID, CMD_FIRE_STATE, FIRE_STATE_FIRE_AT_ALL, 0) - else - spGiveOrderToUnit(unitID, CMD_FIRE_STATE, FIRE_STATE_RETURN_FIRE, 0) - end - spSetUnitRulesParam(unitID, "resurrected", 0, { inlos = true }) -end - -local function rollSpawnCount() - return random(currentZombieConfig.countMin, currentZombieConfig.countMax) -end - -local function calculateSpawnCount(unitDefID) - local countMin = currentZombieConfig.countMin - local countMax = currentZombieConfig.countMax - if countMin == countMax then - return countMin - end - - local unitDef = unitDefs[unitDefID] - if not unitDef then - return countMin - end - - local rezTimeSeconds = calculateSpawnDelayFrames(getUnitRezPower(unitDef)) / Game.gameSpeed - local rezMin = currentZombieConfig.rezMin - local rezMax = currentZombieConfig.rezMax - - if currentTechLevel == nil or currentTechLevel <= 1 then - return math.min(rollSpawnCount(), rollSpawnCount(), rollSpawnCount()) - end - - if rezTimeSeconds == rezMin then - return rollSpawnCount() - end - if rezTimeSeconds == rezMax then - return math.min(rollSpawnCount(), rollSpawnCount(), rollSpawnCount()) - end - return math.min(rollSpawnCount(), rollSpawnCount()) -end - -local function spawnZombies(featureID, unitDefID, healthReductionRatio, x, y, z, wasZombie, pastXp) - local unitDef = unitDefs[unitDefID] - local spawnCount = 1 - if not wasZombie and extraDefs[unitDefID].isMobile then - spawnCount = calculateSpawnCount(unitDefID) - end - local size = unitDef.xsize - local unitDefToCreate = getScavVariantUnitDefID(unitDefID) - local sizeCategory = ceil((unitDef.xsize / 2 + unitDef.zsize / 2) / 2) - local sizeName = "small" - if sizeCategory > 4.5 then - sizeName = "huge" - elseif sizeCategory > 3.5 then - sizeName = "large" - elseif sizeCategory > 2.5 then - sizeName = "medium" - elseif sizeCategory > 1.5 then - sizeName = "small" - else - sizeName = "tiny" - end - - if pastXp == nil then - local corpseData = corpsesData[featureID] - if corpseData and corpseData.pastXp ~= nil then - pastXp = corpseData.pastXp - else - pastXp = spGetFeatureRulesParam(featureID, "previous_xp") or 0 - end - end - - spDestroyFeature(featureID) - corpsesData[featureID] = nil - playSpawnSound(x, y, z) - - for i = 1, spawnCount do - local randomX = x + random(-size * spawnCount, size * spawnCount) - local randomZ = z + random(-size * spawnCount, size * spawnCount) - local adjustedY = spGetGroundHeight(randomX, randomZ) - - local unitID = spCreateUnit(unitDefToCreate, randomX, adjustedY, randomZ, 0, gaiaTeamID) - if unitID then - spSpawnCEG("scav-spawnexplo-" .. sizeName, randomX, adjustedY, randomZ, 0, 0, 0) - local generatedXp = 0 - if modOptions.zombies ~= "normal" then - generatedXp = (random() * ZOMBIE_MAX_XP + random() * ZOMBIE_MAX_XP) / 2 - end - spSetUnitExperience(unitID, math.max(pastXp, generatedXp)) - local unitHealth = spGetUnitHealth(unitID) - spSetUnitHealth(unitID, unitHealth * healthReductionRatio) - spSetUnitRulesParam(unitID, "zombie", 1) - if scavTeamID then - spTransferUnit(unitID, scavTeamID) - else - initializeZombie(unitID, unitDefID) - if ordersEnabled then - local closestKnownEnemy = spGetUnitNearestEnemy(unitID, ENEMY_ATTACK_DISTANCE, true) - local currentCommand = spGetUnitCurrentCommand(unitID) - updateOrders(unitID, unitDefToCreate, closestKnownEnemy, currentCommand) - end - setZombieStates(unitID, unitDefID) - end - end - end -end - ----Turns a unit into a zombie, swapping it for its `_scav` variant where one exists. ----@param unitID UnitID -local function setZombie(unitID) - local unitDefID = spGetUnitDefID(unitID) - if not unitDefID then - return - end - - local scavUnitDefID = getScavVariantUnitDefID(unitDefID) - - -- If we need to convert to _scav variant - if scavUnitDefID ~= unitDefID then - local x, y, z = spGetUnitPosition(unitID) - local facing = spGetUnitDirection(unitID) - local teamID = spGetUnitTeam(unitID) - local newUnitID - if x and facing and teamID then - newUnitID = spCreateUnit(scavUnitDefID, x, y, z, facing, teamID) - end - if newUnitID then - local health, maxHealth = spGetUnitHealth(unitID) - if health and maxHealth then - local originalHealthRatio = health / maxHealth - spSetUnitHealth(newUnitID, originalHealthRatio * maxHealth) - end - local experience = spGetUnitExperience(unitID) - spSetUnitExperience(newUnitID, experience) - - spDestroyUnit(unitID, false, true) - - unitID = newUnitID - unitDefID = scavUnitDefID - end - end - - spSetUnitRulesParam(unitID, "zombie", 1) - initializeZombie(unitID, unitDefID) - setZombieStates(unitID, unitDefID) -end - -local function clearUnitOrders(unitID) - if spValidUnitID(unitID) then - spGiveOrderToUnit(unitID, CMD.STOP, {}, {}) - end -end - ----Clears the queued orders of every tracked zombie. -local function clearAllOrders() - for zombieID, _ in pairs(zombieWatch) do - clearUnitOrders(zombieID) - end -end - -function gadget:FeatureBuildStepPost(featureID) - local featureData = corpsesData[featureID] - if featureData then - if not featureData.tamperedFrame then - local remainingFrames = featureData.spawnFrame - gameFrame - if remainingFrames < featureData.spawnDelayFrames - TIMER_NEAR_MAX_THRESHOLD then - local featureX, featureY, featureZ = spGetFeaturePosition(featureID) - if featureX then - spSpawnCEG("scaspawn-trail", featureX, featureY + 15, featureZ, 0, 0, 0) - end - end - end - featureData.tamperedFrame = gameFrame - end -end - -function UnitEnteredAir(unitID) - flyingUnits[unitID] = true -end - -function UnitLeftAir(unitID) - flyingUnits[unitID] = nil -end - -function gadget:GameFrame(frame) - gameFrame = frame - - if frame % REZ_SPEED_UPDATE_INTERVAL == 0 then - updateRezSpeed() - end - - local corpsesToCheck = corpseCheckFrames[frame] - if corpsesToCheck then - for i = 1, #corpsesToCheck do - local featureID = corpsesToCheck[i] - local corpseData = corpsesData[featureID] - local featureX, featureY, featureZ - if corpseData then - featureX, featureY, featureZ = spGetFeaturePosition(featureID) - end - if not featureX then --feature is gone - corpsesData[featureID] = nil - else --feature is still there - local featureDefData = zombieCorpseDefs[corpseData.featureDefID] - if corpseData.tamperedFrame then - resetSpawn(featureID, corpseData, featureDefData) - else - local healthReductionRatio = calculateHealthRatio(featureID) - spawnZombies( - featureID, - featureDefData.unitDefID, - healthReductionRatio, - featureX, - featureY, - featureZ, - corpseData.wasZombie, - corpseData.pastXp - ) - end - end - end - corpseCheckFrames[frame] = nil - end - - if frame % ZOMBIE_CHECK_INTERVAL == 0 then - spAddTeamResource(gaiaTeamID, "metal", 1000000) - spAddTeamResource(gaiaTeamID, "energy", 1000000) - for unitID, timeoutFrame in pairs(wereZombies) do - if timeoutFrame < frame then - wereZombies[unitID] = nil - end - end - for unitID, xpData in pairs(pendingUnitXp) do - if xpData.timeout < frame then - pendingUnitXp[unitID] = nil - end - end - for featureID, featureData in pairs(corpsesData) do - if featureData.spawnFrame - frame < WARNING_TIME then - local featureX, featureY, featureZ = spGetFeaturePosition(featureID) - if not featureX then --doesn't exist anymore - corpsesData[featureID] = nil - elseif not featureData.tamperedFrame then - warningCEG(featureID, featureX, featureY, featureZ) - end - end - end - end - - if frame % ZOMBIE_ORDER_CHECK_INTERVAL == 1 then - for unitID, data in pairs(zombieWatch) do - local unitDefID = data.unitDefID - if spGetUnitIsDead(unitID) or not spValidUnitID(unitID) then - zombieWatch[unitID] = nil - elseif ordersEnabled then - local currentCommand = spGetUnitCurrentCommand(unitID) - local refreshOrders = currentCommand ~= CMD_FIGHT - and currentCommand ~= CMD_CAPTURE - and random() <= REFRESH_ORDERS_CHANCE - - if - refreshOrders - or (currentCommand ~= CMD_FIGHT and currentCommand ~= CMD_GUARD and currentCommand ~= CMD_CAPTURE) - then - local closestKnownEnemy - if capturingUnits[unitDefID] or unitDefWithWeaponRanges[unitDefID] then - closestKnownEnemy = spGetUnitNearestEnemy(unitID, ENEMY_ATTACK_DISTANCE, true) - end - - local shouldUpdateOrders = refreshOrders or closestKnownEnemy - if not shouldUpdateOrders then - local queueSize = spGetUnitCommandCount(unitID) - shouldUpdateOrders = not queueSize or queueSize < ZOMBIE_MAX_ORDERS_ISSUED - end - - if shouldUpdateOrders then - clearUnitOrders(unitID) - updateOrders(unitID, unitDefID, closestKnownEnemy, currentCommand) - end - end - end - end - end - - if frame % STUCK_CHECK_INTERVAL == 0 then - for unitID, data in pairs(zombieWatch) do - if spGetUnitIsDead(unitID) or not spValidUnitID(unitID) then - zombieWatch[unitID] = nil - else - local x, y, z = spGetUnitPosition(unitID) - if x and y and z then - if distance2dSquared(x, z, data.lastX, data.lastZ) < STUCK_DISTANCE then - local BLOCK_CHECK_STEP = 15 - local forwardDirection = getActualForwardsYaw(unitID) - local unitX, unitY, unitZ = x, y, z - local test1X = unitX + BLOCK_CHECK_STEP * cos(forwardDirection) - local test1Z = unitZ + BLOCK_CHECK_STEP * sin(forwardDirection) - local test2X = unitX - BLOCK_CHECK_STEP * cos(forwardDirection) - local test2Z = unitZ - BLOCK_CHECK_STEP * sin(forwardDirection) - local unitDefID = data.unitDefID - if - not spTestMoveOrder(unitDefID, test1X, spGetGroundHeight(test1X, test1Z), test1Z) - or not spTestMoveOrder(unitDefID, test2X, spGetGroundHeight(test2X, test2Z), test2Z) - then - clearUnitOrders(unitID) - data.isStuck = true - local alreadyPresent = false - for _, zone in ipairs(data.noGoZones) do - local dx = x - zone.x - local dz = z - zone.z - if (dx * dx + dz * dz) < NOGO_ZONE_RADIUS_SQ then - alreadyPresent = true - break - end - end - if not alreadyPresent then - if #data.noGoZones > MAX_NOGO_ZONES then - table.remove(data.noGoZones, 1) - end - table.insert(data.noGoZones, { x = x, y = y, z = z }) - end - end - else - data.isStuck = false - end - data.lastX = x - data.lastY = y - data.lastZ = z - end - end - end - end -end - -local function queueCorpseForSpawning(featureID, override, wasZombie, pastXp) - if not override and not autoSpawningEnabled then - return - end - - local featureDefID = spGetFeatureDefID(featureID) - local corpseDefData = zombieCorpseDefs[featureDefID] - if not corpseDefData or corpseDefData.neverRespawn then - return - end - - wasZombie = wasZombie or wasZombieCorpse(featureID) - if pastXp == nil then - local existingCorpseData = corpsesData[featureID] - if existingCorpseData and existingCorpseData.pastXp ~= nil then - pastXp = existingCorpseData.pastXp - else - pastXp = spGetFeatureRulesParam(featureID, "previous_xp") or 0 - end - end - - local spawnDelayFrames = corpseDefData.spawnDelayFrames - if spawnDelayFrames == 0 then - local featureX, featureY, featureZ = spGetFeaturePosition(featureID) - if featureX then - local healthReductionRatio = calculateHealthRatio(featureID) - spawnZombies( - featureID, - corpseDefData.unitDefID, - healthReductionRatio, - featureX, - featureY, - featureZ, - wasZombie, - pastXp - ) - end - return - end - - local spawnFrame = gameFrame + spawnDelayFrames - corpsesData[featureID] = { - featureDefID = featureDefID, - spawnDelayFrames = spawnDelayFrames, - creationFrame = gameFrame, - spawnFrame = spawnFrame, - wasZombie = wasZombie, - pastXp = pastXp, - } - setCorpseRezRulesParam(featureID, spawnFrame) - corpseCheckFrames[spawnFrame] = corpseCheckFrames[spawnFrame] or {} - corpseCheckFrames[spawnFrame][#corpseCheckFrames[spawnFrame] + 1] = featureID -end - -function gadget:FeatureCreated(featureID, allyTeam, sourceID) - local wasZombie = false - local pastXp = 0 - if sourceID and wereZombies[sourceID] then - wasZombie = true - wereZombies[sourceID] = nil - spSetFeatureRulesParam(featureID, WAS_ZOMBIE_PARAM, 1, PUBLIC_RULES_PARAM_ACCESS) - end - if sourceID and pendingUnitXp[sourceID] then - pastXp = pendingUnitXp[sourceID].xp - pendingUnitXp[sourceID] = nil - else - pastXp = spGetFeatureRulesParam(featureID, "previous_xp") or 0 - end - queueCorpseForSpawning(featureID, false, wasZombie, pastXp) -end - -function gadget:FeatureDestroyed(featureID, allyTeam) - clearCorpseRezRulesParam(featureID) - corpsesData[featureID] = nil -end - -function gadget:UnitCreated(unitID, unitDefID, unitTeam, builderID) - if unitTeam == gaiaTeamID and builderID and isZombie(builderID) then - zombiesBeingBuilt[unitID] = true - spSetUnitRulesParam(unitID, "resurrected", 0, { inlos = true }) - end -end - -function gadget:UnitFinished(unitID, unitDefID, unitTeam) - if unitTeam == gaiaTeamID then - if isZombie(unitID) then - initializeZombie(unitID, unitDefID) - elseif zombiesBeingBuilt[unitID] then - zombiesBeingBuilt[unitID] = nil - setZombie(unitID) - end - end -end - -function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) - if zombieHeapDefs[unitDefID] then - pendingUnitXp[unitID] = - { xp = spGetUnitExperience(unitID) or 0, timeout = gameFrame + WAS_ZOMBIE_TIMEOUT_FRAMES } - end - if isZombie(unitID) and currentZombieConfig.zombieCorpses and not heapingZombies[unitID] then - wereZombies[unitID] = gameFrame + WAS_ZOMBIE_TIMEOUT_FRAMES - end - heapingZombies[unitID] = nil - pendingZombieCaptures[unitID] = nil - flyingUnits[unitID] = nil - zombieWatch[unitID] = nil - zombiesBeingBuilt[unitID] = nil -end - -function gadget:AllowUnitCaptureStep(builderID, builderTeam, unitID, unitDefID, part) - if isZombie(builderID) then - pendingZombieCaptures[unitID] = true - end - return true -end - -function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) - if pendingZombieCaptures[unitID] then - pendingZombieCaptures[unitID] = nil - if not isZombie(unitID) then - setZombie(unitID) - end - end -end - -local function isUnitInLava(unitID) - local _, unitY = spGetUnitBasePosition(unitID) - if not unitY then - return false - end - - local lavaLevel = spGetGameRulesParam("lavaLevel") - if lavaLevel ~= nil and unitY < lavaLevel then - return true - end - - local waterTypeOverlay = GG.WaterTypeOverlay - if waterTypeOverlay and waterTypeOverlay.isActive() and waterTypeOverlay.getActiveType() == "lava" then - local overlayLevel = waterTypeOverlay.getLevel() - if overlayLevel and unitY < overlayLevel then - return true - end - end - - return false -end - -local function shouldAlwaysLeaveHeap(unitID, weaponDefID, attackerID) - if weaponDefID == WATER_DAMAGE_DEF_ID then - return true - end - if not isUnitInLava(unitID) then - return false - end - if not weaponDefID or weaponDefID < 0 then - return true - end - if not attackerID or attackerID < 0 or not spValidUnitID(attackerID) then - return true - end - return false -end - -local function leaveZombieHeap(unitID, unitDefID, attackerID) - local unitX, unitY, unitZ = spGetUnitPosition(unitID) - if not unitX then - return - end - local defData = zombieHeapDefs[unitDefID] - if not defData then - return - end - heapingZombies[unitID] = true - spDestroyUnit(unitID, false, true, attackerID) - spSpawnExplosion(unitX, unitY, unitZ, 0, 0, 0, { weaponDef = defData.explosionDefID, owner = unitID }) - if defData.heapDefID then - spCreateFeature(defData.heapDefID, unitX, unitY, unitZ) - end -end - -function gadget:UnitPreDamaged(unitID, unitDefID, unitTeam, damage, paralyzer, weaponDefID, projectileID, attackerID) - if not isZombie(unitID) then - return - end - local leaveHeap = not currentZombieConfig.zombieCorpses or shouldAlwaysLeaveHeap(unitID, weaponDefID, attackerID) - if not leaveHeap then - return - end - local health = spGetUnitHealth(unitID) - if damage >= health then - leaveZombieHeap(unitID, unitDefID, attackerID) - end -end - ----Immediately raises zombies from a corpse feature. Only acts while in idle mode. ----@param featureID FeatureID ----@return boolean spawned `false` when not in idle mode, or the feature is not a zombie corpse. -local function createZombieFromFeature(featureID) - if isIdleMode then - local featureDefID = spGetFeatureDefID(featureID) - if zombieCorpseDefs[featureDefID] then - local featureX, featureY, featureZ = spGetFeaturePosition(featureID) - if featureX then - local featureDefData = zombieCorpseDefs[featureDefID] - local healthReductionRatio = calculateHealthRatio(featureID) - local corpseData = corpsesData[featureID] - local wasZombie = wasZombieCorpse(featureID, corpseData) - local pastXp = corpseData and corpseData.pastXp - spawnZombies( - featureID, - featureDefData.unitDefID, - healthReductionRatio, - featureX, - featureY, - featureZ, - wasZombie, - pastXp - ) - return true - end - end - end - return false -end - ----Queues every corpse currently on the map to raise zombies. -local function queueAllCorpsesForSpawning() - local features = Spring.GetAllFeatures() - for _, featureID in ipairs(features) do - queueCorpseForSpawning(featureID, true) - end -end - ----Switches all zombies between return-fire with no auto-orders and normal aggression. ----@param enabled boolean `true` to pacify, `false` to restore normal behavior. -local function pacifyZombies(enabled) - local fireState - if enabled then - fireState = FIRE_STATE_RETURN_FIRE - ordersEnabled = false - clearAllOrders() - else - fireState = FIRE_STATE_FIRE_AT_ALL - ordersEnabled = true - end - for zombieID, _ in pairs(zombieWatch) do - if spValidUnitID(zombieID) then - Spring.GiveOrderToUnit(zombieID, CMD.FIRE_STATE, fireState) - end - end -end - ----Stops or resumes the automatic orders given to zombies, without changing fire state. ----@param enabled boolean `true` to suspend auto-orders, `false` to resume them. -local function suspendAutoOrders(enabled) - if enabled then - ordersEnabled = false - clearAllOrders() - else - ordersEnabled = true - end -end - -local function fightNearTargets(targetUnits) - if not targetUnits or #targetUnits == 0 then - return false - end - - for zombieID, _ in pairs(zombieWatch) do - if spValidUnitID(zombieID) then - local randomTarget = targetUnits[random(1, #targetUnits)] - if spValidUnitID(randomTarget) then - local targetX, targetY, targetZ = spGetUnitPosition(randomTarget) - if targetX then - local angle = random() * tau - local offsetDistance = random(25, 500) - local fightX = targetX + cos(angle) * offsetDistance - local fightZ = targetZ + sin(angle) * offsetDistance - local fightY = spGetGroundHeight(fightX, fightZ) - - Spring.GiveOrderToUnit(zombieID, CMD.FIGHT, { fightX, fightY, fightZ }, {}) - end - end - end - end - - return true -end - ----Sends every zombie to fight the units of one team. ----@param teamID TeamID ----@return boolean ordered `false` when the team is dead or has no units. -local function aggroTeamID(teamID) - clearAllOrders() - - local isDead = select(3, Spring.GetTeamInfo(teamID)) - - if isDead or isDead == nil then - return false - end - - local targetUnits = Spring.GetTeamUnits(teamID) or {} - return fightNearTargets(targetUnits) -end - ----Sends every zombie to fight the units of every team in an allyteam. ----@param allyID AllyTeamID ----@return boolean ordered `false` when the allyteam has no teams or no units. -local function aggroAllyID(allyID) - clearAllOrders() - - local targetUnits = {} - local allyTeams = Spring.GetTeamList(allyID) - - if not allyTeams then - return false - end - - for _, teamID in pairs(allyTeams) do - local unitsToAdd = Spring.GetTeamUnits(teamID) - for _, unitID in pairs(unitsToAdd) do - table.insert(targetUnits, unitID) - end - end - - return fightNearTargets(targetUnits) -end - ----Kills every tracked zombie with environmental damage. -local function killAllZombies() - for zombieID, zombieData in pairs(zombieWatch) do - if spValidUnitID(zombieID) and not Spring.GetUnitIsDead(zombieID) then - local currentHealth = spGetUnitHealth(zombieID) - if currentHealth and currentHealth > 0 then - Spring.AddUnitDamage(zombieID, currentHealth, 0, NULL_ATTACKER, ENVIRONMENTAL_DAMAGE_ID) - end - end - end -end - ----Enables or disables raising zombies from corpses automatically. ----Enabling also queues every corpse already on the map. ----@param enabled boolean -local function setAutoSpawning(enabled) - autoSpawningEnabled = enabled - if enabled then - queueAllCorpsesForSpawning() - end -end - ----Drops every queued corpse spawn without affecting zombies already raised. -local function clearAllZombieSpawns() - for featureID in pairs(corpsesData) do - clearCorpseRezRulesParam(featureID) - end - corpsesData = {} - corpseCheckFrames = {} -end - -local function isAuthorized(playerID) - if Spring.IsCheatingEnabled() then - return true - end - local playername = Spring.GetPlayerInfo(playerID) - local accountID = BAR.Utilities.GetAccountID(playerID) - if - ( - _G - and _G.permissions.devhelpers - and (_G.permissions.devhelpers[accountID] or (playername and _G.permissions.devhelpers[playername])) - ) - or ( - SYNCED - and SYNCED.permissions.devhelpers - and (SYNCED.permissions.devhelpers[accountID] or (playername and SYNCED.permissions.devhelpers[playername])) - ) - then - return true - end - return false -end - ----Turns each of the given units into a zombie. ----@param unitIDs UnitID[]? ----@return integer converted Number of units that were valid and converted. -local function convertUnitsToZombies(unitIDs) - if not unitIDs or #unitIDs == 0 then - return 0 - end - - local convertedCount = 0 - for _, unitID in ipairs(unitIDs) do - if spValidUnitID(unitID) then - setZombie(unitID) - convertedCount = convertedCount + 1 - end - end - - return convertedCount -end - ----Turns every Gaia-owned unit that is not already a zombie into one. ----@return integer converted -local function setAllGaiaToZombies() - local allUnits = Spring.GetAllUnits() - local convertedCount = 0 - - for _, unitID in ipairs(allUnits) do - local unitTeam = Spring.GetUnitTeam(unitID) - if unitTeam == gaiaTeamID and not isZombie(unitID) then - setZombie(unitID) - convertedCount = convertedCount + 1 - end - end - - return convertedCount -end - -local function commandSetAllGaiaToZombies(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - local convertedCount = setAllGaiaToZombies() - Spring.SendMessageToPlayer(playerID, "Set " .. convertedCount .. " Gaia units as zombies") -end - -local function commandQueueAllCorpsesForReanimation(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - queueAllCorpsesForSpawning() - Spring.SendMessageToPlayer(playerID, "Queued all corpses for spawning") -end - -local function commandToggleAutoReanimation(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - if #words == 0 then - Spring.SendMessageToPlayer(playerID, "Usage: /luarules zombieautospawn 0|1") - return - end - - local enabled = tonumber(words[1]) - if enabled == nil or (enabled ~= 0 and enabled ~= 1) then - Spring.SendMessageToPlayer(playerID, "Invalid value. Use 0 to disable or 1 to enable") - return - end - - setAutoSpawning(enabled == 1) - Spring.SendMessageToPlayer(playerID, "Auto spawning " .. (enabled == 1 and "enabled" or "disabled")) -end - -local function commandPacifyZombies(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - if #words == 0 then - Spring.SendMessageToPlayer(playerID, "Usage: /luarules zombiepacify 0|1") - return - end - - local enabled = tonumber(words[1]) - if enabled == nil or (enabled ~= 0 and enabled ~= 1) then - Spring.SendMessageToPlayer(playerID, "Invalid value. Use 0 to disable or 1 to enable") - return - end - - pacifyZombies(enabled == 1) - Spring.SendMessageToPlayer(playerID, "Zombies " .. (enabled == 1 and "pacified" or "unpacified")) -end - -local function commandSuspendAutoOrders(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - if #words == 0 then - Spring.SendMessageToPlayer(playerID, "Usage: /luarules zombiesuspendorders 0|1") - return - end - - local enabled = tonumber(words[1]) - if enabled == nil or (enabled ~= 0 and enabled ~= 1) then - Spring.SendMessageToPlayer(playerID, "Invalid value. Use 0 to disable or 1 to enable") - return - end - - suspendAutoOrders(enabled == 1) - Spring.SendMessageToPlayer(playerID, "Zombie auto-orders " .. (enabled == 1 and "suspended" or "resumed")) -end - -local function commandAggroZombiesToTeam(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - if #words == 0 then - Spring.SendMessageToPlayer(playerID, "Usage: /luarules zombieaggroteam ") - return - end - - local targetTeamID = tonumber(words[1]) - if not targetTeamID or targetTeamID < 0 then - Spring.SendMessageToPlayer(playerID, "Invalid team ID") - return - end - - local success = aggroTeamID(targetTeamID) - if success then - Spring.SendMessageToPlayer(playerID, "Zombies aggroed to team " .. targetTeamID) - else - Spring.SendMessageToPlayer(playerID, "Team " .. targetTeamID .. " not found or has no units") - end -end - -local function commandAggroZombiesToAlly(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - if #words == 0 then - Spring.SendMessageToPlayer(playerID, "Usage: /luarules zombieaggroally ") - return - end - - local targetAllyID = tonumber(words[1]) - if not targetAllyID or targetAllyID < 0 then - Spring.SendMessageToPlayer(playerID, "Invalid ally ID") - return - end - - local success = aggroAllyID(targetAllyID) - if success then - Spring.SendMessageToPlayer(playerID, "Zombies aggroed to ally team " .. targetAllyID) - else - Spring.SendMessageToPlayer(playerID, "Ally team " .. targetAllyID .. " not found or has no units") - end -end - -local function commandKillAllZombies(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - killAllZombies() - Spring.SendMessageToPlayer(playerID, "Killed all zombies") -end - -local function commandClearAllZombieOrders(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - clearAllOrders() - Spring.SendMessageToPlayer(playerID, "Cleared zombie orders") -end - -local function commandClearZombieSpawns(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - clearAllZombieSpawns() - Spring.SendMessageToPlayer(playerID, "Cleared all queued zombie spawns") -end - ----Switches the zombie difficulty preset. ----@param mode ZombieMode ----@return boolean applied `false` when `mode` is not a known preset. -local function setZombieMode(mode) - ---@diagnostic disable-next-line: unnecessary-if - if mode ~= "normal" and mode ~= "hard" and mode ~= "nightmare" and mode ~= "akumu" then - return false - end - - currentZombieMode = mode - applyZombieModeSettings(mode) - return true -end - -local function commandSetZombieMode(_, line, words, playerID) - if not isAuthorized(playerID) then - Spring.SendMessageToPlayer(playerID, UNAUTHORIZED_TEXT) - return - end - - if #words == 0 then - Spring.SendMessageToPlayer(playerID, "Usage: /luarules zombiemode normal|hard|nightmare|akumu") - return - end - - local mode = string.lower(words[1]) - if mode ~= "normal" and mode ~= "hard" and mode ~= "nightmare" and mode ~= "akumu" then - Spring.SendMessageToPlayer(playerID, "Invalid mode. Use: normal, hard, nightmare, or akumu") - return - end - - local success = setZombieMode(mode) - if success then - Spring.SendMessageToPlayer(playerID, "Zombie mode set to " .. mode) - else - Spring.SendMessageToPlayer(playerID, "Failed to set zombie mode to " .. mode) - end -end - -function gadget:Initialize() - local modOptionEnabled = modOptions.zombies ~= "disabled" - isIdleMode = GG.Zombies and GG.Zombies.IdleMode == true or false - - if not modOptionEnabled and not isIdleMode then - gadgetHandler:RemoveGadget(gadget) - return - end - - local initialMode = modOptions.zombies --[[@as ZombieMode?]] or "normal" - applyZombieModeSettings(initialMode) - - autoSpawningEnabled = modOptionEnabled and not isIdleMode - - gameFrame = spGetGameFrame() - - local units = spGetAllUnits() - for _, unitID in ipairs(units) do - if isZombie(unitID) then - setZombie(unitID) - end - end - - if not isIdleMode then - local features = spGetAllFeatures() - for _, featureID in ipairs(features) do - gadget:FeatureCreated(featureID, gaiaTeamID) - end - end - - GG.Zombies = {} - GG.Zombies.SetZombie = setZombie - GG.Zombies.ConvertUnitsToZombies = convertUnitsToZombies - GG.Zombies.SetAllGaiaToZombies = setAllGaiaToZombies - GG.Zombies.CreateZombieFromFeature = createZombieFromFeature - GG.Zombies.QueueAllCorpsesForSpawning = queueAllCorpsesForSpawning - GG.Zombies.SetAutoSpawning = setAutoSpawning - GG.Zombies.ClearAllZombieSpawns = clearAllZombieSpawns - GG.Zombies.PacifyZombies = pacifyZombies - GG.Zombies.SuspendAutoOrders = suspendAutoOrders - GG.Zombies.AggroTeamID = aggroTeamID - GG.Zombies.AggroAllyID = aggroAllyID - GG.Zombies.KillAllZombies = killAllZombies - GG.Zombies.ClearAllOrders = clearAllOrders - GG.Zombies.SetZombieMode = setZombieMode - ---@return ZombieMode mode The active difficulty preset. - GG.Zombies.GetZombieMode = function() - return currentZombieMode - end - - gadgetHandler:AddChatAction("zombiesetallgaia", commandSetAllGaiaToZombies, "Set all Gaia units as zombies") - gadgetHandler:AddChatAction( - "zombiequeueallcorpses", - commandQueueAllCorpsesForReanimation, - "Queue all corpses for spawning" - ) - gadgetHandler:AddChatAction("zombieautospawn", commandToggleAutoReanimation, "Enable/disable auto spawning") - gadgetHandler:AddChatAction("zombieclearspawns", commandClearZombieSpawns, "Clear all queued zombie spawns") - gadgetHandler:AddChatAction("zombiepacify", commandPacifyZombies, "Pacify/unpacify zombies") - gadgetHandler:AddChatAction("zombiesuspendorders", commandSuspendAutoOrders, "Suspend/resume zombie auto-orders") - gadgetHandler:AddChatAction("zombieaggroteam", commandAggroZombiesToTeam, "Make zombies aggro to specific team") - gadgetHandler:AddChatAction("zombieaggroally", commandAggroZombiesToAlly, "Make zombies aggro to entire ally team") - gadgetHandler:AddChatAction("zombiekillall", commandKillAllZombies, "Kill all zombies") - gadgetHandler:AddChatAction("zombieclearallorders", commandClearAllZombieOrders, "Clear allzombie orders") - gadgetHandler:AddChatAction("zombiemode", commandSetZombieMode, "Set zombie mode (normal/hard/nightmare/akumu)") -end - -function gadget:Shutdown() - gadgetHandler:RemoveChatAction("zombiesetallgaia") - gadgetHandler:RemoveChatAction("zombiequeueallcorpses") - gadgetHandler:RemoveChatAction("zombieautospawn") - gadgetHandler:RemoveChatAction("zombieclearspawns") - gadgetHandler:RemoveChatAction("zombiepacify") - gadgetHandler:RemoveChatAction("zombiesuspendorders") - gadgetHandler:RemoveChatAction("zombieaggroteam") - gadgetHandler:RemoveChatAction("zombieaggroally") - gadgetHandler:RemoveChatAction("zombiekillall") - gadgetHandler:RemoveChatAction("zombieclearallorders") - gadgetHandler:RemoveChatAction("zombiemode") -end - -function gadget:GameStart() - setGaiaStorage() -end diff --git a/luaui/Include/AtlasOnDemand.lua b/luaui/Include/AtlasOnDemand.lua index f0527e9e701..3cc75fac365 100644 --- a/luaui/Include/AtlasOnDemand.lua +++ b/luaui/Include/AtlasOnDemand.lua @@ -213,7 +213,6 @@ local function MakeAtlasOnDemand(config) end end local GL_RGBA = 0x1908 - local GL_TEXTURE_2D_MULTISAMPLE = 0x9100 AtlasOnDemand.texProps = config.texProps or { min_filter = GL.LINEAR, @@ -222,9 +221,6 @@ local function MakeAtlasOnDemand(config) wrap_s = GL.CLAMP, wrap_t = GL.CLAMP, format = GL_RGBA, - - --target = GL_TEXTURE_2D_MULTISAMPLE, - --samples = 1, } AtlasOnDemand.texProps.fbo = true -- need so that we can RenderToTexture it AtlasOnDemand.textureID = gl.CreateTexture(config.sizex, config.sizey, AtlasOnDemand.texProps) @@ -448,7 +444,7 @@ local function MakeAtlasOnDemand(config) return { x = 0, X = 1, y = 0, Y = 1, w = 1, h = 1, id = text } end local textparams - if not params then -- render with default fot + if not params then -- render with default font if self.uvcoords[text] then return self.uvcoords[text] else @@ -624,8 +620,6 @@ local function MakeAtlasOnDemand(config) gl.Blending(task.srcmode or GL.ONE, task.dstmode or GL.ZERO) local drawmodeTexName = self.drawmode .. task.id gl.Texture(0, drawmodeTexName) - local p = self.padx * 0 - local o = self.padx * 0 local w = (task.w / self.xsize) * 2 local h = (task.h / self.ysize) * 2 local x = (task.x / self.xsize - 0.5) * 2 @@ -764,8 +758,6 @@ local function MakeAtlasOnDemand(config) Spring.Echo("AtlasOnDemand:TextRect cannot find id", id) return end - local ypos = y - local xpos = x if align then if align == "c" then gl.TexRect( diff --git a/luaui/Include/DrawPrimitiveAtUnit.lua b/luaui/Include/DrawPrimitiveAtUnit.lua index 04c7fffca77..e8a3c04690d 100644 --- a/luaui/Include/DrawPrimitiveAtUnit.lua +++ b/luaui/Include/DrawPrimitiveAtUnit.lua @@ -3,8 +3,6 @@ -- License: GNU GPL V2 ------------------------------------------------- -local DrawPrimitiveAtUnit = {} - local shaderConfig = { TRANSPARENCY = 0.2, -- transparency of the stuff drawn HEIGHTOFFSET = 1, -- Additional height added to everything diff --git a/luaui/Include/blueprint_substitution/definitions.lua b/luaui/Include/blueprint_substitution/definitions.lua index 54f9d83ee05..924eada6196 100644 --- a/luaui/Include/blueprint_substitution/definitions.lua +++ b/luaui/Include/blueprint_substitution/definitions.lua @@ -62,10 +62,10 @@ function DefinitionsModule.defineUnitCategories() "ADVANCED_EXPLOITER", { [SIDES.ARMADA] = "armmoho", [SIDES.CORTEX] = "cormexp", [SIDES.LEGION] = "legmohocon" } ) - DefCat("UW_EXTRACTOR", { [SIDES.ARMADA] = "armuwmex", [SIDES.CORTEX] = "coruwmex", [SIDES.LEGION] = "leguwmex" }) + DefCat("UW_EXTRACTOR", { [SIDES.ARMADA] = "armuwmex", [SIDES.CORTEX] = "coruwmex" }) DefCat( "ADVANCED_UW_EXTRACTOR", - { [SIDES.ARMADA] = "armuwmme", [SIDES.CORTEX] = "coruwmme", [SIDES.LEGION] = "leguwmme" } + { [SIDES.ARMADA] = "armuwmme", [SIDES.CORTEX] = "coruwmme", [SIDES.LEGION] = "leganavalmex" } ) DefCat("METAL_STORAGE", { [SIDES.ARMADA] = "armmstor", [SIDES.CORTEX] = "cormstor", [SIDES.LEGION] = "legmstor" }) DefCat( @@ -78,7 +78,7 @@ function DefinitionsModule.defineUnitCategories() ) DefCat( "UW_ADVANCED_METAL_STORAGE", - { [SIDES.ARMADA] = "armuwadvms", [SIDES.CORTEX] = "coruwadvms", [SIDES.LEGION] = "coruwadvms" } + { [SIDES.ARMADA] = "armuwadvms", [SIDES.CORTEX] = "coruwadvms", [SIDES.LEGION] = "legamstor" } ) -- Energy buildings @@ -100,19 +100,28 @@ function DefinitionsModule.defineUnitCategories() DefCat("TIDAL", { [SIDES.ARMADA] = "armtide", [SIDES.CORTEX] = "cortide", [SIDES.LEGION] = "legtide" }) DefCat("FUSION", { [SIDES.ARMADA] = "armfus", [SIDES.CORTEX] = "corfus", [SIDES.LEGION] = "legfus" }) DefCat("ADVANCED_FUSION", { [SIDES.ARMADA] = "armafus", [SIDES.CORTEX] = "corafus", [SIDES.LEGION] = "legafus" }) - DefCat("UW_FUSION", { [SIDES.ARMADA] = "armuwfus", [SIDES.CORTEX] = "coruwfus", [SIDES.LEGION] = "leguwfus" }) + DefCat( + "UW_FUSION", + { [SIDES.ARMADA] = "armuwfus", [SIDES.CORTEX] = "coruwfus", [SIDES.LEGION] = "leganavalfusion" } + ) DefCat("GEOTHERMAL", { [SIDES.ARMADA] = "armageo", [SIDES.CORTEX] = "corbhmth", [SIDES.LEGION] = "leggeo" }) - DefCat("ADVANCED_GEO", { [SIDES.ARMADA] = "armgmm", [SIDES.CORTEX] = "corgmm", [SIDES.LEGION] = "leggmm" }) - DefCat("UW_ADV_GEO", { [SIDES.ARMADA] = "armuwageo", [SIDES.CORTEX] = "coruwageo", [SIDES.LEGION] = "leguwageo" }) + DefCat("ADVANCED_GEO", { [SIDES.ARMADA] = "armgmm", [SIDES.CORTEX] = "corgmm" }) + DefCat( + "UW_ADV_GEO", + { [SIDES.ARMADA] = "armuwageo", [SIDES.CORTEX] = "coruwageo", [SIDES.LEGION] = "leganavaladvgeo" } + ) DefCat("ENERGY_STORAGE", { [SIDES.ARMADA] = "armestor", [SIDES.CORTEX] = "corestor", [SIDES.LEGION] = "legestor" }) DefCat( "ADVANCED_ENERGY_STORAGE", { [SIDES.ARMADA] = "armuwadves", [SIDES.CORTEX] = "coradvestore", [SIDES.LEGION] = "legadvestore" } ) - DefCat("UW_ENERGY_STORAGE", { [SIDES.ARMADA] = "armuwes", [SIDES.CORTEX] = "coruwes", [SIDES.LEGION] = "leguwes" }) + DefCat( + "UW_ENERGY_STORAGE", + { [SIDES.ARMADA] = "armuwes", [SIDES.CORTEX] = "coruwes", [SIDES.LEGION] = "leguwestore" } + ) DefCat( "UW_ADVANCED_ENERGY_STORAGE", - { [SIDES.ARMADA] = "armuwadves", [SIDES.CORTEX] = "coruwadves", [SIDES.LEGION] = "coruwadves" } + { [SIDES.ARMADA] = "armuwadves", [SIDES.CORTEX] = "coruwadves", [SIDES.LEGION] = "legadvestore" } ) -- Factory buildings @@ -123,8 +132,11 @@ function DefinitionsModule.defineUnitCategories() "ADVANCED_AIRCRAFT_PLANT", { [SIDES.ARMADA] = "armaap", [SIDES.CORTEX] = "coraap", [SIDES.LEGION] = "legaap" } ) - DefCat("SHIPYARD", { [SIDES.ARMADA] = "armsy", [SIDES.CORTEX] = "corsy", [SIDES.LEGION] = "corsy" }) - DefCat("ADVANCED_SHIPYARD", { [SIDES.ARMADA] = "armasy", [SIDES.CORTEX] = "corasy", [SIDES.LEGION] = "legasy" }) + DefCat("SHIPYARD", { [SIDES.ARMADA] = "armsy", [SIDES.CORTEX] = "corsy", [SIDES.LEGION] = "legsy" }) + DefCat( + "ADVANCED_SHIPYARD", + { [SIDES.ARMADA] = "armasy", [SIDES.CORTEX] = "corasy", [SIDES.LEGION] = "legadvshipyard" } + ) DefCat("HOVER_PLATFORM", { [SIDES.ARMADA] = "armhp", [SIDES.CORTEX] = "corhp", [SIDES.LEGION] = "leghp" }) DefCat( "AMPHIBIOUS_COMPLEX", @@ -161,7 +173,7 @@ function DefinitionsModule.defineUnitCategories() ) DefCat( "FLOATING_HEAVY_LASER", - { [SIDES.ARMADA] = "armfhlt", [SIDES.CORTEX] = "corfhlt", [SIDES.LEGION] = "legfhlt" } + { [SIDES.ARMADA] = "armfhlt", [SIDES.CORTEX] = "corfhlt", [SIDES.LEGION] = "legfhive" } ) DefCat("FLOATING_MISSILE", { [SIDES.ARMADA] = "armfrt", [SIDES.CORTEX] = "corfrt", [SIDES.LEGION] = "legfrl" }) DefCat( @@ -169,8 +181,11 @@ function DefinitionsModule.defineUnitCategories() { [SIDES.ARMADA] = "armmercury", [SIDES.CORTEX] = "corscreamer", [SIDES.LEGION] = "leglraa" } ) DefCat("TORPEDO", { [SIDES.ARMADA] = "armdl", [SIDES.CORTEX] = "cordl", [SIDES.LEGION] = "legctl" }) - DefCat("ADV_TORPEDO", { [SIDES.ARMADA] = "armatl", [SIDES.CORTEX] = "coratl", [SIDES.LEGION] = "legatl" }) - DefCat("OFFSHORE_TORPEDO", { [SIDES.ARMADA] = "armptl", [SIDES.CORTEX] = "corptl", [SIDES.LEGION] = "legptl" }) + DefCat( + "ADV_TORPEDO", + { [SIDES.ARMADA] = "armatl", [SIDES.CORTEX] = "coratl", [SIDES.LEGION] = "leganavaltorpturret" } + ) + DefCat("OFFSHORE_TORPEDO", { [SIDES.ARMADA] = "armptl", [SIDES.CORTEX] = "corptl" }) DefCat("ARTILLERY", { [SIDES.ARMADA] = "armguard", [SIDES.CORTEX] = "corpun", [SIDES.LEGION] = "legcluster" }) DefCat( "LONG_RANGE_PLASMA_CANNON", @@ -207,8 +222,11 @@ function DefinitionsModule.defineUnitCategories() DefCat("ADV_RADAR", { [SIDES.ARMADA] = "armarad", [SIDES.CORTEX] = "corarad", [SIDES.LEGION] = "legarad" }) DefCat("JAMMER", { [SIDES.ARMADA] = "armjamt", [SIDES.CORTEX] = "corjamt", [SIDES.LEGION] = "legjam" }) DefCat("ADVANCED_JAMMER", { [SIDES.ARMADA] = "armveil", [SIDES.CORTEX] = "corshroud", [SIDES.LEGION] = "legajam" }) - DefCat("SONAR", { [SIDES.ARMADA] = "armsonar", [SIDES.CORTEX] = "corsonar", [SIDES.LEGION] = "legsonar" }) - DefCat("ADV_SONAR", { [SIDES.ARMADA] = "armason", [SIDES.CORTEX] = "corason", [SIDES.LEGION] = "legason" }) + DefCat("SONAR", { [SIDES.ARMADA] = "armsonar", [SIDES.CORTEX] = "corsonar" }) + DefCat( + "ADV_SONAR", + { [SIDES.ARMADA] = "armason", [SIDES.CORTEX] = "corason", [SIDES.LEGION] = "leganavalsonarstation" } + ) DefCat("CAMERA", { [SIDES.ARMADA] = "armeyes", [SIDES.CORTEX] = "coreyes", [SIDES.LEGION] = "legeyes" }) DefCat("NUKE", { [SIDES.ARMADA] = "armsilo", [SIDES.CORTEX] = "corsilo", [SIDES.LEGION] = "legsilo" }) DefCat("ANTINUKE", { [SIDES.ARMADA] = "armamd", [SIDES.CORTEX] = "corfmd", [SIDES.LEGION] = "legabm" }) @@ -235,7 +253,7 @@ function DefinitionsModule.defineUnitCategories() DefCat("FLOATING_RADAR_PG", { [SIDES.ARMADA] = "armfrad", [SIDES.CORTEX] = "corfrad", [SIDES.LEGION] = "legfrad" }) DefCat( "FLOATING_CONVERTER_PG", - { [SIDES.ARMADA] = "armfmkr", [SIDES.CORTEX] = "corfmkr", [SIDES.LEGION] = "legfmkr" } + { [SIDES.ARMADA] = "armfmkr", [SIDES.CORTEX] = "corfmkr", [SIDES.LEGION] = "legfeconv" } ) DefCat( "FLOATING_DRAGONSTEETH_PG", diff --git a/luaui/Include/chat_emoji.lua b/luaui/Include/chat_emoji.lua index 9ef5dc011a5..88a51fad324 100644 --- a/luaui/Include/chat_emoji.lua +++ b/luaui/Include/chat_emoji.lua @@ -226,12 +226,52 @@ function ChatEmoji.HasEmojiAliasCandidate(text) return sfind(text, ":", firstColon + 1, true) ~= nil end +---@param line string +---@return string[] +local function colorSafeWords(line) + local words = {} + local count = 0 + local len = #line + local wordStart = 0 + local pos = 1 + while pos <= len do + local c = sbyte(line, pos) + if c == 255 then + -- Inline color code: 255 followed by three color bytes, kept whole so a wrap cannot split it. + -- The font eats all four bytes even when fewer than three follow, so do the same here + if wordStart == 0 then + wordStart = pos + end + pos = pos + 4 + elseif c == 32 or (c >= 9 and c <= 13) then + -- Whitespace, the same set as %s: space, tab, newline, vertical tab, form feed, carriage return + if wordStart > 0 then + count = count + 1 + words[count] = ssub(line, wordStart, pos - 1) + wordStart = 0 + end + pos = pos + 1 + else + if wordStart == 0 then + wordStart = pos + end + pos = pos + 1 + end + end + if wordStart > 0 then + count = count + 1 + words[count] = ssub(line, wordStart, len) + end + + return words +end + function ChatEmoji.WordWrapPlain(textLines, maxWidth, usedFont, fontSize) local lines = {} local lineCount = 0 for _, line in ipairs(textLines) do local linebuffer = "" - for word in line:gmatch("%S+") do + for _, word in ipairs(colorSafeWords(line)) do if linebuffer ~= "" and (usedFont:GetTextWidth(linebuffer .. " " .. word) * fontSize) > maxWidth then lineCount = lineCount + 1 lines[lineCount] = linebuffer @@ -426,7 +466,7 @@ function ChatEmoji.WordWrapRichText(text, maxWidth, fontSize, usedFont) local lineHasEmoji = likelyContainsEmoji(line) if not lineHasEmoji then - for word in line:gmatch("%S+") do + for _, word in ipairs(colorSafeWords(line)) do if linebuffer ~= "" and (usedFont:GetTextWidth(linebuffer .. " " .. word) * fontSize) > maxWidth then lineCount = lineCount + 1 lines[lineCount] = linebuffer @@ -439,7 +479,7 @@ function ChatEmoji.WordWrapRichText(text, maxWidth, fontSize, usedFont) lines[lineCount] = linebuffer end else - for word in line:gmatch("%S+") do + for _, word in ipairs(colorSafeWords(line)) do local wordWidth = emojiTextWidth(word, fontSize, usedFont) if linebuffer ~= "" and (linebufferWidth + spaceWidth + wordWidth) > maxWidth then lineCount = lineCount + 1 diff --git a/luaui/Include/keybind_config.lua b/luaui/Include/keybind_config.lua new file mode 100644 index 00000000000..bc7e33684a2 --- /dev/null +++ b/luaui/Include/keybind_config.lua @@ -0,0 +1,22 @@ +-- Reader for the shared keybind JSON configs in common/configs. +-- +-- A malformed file costs the caller its data, never the LuaUI session: Json.decode raises +-- on bad input, and an unguarded decode at include time takes the widget down with it. +-- Callers do their own shape checks; this only guarantees a table or nil. + +local Json = Json or VFS.Include("common/luaUtilities/json.lua") + +local M = {} + +function M.load(path) + local ok, decoded = pcall(Json.decode, VFS.LoadFile(path)) + if ok and type(decoded) == "table" then + return decoded + end + + Spring.Echo("[keybinds] Error: could not load " .. path) + + return nil +end + +return M diff --git a/luaui/Include/keybind_dropdown.lua b/luaui/Include/keybind_dropdown.lua new file mode 100644 index 00000000000..c9dcb65e7c0 --- /dev/null +++ b/luaui/Include/keybind_dropdown.lua @@ -0,0 +1,358 @@ +-- Select control for the keybind editor's preset picker. +-- Uses FlowUI's Selector visuals to match the Settings look. Shows the current +-- selection; onSelect(option, index) fires when a choice is picked. +-- +-- An option record may carry a `tag`, a short word drawn on a faint pill at the right of its +-- row, and of the closed control while it is the selection; and a `group`, where the open +-- list rules a line between two neighbours whose groups differ. Both are opt-in, so a list +-- of plain strings draws as it always has. + +local text = VFS.Include("luaui/Include/keybind_text.lua") + +local Dropdown = {} +Dropdown.__index = Dropdown + +local floor = math.floor + +local colorText = "\255\235\235\235" +-- Quieter than the name beside it: a tag qualifies the option rather than naming it. +local colorTag = "\255\175\175\175" +-- SelectHighlight defaults to 0.35 and the rest of the UI stays near it. At 1 the +-- overlay is opaque and swallows the option label under it. +local hoverOpacity = 0.25 +-- Lighter for the control itself than for a row of the open list: one says the cursor +-- is on it, the other says this is the option a click would take. +local controlHoverOpacity = 0.14 +local white = { 1, 1, 1 } +local listFill = { 0.09, 0.09, 0.09, 0.96 } +local tagFill = { 1, 1, 1, 0.08 } +-- Under the option the list was opened on. Fainter than the hover, so the two stay apart +-- when the cursor is on another row. +local selectedFill = { 1, 1, 1, 0.07 } +local ruleColor = { 1, 1, 1, 0.14 } + +-- Font is fetched per draw; it does not exist when this file is included. +local function getFont() + return WG["fonts"].getFont() +end + +-- Options may be plain strings or { label = ... } records. +local function optionLabel(opt) + if type(opt) == "table" then + return opt.label or tostring(opt.value) + end + + return tostring(opt) +end + +local function optionTag(opt) + return type(opt) == "table" and opt.tag or nil +end + +local function optionGroup(opt) + return type(opt) == "table" and opt.group or nil +end + +-- A select: closed it shows the selection, open it overlays its options. +function Dropdown.new(opts) + opts = opts or {} + + local self = setmetatable({}, Dropdown) + self.options = opts.options or {} + self.onSelect = opts.onSelect + self.selected = opts.selected or 1 + self.placeholder = opts.placeholder + -- An outline to draw the text with, for a panel that pins its own. The font is shared with + -- every other widget and keeps whatever outline was set on it last; without one this takes + -- that, as it always has. + self.outline = opts.outline + -- Shade the selected option in the open list: for a picker whose selection is always a + -- real choice, never a placeholder standing in for none. + self.markSelected = opts.markSelected + self.open = false + self.rect = { 0, 0, 0, 0 } + self.optRects = {} + self.fontSize = 14 + + return self +end + +-- Placement, plus the option rects the open list will use. +function Dropdown:setRect(x1, y1, x2, y2, fontSize) + self.rect = { x1, y1, x2, y2 } + self.fontSize = fontSize or (y2 - y1) * 0.5 + -- Tag pills are measured against the row height and the font size, both just set. + self.tagCache = nil + + local optH = floor(y2 - y1) + self.optRects = {} + for i = 1, #self.options do + self.optRects[i] = { x1 = x1, y1 = y1 - i * optH, x2 = x2, y2 = y1 - (i - 1) * optH } + end +end + +-- Swaps the options, closing the list and keeping the selection in range. +function Dropdown:setOptions(options) + -- Closed as well as rebuilt: a refresh while the list is down would otherwise leave it + -- open over a different set of options than the one it was opened on. + self.open = false + self.options = options or {} + if self.selected > #self.options then + self.selected = 1 + end + -- Fitted captions belong to the old options. + self.optFitted = nil + + local r = self.rect + self:setRect(r[1], r[2], r[3], r[4], self.fontSize) +end + +-- The caption shortened to its box, measured once per text and width rather than per +-- frame. The result is kept on the cache table under the key given. +local function fittedLabel(cache, key, font, label, w, fs) + local hit = cache[key] + if hit and hit.label == label and hit.w == w then + return hit.text + end + + local fitted = colorText .. text.fit(font, label, w, fs) + cache[key] = { label = label, w = w, text = fitted } + + return fitted +end + +-- A tag's pill width, caption size, padding and coloured caption at this control's size. +-- Measured once per tag rather than per frame; setRect drops them when the size changes. +local function tagMetrics(self, font, tag) + local cache = self.tagCache + if not cache then + cache = {} + self.tagCache = cache + end + + local hit = cache[tag] + if not hit then + local fs = floor(self.fontSize * 0.8) + local pad = floor((self.rect[4] - self.rect[2]) * 0.25) + hit = { w = floor(font:GetTextWidth(tag) * fs) + pad * 2, fs = fs, pad = pad, text = colorTag .. tag } + cache[tag] = hit + end + + return hit +end + +-- Vertical span of a tag's pill in a row: a little over half the row's height, centred. +local function tagSpan(y1, y2) + local h = floor((y2 - y1) * 0.62) + local py1 = floor((y1 + y2 - h) * 0.5) + + return py1, py1 + h +end + +-- Moves the selection without notifying the owner, for syncing from outside. +function Dropdown:setSelected(i) + if i and self.options[i] then + self.selected = i + end +end + +function Dropdown:isOpen() + return self.open +end + +-- What the cursor is over: an option's index in the open list, 0 for the control itself, +-- nil for neither. For an owner that shows something about the option under the cursor. +function Dropdown:optionAt(x, y) + if self.open then + for i, r in ipairs(self.optRects) do + if x >= r.x1 and x <= r.x2 and y >= r.y1 and y <= r.y2 then + return i + end + end + end + + local b = self.rect + if x >= b[1] and x <= b[3] and y >= b[2] and y <= b[4] then + return 0 + end + + return nil +end + +function Dropdown:close() + self.open = false +end + +-- Chevron corners, held as upvalues so the vertex callback can be built once rather +-- than closing over fresh geometry on every draw. +local chevronX, chevronY, chevronH = 0, 0, 0 +local function chevronVertices() + gl.Vertex(chevronX - chevronH, chevronY) + gl.Vertex(chevronX + chevronH, chevronY) + -- Floored with the rest: a vertex between two pixels softens the whole glyph. + gl.Vertex(chevronX, floor(chevronY - chevronH * 1.2)) +end + +function Dropdown:draw() + local font = getFont() + local Selector = WG.FlowUI.Draw.Selector + local Highlight = WG.FlowUI.Draw.SelectHighlight + local R = WG.FlowUI.Draw.RectRound + local mx, my = Spring.GetMouseState() + local x1, y1, x2, y2 = self.rect[1], self.rect[2], self.rect[3], self.rect[4] + local inset = floor((y2 - y1) * 0.3) + local tagCs = math.max(1, floor(WG.FlowUI.elementCorner * 0.5)) + -- Where a tag's pill ends: clear of the square FlowUI's Selector draws for its button at the + -- right end, as wide as the control is tall. The open list's tags keep to the same column, + -- so a tag does not jump sideways between the closed control and the rows under it. + local tagRight = x2 - (y2 - y1) - inset + + Selector(x1, y1, x2, y2) + -- A control with nothing to choose from does not light under the cursor. Lighting is + -- what tells a player something will happen when they press, and here nothing will. + if not self.disabled and mx >= x1 and mx <= x2 and my >= y1 and my <= y2 then + Highlight(x1, y1, x2, y2, floor(WG.FlowUI.elementCorner * 0.66), controlHoverOpacity, white) + end + + -- Chevron in the gap already reserved at the right edge, so the control reads as a + -- select rather than a button. Drawn before the text: geometry inside a font batch + -- makes both flicker. + local arrowH = floor((y2 - y1) * 0.16) + local arrowX = x2 - inset - arrowH + local arrowY = floor((y1 + y2) * 0.5 + arrowH * 0.5) + gl.Color(1, 1, 1, self.disabled and 0.25 or (self.open and 0.9 or 0.55)) + chevronX, chevronY, chevronH = arrowX, arrowY, arrowH + gl.BeginEnd(GL.TRIANGLES, chevronVertices) + gl.Color(1, 1, 1, 1) + + -- The selection's tag, in the column worked out above. Its pill is geometry too, so it goes + -- down here and its caption waits for the font batch. A placeholder is not an option and + -- has no tag. + local current = self.options[self.selected] + local currentTag = not self.placeholder and optionTag(current) + local tag = currentTag and tagMetrics(self, font, currentTag) + local labelRight = (arrowX - arrowH) - inset * 2 + local tagX1, tagY1, tagY2 + if tag then + tagX1 = tagRight - tag.w + tagY1, tagY2 = tagSpan(y1, y2) + R(tagX1, tagY1, tagRight, tagY2, tagCs, 1, 1, 1, 1, tagFill) + labelRight = tagX1 - inset + end + + local fitted = self.optFitted + if not fitted then + fitted = {} + self.optFitted = fitted + end + + font:Begin() + if self.outline then + font:SetOutlineColor(self.outline) + end + local label = self.placeholder or (current and optionLabel(current) or "") + -- A preset name is free text and can outrun the control, which is fixed width so the + -- header does not reflow every time the selection changes. + local labelW = labelRight - (x1 + inset) + font:Print( + fittedLabel(fitted, 0, font, label, labelW, self.fontSize), + x1 + inset, + text.baseline(font, y1, y2, self.fontSize), + self.fontSize, + "o" + ) + if tag then + font:Print(tag.text, tagX1 + tag.pad, text.baseline(font, tagY1, tagY2, tag.fs), tag.fs, "o") + end + font:End() + + if self.open and #self.optRects > 0 then + local top = self.optRects[1].y2 + local bottom = self.optRects[#self.optRects].y1 + -- Rounded like the rest of the panel's inner elements. + local cs = floor(WG.FlowUI.elementCorner * 0.66) + R(x1, bottom, x2, top, cs, 1, 1, 1, 1, listFill) + + local ruleH = math.max(1, floor((y2 - y1) * 0.04)) + for i, opt in ipairs(self.options) do + ---@type table + local r = self.optRects[i] + if self.markSelected and i == self.selected then + R(r.x1, r.y1, r.x2, r.y2, cs, 1, 1, 1, 1, selectedFill) + end + if mx >= r.x1 and mx <= r.x2 and my >= r.y1 and my <= r.y2 then + Highlight(r.x1, r.y1, r.x2, r.y2, cs, hoverOpacity, white) + end + + local optTag = optionTag(opt) + if optTag then + local m = tagMetrics(self, font, optTag) + local py1, py2 = tagSpan(r.y1, r.y2) + R(tagRight - m.w, py1, tagRight, py2, tagCs, 1, 1, 1, 1, tagFill) + end + + -- A rule along the top of the row where one group of options gives way to the next. + if i > 1 and optionGroup(opt) ~= optionGroup(self.options[i - 1]) then + gl.Color(ruleColor[1], ruleColor[2], ruleColor[3], ruleColor[4]) + gl.Rect(r.x1 + inset, r.y2 - ruleH, r.x2 - inset, r.y2) + gl.Color(1, 1, 1, 1) + end + end + + -- Still the caption's outline: it was set on this same font a moment ago, in this draw. + font:Begin() + for i, opt in ipairs(self.options) do + local r = self.optRects[i] + local optTag = optionTag(opt) + local m = optTag and tagMetrics(self, font, optTag) + -- The name stops short of its tag when it has one, as the closed control's does. + local right = m and (tagRight - m.w - inset) or (r.x2 - inset) + font:Print( + fittedLabel(fitted, i, font, optionLabel(opt), right - (r.x1 + inset), self.fontSize), + r.x1 + inset, + text.baseline(font, r.y1, r.y2, self.fontSize), + self.fontSize, + "o" + ) + if m then + local py1, py2 = tagSpan(r.y1, r.y2) + font:Print(m.text, tagRight - m.w + m.pad, text.baseline(font, py1, py2, m.fs), m.fs, "o") + end + end + font:End() + end +end + +function Dropdown:mousePress(x, y) + if self.disabled then + self.open = false + + return false + end + + if self.open then + for i, r in ipairs(self.optRects) do + if x >= r.x1 and x <= r.x2 and y >= r.y1 and y <= r.y2 then + self.open = false + self.selected = i + if self.onSelect then + self.onSelect(self.options[i], i) + end + + return true + end + end + end + + local b = self.rect + if x >= b[1] and x <= b[3] and y >= b[2] and y <= b[4] then + self.open = not self.open + return true + end + + self.open = false + + return false +end + +return Dropdown diff --git a/luaui/Include/keybind_editbox.lua b/luaui/Include/keybind_editbox.lua new file mode 100644 index 00000000000..00b04f5a670 --- /dev/null +++ b/luaui/Include/keybind_editbox.lua @@ -0,0 +1,460 @@ +-- Single-line text input, written for the keybind editor's search field and shared with +-- the game info panel's. +-- Active only while focused, so it is safe to host alongside game input. + +local utf8 = VFS.Include("common/luaUtilities/utf8.lua") + +local KEYSYMS = VFS.Include("luaui/Include/keybind_keysyms.lua") +local text = VFS.Include("luaui/Include/keybind_text.lua") + +local Editbox = {} +Editbox.__index = Editbox + +local floor = math.floor + +local colorText = "\255\235\235\235" +local colorDim = "\255\160\160\160" + +-- Caret look and blink taken from gui_chat's input, so the two fields read as the same +-- control: a sharp bar that starts bright on a keystroke and fades over a second before +-- snapping back, rather than a hard on/off blink. +local cursorBlinkDuration = 1 +local cursorGrey = 0.7 + +-- What the panels light a row with under the cursor. The field takes the same, so it +-- reads as something you can click into rather than a plate with text on it. +local hoverOpacity = 0.14 +local white = { 1, 1, 1 } + +-- Font is fetched per draw; it does not exist when this file is included. +local function getFont() + return WG["fonts"].getFont() +end + +-- Restarts the fade, so the caret is at its brightest right after an edit. +local function resetBlink(self) + self.blinkStart = Spring.GetTimer() + self.blinkText = self.text + self.blinkCaret = self.caret +end + +-- Single-line text field with a caret, selection and word motion. +function Editbox.new(opts) + opts = opts or {} + + local self = setmetatable({}, Editbox) + self.text = opts.text or "" + self.caret = utf8.len(self.text) + self.selAnchor = nil + self.focused = false + self.dragging = false + self.placeholder = opts.placeholder or "" + self.maxChars = opts.maxChars or 127 + self.onChange = opts.onChange + -- An outline to draw the text with, for a panel that pins its own. The font is shared with + -- every other widget and keeps whatever outline was set on it last; without one this takes + -- that, as it always has. + self.outline = opts.outline + -- A faint button at the right end that empties the field, shown while there is text. Asked + -- for rather than given: a field whose text is not a filter has nothing it should clear. + self.clearable = opts.clearable + self.rect = { 0, 0, 0, 0 } + self.fontSize = 14 + self.pad = 6 + + return self +end + +function Editbox:setRect(x1, y1, x2, y2, fontSize, pad) + self.rect = { x1, y1, x2, y2 } + self.fontSize = fontSize or (y2 - y1) * 0.5 + self.pad = pad or floor((y2 - y1) * 0.3) +end + +function Editbox:getText() + return self.text +end + +-- Replaces the contents, caret to the end. +function Editbox:setText(t) + self.text = t or "" + self.caret = utf8.len(self.text) + self.selAnchor = nil + + if self.onChange then + self.onChange(self.text) + end +end + +-- SDL text input is owned by the panel, not by this field: blurring the search box to +-- click a keybind must not stop text events while the editor is still open. +function Editbox:focus() + -- A field that just took focus shows a bright caret, not whatever phase the fade + -- happened to be in when it was last used. + if not self.focused then + resetBlink(self) + end + self.focused = true +end + +-- Gives up focus and any drag in progress. +function Editbox:blur() + self.focused = false + self.dragging = false +end + +function Editbox:isFocused() + return self.focused +end + +function Editbox:hasSelection() + return self.selAnchor ~= nil and self.selAnchor ~= self.caret +end + +-- The highlighted range, low end first. +function Editbox:selRange() + return math.min(self.selAnchor, self.caret), math.max(self.selAnchor, self.caret) +end + +-- Removes the highlighted range, caret left where it began. +function Editbox:deleteSelection() + if not self:hasSelection() then + return false + end + + local a, b = self:selRange() + self.text = utf8.sub(self.text, 1, a) .. utf8.sub(self.text, b + 1) + self.caret = a + self.selAnchor = nil + + return true +end + +-- Moves the caret, growing the selection when the caller asks to extend. +function Editbox:setCaret(pos, extend) + if extend then + if not self.selAnchor then + self.selAnchor = self.caret + end + else + self.selAnchor = nil + end + + local len = utf8.len(self.text) + if pos < 0 then + pos = 0 + elseif pos > len then + pos = len + end + self.caret = pos +end + +function Editbox:prevWord() + local pos = self.caret + while pos > 0 and utf8.sub(self.text, pos, pos):match("%s") do + pos = pos - 1 + end + while pos > 0 and not utf8.sub(self.text, pos, pos):match("%s") do + pos = pos - 1 + end + + return pos +end + +function Editbox:nextWord() + local len = utf8.len(self.text) + local pos = self.caret + while pos < len and not utf8.sub(self.text, pos + 1, pos + 1):match("%s") do + pos = pos + 1 + end + while pos < len and utf8.sub(self.text, pos + 1, pos + 1):match("%s") do + pos = pos + 1 + end + + return pos +end + +function Editbox:indexFromX(x) + local font = getFont() + local relX = x - (self.rect[1] + self.pad) + + if relX <= 0 then + return 0 + end + + local n = utf8.len(self.text) + for i = 1, n do + local w = font:GetTextWidth(utf8.sub(self.text, 1, i)) * self.fontSize + if w >= relX then + local wPrev = font:GetTextWidth(utf8.sub(self.text, 1, i - 1)) * self.fontSize + if (relX - wPrev) < (w - relX) then + return i - 1 + end + + return i + end + end + + return n +end + +-- Takes a typed character, replacing any selection. +function Editbox:textInput(char) + if not self.focused then + return false + end + + self:deleteSelection() + + if utf8.len(self.text) >= self.maxChars then + return true + end + + self.text = utf8.sub(self.text, 1, self.caret) .. char .. utf8.sub(self.text, self.caret + 1) + self.caret = self.caret + 1 + self.selAnchor = nil + + if self.onChange then + self.onChange(self.text) + end + + return true +end + +-- Editing and motion keys; printable characters arrive through textInput instead. +function Editbox:keyPress(key) + if not self.focused then + return false + end + + local _, ctrl, _, shift = Spring.GetModKeyState() + local changed = false + + if ctrl and key == KEYSYMS.A then + self.selAnchor = 0 + self.caret = utf8.len(self.text) + elseif key == KEYSYMS.ESCAPE or key == KEYSYMS.RETURN then + self:blur() + elseif key == KEYSYMS.BACKSPACE then + if not self:deleteSelection() then + if ctrl then + local p = self:prevWord() + if p < self.caret then + self.text = utf8.sub(self.text, 1, p) .. utf8.sub(self.text, self.caret + 1) + self.caret = p + end + elseif self.caret > 0 then + self.text = utf8.sub(self.text, 1, self.caret - 1) .. utf8.sub(self.text, self.caret + 1) + self.caret = self.caret - 1 + end + end + changed = true + elseif key == KEYSYMS.DELETE then + if not self:deleteSelection() then + if self.caret < utf8.len(self.text) then + self.text = utf8.sub(self.text, 1, self.caret) .. utf8.sub(self.text, self.caret + 2) + end + end + changed = true + elseif key == KEYSYMS.LEFT then + self:setCaret(ctrl and self:prevWord() or self.caret - 1, shift) + elseif key == KEYSYMS.RIGHT then + self:setCaret(ctrl and self:nextWord() or self.caret + 1, shift) + elseif key == KEYSYMS.HOME then + self:setCaret(0, shift) + elseif key == KEYSYMS.END then + self:setCaret(utf8.len(self.text), shift) + end + + if changed and self.onChange then + self.onChange(self.text) + end + + return true +end + +-- The clear button: a square the height of the field against its right end, inset like the +-- caret and the selection are. +local function clearRect(self) + local x2, y1, y2 = self.rect[3], self.rect[2], self.rect[4] + local inset = floor((y2 - y1) * 0.18) + + return x2 - (y2 - y1) + inset, y1 + inset, x2 - inset, y2 - inset +end + +local function overClear(self, x, y) + if not self.clearable or self.text == "" then + return false + end + local bx1, by1, bx2, by2 = clearRect(self) + + return x >= bx1 and x <= bx2 and y >= by1 and y <= by2 +end + +-- Click to place the caret, or start a drag selection. +function Editbox:mousePress(x, y) + if x < self.rect[1] or x > self.rect[3] or y < self.rect[2] or y > self.rect[4] then + return false + end + + -- Focus stays, so the next thing typed starts a new search. + if overClear(self, x, y) then + self:focus() + self:setText("") + + return true + end + + local _, _, _, shift = Spring.GetModKeyState() + local idx = self:indexFromX(x) + + self:focus() + self:setCaret(idx, shift) + if not shift then + self.selAnchor = idx + end + self.dragging = true + + return true +end + +-- Recomputes caret and selection pixel offsets after the text or rect changes. +local function update(self) + if self.dragging then + local mx, _, lmb = Spring.GetMouseState() + if lmb then + self.caret = self:indexFromX(mx) + else + self.dragging = false + end + end + + -- Watched here rather than reset from each editing path: every way the caret can move + -- (typing, deleting, arrows, a click, a drag, setText) shows up as one of these two + -- changing, so none of them can be missed. + if not self.blinkStart or self.text ~= self.blinkText or self.caret ~= self.blinkCaret then + resetBlink(self) + end +end + +-- Alpha of the caret this frame: full brightness at the last edit, fading to 0.15 over +-- the blink duration, then starting over. Matches gui_chat's sawtooth exactly. +local function caretAlpha(self) + local elapsed = Spring.DiffTimers(Spring.GetTimer(), self.blinkStart) % cursorBlinkDuration + + return 1 - (elapsed * (1 / cursorBlinkDuration)) + 0.15 +end + +-- How far into the text the caret sits, in pixels. Measured only when the text, the caret +-- or the size moved: the field is drawn live every frame so the blink can animate, and +-- measuring the leading substring each of those frames is the one real cost in here. +local function caretOffset(self, font) + if self.caretPxAt ~= self.caret or self.caretPxText ~= self.text or self.caretPxFs ~= self.fontSize then + self.caretPxAt, self.caretPxText, self.caretPxFs = self.caret, self.text, self.fontSize + self.caretPx = font:GetTextWidth(utf8.sub(self.text, 1, self.caret)) * self.fontSize + end + + return self.caretPx +end + +-- Held rather than built per draw: a colour table a frame is an allocation a frame. +local fieldFill = { 0, 0, 0, 0.35 } +local clearFill = { 1, 1, 1, 0.04 } + +-- A thin cross, drawn as geometry rather than a glyph so it does not depend on the font +-- carrying one. The second bar is two halves either side of the first, so the middle is not +-- painted twice and does not show as a brighter dot. +local function drawClear(self, hot, cs) + local bx1, by1, bx2, by2 = clearRect(self) + WG.FlowUI.Draw.RectRound(bx1, by1, bx2, by2, cs, 1, 1, 1, 1, clearFill) + if hot then + WG.FlowUI.Draw.SelectHighlight(bx1, by1, bx2, by2, cs, hoverOpacity, white) + end + + local arm = math.max(2, floor((bx2 - bx1) * 0.24)) + local half = math.max(1, floor((bx2 - bx1) * 0.035 + 0.5)) + gl.Color(1, 1, 1, hot and 0.75 or 0.32) + gl.PushMatrix() + gl.Translate(floor((bx1 + bx2) * 0.5), floor((by1 + by2) * 0.5), 0) + gl.Rotate(45, 0, 0, 1) + gl.Rect(-arm, -half, arm, half) + gl.Rect(-half, half, half, arm) + gl.Rect(-half, -arm, half, -half) + gl.PopMatrix() + gl.Color(1, 1, 1, 1) +end + +function Editbox:draw() + update(self) + + local font = getFont() + local R = WG.FlowUI.Draw.RectRound + local x1, y1, x2, y2 = self.rect[1], self.rect[2], self.rect[3], self.rect[4] + -- Rounded like the rest of the panel's inner elements; the caret and selection sit + -- inside the field by their own inset. + -- Whole pixels: an edge on a fraction is blended across two of them and reads soft. + local cs = floor(WG.FlowUI.elementCorner * 0.66) + local inset = floor((y2 - y1) * 0.18) + local tx = x1 + self.pad + -- The middle of the field, for the caret and the selection, which are box-shaped and + -- want the box; and the baseline the text is drawn from, which wants the font. + local cy = floor((y1 + y2) * 0.5) + local ty = text.baseline(font, y1, y2, self.fontSize) + + R(x1, y1, x2, y2, cs, 1, 1, 1, 1, fieldFill) + + local mx, my = Spring.GetMouseState() + if mx >= x1 and mx <= x2 and my >= y1 and my <= y2 then + WG.FlowUI.Draw.SelectHighlight(x1, y1, x2, y2, cs, hoverOpacity, white) + end + + if self:hasSelection() then + local a, b = self:selRange() + local sa = floor(font:GetTextWidth(utf8.sub(self.text, 1, a)) * self.fontSize) + local sb = floor(font:GetTextWidth(utf8.sub(self.text, 1, b)) * self.fontSize) + gl.Color(0.4, 0.55, 0.85, 0.5) + gl.Rect(tx + sa, y1 + inset, tx + sb, y2 - inset) + gl.Color(1, 1, 1, 1) + end + + -- The coloured string is kept until the text changes, not rebuilt every frame. + local shown + if self.text == "" and not self.focused then + shown = self.placeholderShown + if not shown then + shown = colorDim .. self.placeholder + self.placeholderShown = shown + end + else + if self.shownFor ~= self.text then + self.shownFor = self.text + self.shown = colorText .. self.text + end + shown = self.shown + end + + font:Begin() + if self.outline then + font:SetOutlineColor(self.outline) + end + font:Print(shown, tx, ty, self.fontSize, "o") + font:End() + + if self.clearable and self.text ~= "" then + drawClear(self, overClear(self, mx, my), cs) + end + + if self.focused then + -- Sharp bar rather than a rounded one, sized and placed off the font like chat's: + -- a fixed span around the text's middle, so it does not stretch with the field. + local cx = floor(tx + caretOffset(self, font)) + local cWidth = 1 + floor(self.fontSize / 14) + local cy1 = math.max(y1 + 1, floor(cy - self.fontSize * 0.6)) + local cy2 = math.min(y2 - 1, floor(cy + self.fontSize * 0.64)) + gl.Color(cursorGrey, cursorGrey, cursorGrey, caretAlpha(self)) + gl.Rect(cx, cy1, cx + cWidth, cy2) + gl.Color(1, 1, 1, 1) + end +end + +return Editbox diff --git a/luaui/Include/keybind_editor_view.lua b/luaui/Include/keybind_editor_view.lua new file mode 100644 index 00000000000..7e21ef868d1 --- /dev/null +++ b/luaui/Include/keybind_editor_view.lua @@ -0,0 +1,5197 @@ +-- Interactive view for the in-game keybind editor, hosted as the first tab of +-- the Keybind/Mouse Info panel. Immediate-mode in shape, but the panel body is baked +-- into a display list and replayed until something it was painted from changes. +-- +-- The picker lists the shipped presets, tagged as defaults, then the player's own. Edits +-- are staged in the working model and touch neither the engine nor disk until Save, which +-- is also where a default forks: it cannot take the edits, so saving makes a new preset of +-- them, and the footer says so before anything is saved. Unsaved work is marked with a "*" +-- on the preset's name and guarded on the way out. + +local keybindModel = VFS.Include("luaui/Include/keybind_model.lua") +local keybindConfig = VFS.Include("luaui/Include/keybind_config.lua") +local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") + +-- Shape and rules are documented in common/configs/keybinds.README.md; this is the +-- contract Chobby and the lobby read too, so it is data rather than Lua. +local catalog = keybindConfig.load("common/configs/keybind_catalog.json") or {} +local Editbox = VFS.Include("luaui/Include/keybind_editbox.lua") +local Dropdown = VFS.Include("luaui/Include/keybind_dropdown.lua") +local Search = VFS.Include("luaui/Include/search.lua") +local profiles = VFS.Include("luaui/Include/keybind_profiles.lua") + +local KEYSYMS = VFS.Include("luaui/Include/keybind_keysyms.lua") +local text = VFS.Include("luaui/Include/keybind_text.lua") + +local view = {} + +local floor = math.floor +local spGetMouseState = Spring.GetMouseState +local spGetTimer = Spring.GetTimer +local spDiffTimers = Spring.DiffTimers +local isInRect = math.isInRect +local glColor = gl.Color +local glTexture = gl.Texture +local glTexRect = gl.TexRect +local glBlending = gl.Blending + +-- The engine loads this one file; a profile is applied by writing it here. +local customKeysFile = profiles.activeFile + +local area = { x1 = 0, y1 = 0, x2 = 0, y2 = 0 } +local scale = 1 +local rowHeight = 22 +-- Sizes derived from the scale, in one table: this chunk is close to Lua's limit of +-- 200 locals, so they share a slot rather than each taking one. +local metrics = { + -- Category entries run taller than keybind rows and in their own, larger, font. + catRowHeight = 28, + rowFs = 12, + rowPad = 6, + sidePad = 12, + catInset = 4, + -- Chips sit inside their row by this much, top and bottom. + chipInset = 3, + -- The cursor picture in front of an order's name, square. + cursorIcon = 19, + -- A category heading stands taller than the bindings under it and is set larger, so it + -- reads as a divider rather than another row. + headerRowHeight = 32, + headerFs = 14, + catFs = 13, + -- The line along the bottom of a heading and of the selected category. + underlineH = 2, + -- Everything that sits against the panel's right edge - the header icons, the footer + -- buttons and the scrollbar - is held off it by this much, matching the inset the + -- header and footer already use vertically, so a button clears all three edges equally. + edgeInset = 4, + -- Clearance between the bottom of the list band and the footer, so the scrollbar does + -- not run down into the buttons. + footerGap = 8, + -- Clearance between the right edge of the rows and the scrollbar beside them. + listGap = 12, + -- How far the category card rises above the first entry in it. + cardLip = 5, + -- The panel title: its baseline below the top edge, and its size. + titleY = 17, + titleFs = 20, + -- The caption in front of the preset picker, placed by layoutHeader: its left edge, its + -- baseline and its size. + presetLabelX = 0, + presetLabelY = 0, + presetLabelFs = 13, + -- The line in the footer saying where staged edits will go: its left edge, its baseline + -- and its size. + noticeX = 0, + noticeY = 0, + noticeFs = 12, + -- How far the category column starts below the keybind rows beside it, to leave the + -- title room to breathe. + sidebarDrop = 8, + -- Corner radii, taken from FlowUI's so the panel rounds like the rest of the UI. + csSmall = 2, + csButton = 3, + csPanel = 4, +} +-- Bumped by setArea. A cached row layout carries the value it was built against and is +-- measured again when it moves, without every row being walked at resize. +local layoutGen = 0 +local listTop = 0 +local barX1 = 0 +local listX1 = 0 +local sidebarW = 0 +local categories = {} +-- Selection is held as the catalog's i18n key, never its translated title, so a language +-- change cannot strand it against titles that have all moved. +---@type string? +local selectedCategory +-- Stands in for the generated Other bucket when no catalog category is titled the same. +-- A table cannot collide with a catalog key, which is always a string. +local generatedOtherKey = {} +local otherCategoryKey = generatedOtherKey +-- Set while a category asks to be drawn as the grid menu instead of a row list. +---@type table? +local gridGroup +local listRight = 0 + +---@type table +local working +---@type table +local resolvedCatalog +-- Grouped chips per action. Derived purely from working.byAction, and every path that +-- changes that rebuilds the rows, so rebuildRows is where it gets dropped. +local chipGroups = {} +local catalogAny, catalogAnyPrefixes, catalogShiftPair = {}, {}, {} +local L = {} +local rows = {} +-- Bumped by rebuildRows, so the baked panel knows the list behind it changed. +local rowsGen = 0 +local scroll = 0 + +-- What the cursor is over, in the terms the panel paints hover with. Refilled in place +-- each frame rather than allocated. +-- `grab` is where the scrollbar's thumb was taken hold of, as the distance from the cursor +-- to its top edge, so the thumb follows the cursor instead of jumping its middle to the +-- press. It rides here rather than in a local of its own: this chunk is at Lua's ceiling of +-- 200 locals, which is why the sizes above share `metrics` too. +-- `kb` is the key under the cursor on the keyboard page. +local hover = { + sb = 0, + row = 0, + zone = "", + idx = 0, + gk = "", + ga = 0, + gb = 0, + btn = "", + bar = 0, + grab = 0, + cat = 0, + drag = false, + kb = 0, +} +local dirty = false + +-- Blur behind whatever floats over the panel, and the floating content drawn back on top +-- of it. +-- +-- The panel's own backdrop is registered with InsertDlist, which is the *world* set: it +-- blurs the map behind the panel and leaves the UI alone. A popup has to blur UI - the +-- rows and buttons it covers - so it goes into the screen set instead. +-- +-- That set is drawn by gfx_guishader, which copies the screen as it stands and blurs it +-- inside those rects. widgetHandler walks DrawScreen in reverse layer order, so this +-- panel (-99990) draws well before guishader (-990000) and a popup of ours inside one of +-- those rects would be blurred along with what it covers. Handing the drawing to +-- insertRenderDlist gets it replayed after the blur, which is how gui_options keeps its +-- select list crisp. +-- +-- One table rather than a handful of locals, and for the same reason as `hover` above: +-- this chunk is at Lua's ceiling of 200. +local shade = { owner = nil, rects = {}, lists = {} } + +-- Only touched when the rect actually moves: every insert marks the stencil dirty, so +-- doing it per frame has it rebuilt per frame. +function shade.rect(name, x1, y1, x2, y2) + if not WG.guishader then + return + end + local was = shade.rects[name] + if x1 then + if not (was and was[1] == x1 and was[2] == y1 and was[3] == x2 and was[4] == y2) then + WG.guishader.InsertScreenRect(x1, y1, x2, y2, "keybindeditor_" .. name, shade.owner) + shade.rects[name] = { x1, y1, x2, y2 } + end + elseif was then + WG.guishader.RemoveScreenRect("keybindeditor_" .. name) + shade.rects[name] = nil + end +end + +function shade.drop(name) + local list = shade.lists[name] + if list then + if WG.guishader then + WG.guishader.removeRenderDlist(list) + end + gl.DeleteList(list) + shade.lists[name] = nil + end +end + +-- Rebuilt per frame: a modal carries a blinking caret and the picker lights the option +-- under the cursor, so there is nothing static to hold on to. +function shade.float(name, fn) + if not (WG.guishader and WG.guishader.insertRenderDlist) then + -- No blur will be drawn over it, so there is nothing to hand over. + fn() + return + end + shade.drop(name) + shade.lists[name] = gl.CreateList(fn) + WG.guishader.insertRenderDlist(shade.lists[name]) +end + +function shade.clear() + for name in pairs(shade.rects) do + if WG.guishader then + WG.guishader.RemoveScreenRect("keybindeditor_" .. name) + end + shade.rects[name] = nil + end + for name in pairs(shade.lists) do + shade.drop(name) + end +end +---@type table? +local capturing + +---@type table +local font +---@type function +local RectRound +---@type table +local Scroller +---@type function +local UiElement +---@type function +local Highlight +---@type function +local UiButton +---@type function +local UiUnitFrame + +local colorAction = "\255\210\210\205" +local colorKey = "\255\235\185\070" +local colorText = "\255\235\235\235" +local colorDim = "\255\160\160\160" +-- A button that cannot be pressed: dimmer than the dim used for ordinary secondary text, +-- since here it has to read as unavailable rather than merely quiet. +local colorFaded = "\255\115\115\115" +local colorHeader = "\255\255\200\130" +local colorDanger = "\255\235\090\090" +-- SelectHighlight defaults to 0.35 and the rest of the UI stays near it. At 1 the +-- overlay is opaque and swallows the label under it. +local hoverOpacity = 0.25 +local buttonFill = { 0.18, 0.18, 0.18, 1 } +-- Matching stops because Draw.Button gradients color1 -> color2, and the white hover +-- overlay washes a tinted button out to grey, so these brighten instead. +local dangerFill = { 0.46, 0.10, 0.10, 1 } +local dangerFillHover = { 0.66, 0.14, 0.14, 1 } +-- With nothing staged there is nothing to discard or save, so both footer buttons drop +-- most of their colour and go part transparent, sinking into the panel instead of sitting +-- on it as a slightly darker version of the live button. +local dangerFillMuted = { 0.17, 0.12, 0.12, 0.45 } +local confirmFill = { 0.17, 0.38, 0.21, 1 } +local confirmFillHover = { 0.24, 0.52, 0.29, 1 } +local confirmFillMuted = { 0.12, 0.17, 0.13, 0.45 } +local pillFill = { 0.22, 0.22, 0.22, 1 } +local sheenTop = { 1, 1, 1, 0.05 } +-- Fills and captions the list is painted with, in one table for the same reason as +-- metrics above. +local look = { + chipFill = { 0, 0, 0, 0.35 }, + chipFillHover = { 0, 0, 0, 0.45 }, + -- A chip that answered a search by key, warmed in the gold its key is printed in, so it stands + -- out from the rest of the row without reading as hovered. + chipFillHit = { 0.92, 0.72, 0.27, 0.3 }, + -- A chip whose key another listed action also answers to: reddened, and its tooltip says which. + chipFillConflict = { 0.62, 0.16, 0.12, 0.4 }, + -- The key a row had in the preset it was forked from, against the row's right edge as a + -- hollow chip: an amber border - the hue the headings and the unsaved notice use - with the + -- row's own dark inside it and a "default:" caption, so it reads as a note about the row + -- rather than one more of its keys. Clicking it puts that key back. + ghostBorder = { 1, 0.78, 0.51, 0.3 }, + ghostBorderHover = { 1, 0.78, 0.51, 0.7 }, + ghostInner = { 0.09, 0.09, 0.09, 1 }, + ghostKeys = "\255\200\165\110", + -- Shared by every unchanged row that asks: no keys at all, and nothing to copy. + noRaws = {}, + -- The import preview: the box the clipboard's lines scroll in, the band under a line that + -- will be dropped, and each kind of line in its own colour. + previewFill = { 0, 0, 0, 0.35 }, + previewGutter = { 1, 1, 1, 0.05 }, + previewErrorFill = { 0.62, 0.16, 0.12, 0.28 }, + previewColours = { bind = colorText, directive = colorDim, comment = colorFaded, error = colorDanger }, + addFill = { 0.2, 0.45, 0.25, 0.4 }, + -- Lit rather than nudged: hovering used to lift the alpha alone, which on a green this + -- soft was hard to tell from resting. A tinted element brightens its own fill instead + -- of taking the white overlay, which would wash the green out to grey. + addFillHover = { 0.32, 0.74, 0.4, 0.6 }, + selectedFill = { 1, 1, 1, 0.13 }, + -- The outline every string the panel prints is drawn with, set on every batch rather than + -- once: the font is shared with every other widget, some of which set an outline of their + -- own and leave it set, and text baked into a display list keeps whatever outline was set + -- last. The settings panel's value, which this panel is styled after. + outline = { 0, 0, 0, 0.4 }, + -- The keyboard page's toggle in the header, pressed while that page is showing: lifted + -- above the resting button grey, and further under the cursor, since a tinted face takes + -- no hover overlay. + toggleFill = { 0.33, 0.33, 0.33, 1 }, + toggleFillHover = { 0.4, 0.4, 0.4, 1 }, + -- The category column sits on its own darker card, so it reads apart from the list. + sidebarFill = { 0, 0, 0, 0.24 }, + sidebarFillTop = { 0, 0, 0, 0.16 }, + white = { 1, 1, 1 }, + -- Rows, categories and grid cells hover with the same FlowUI highlight the settings + -- list uses, at the strength it gives a plain row. + rowHoverOpacity = 0.14, + -- Underlines are drawn as a thin bar that fades upward out of the bottom edge, each in + -- the hue of the text above it: warm under a category heading, plain under the selected + -- category in the column. + headerLine = { 1, 0.78, 0.51, 0.4 }, + headerLineFade = { 1, 0.78, 0.51, 0 }, + -- Border strength for a tile that is only being shown, not offered. FlowUI's own + -- default for a live one is 0.1. + idleBorder = 0.02, + removeHot = colorDanger .. "x", + removeCold = colorDim .. "x", + plusText = colorText .. "+", + -- The glyph goes to full white with it, the way a chip's key does under the cursor. + plusTextHover = "\255\255\255\255" .. "+", + arrow = colorKey .. string.char(226, 128, 186), + -- The cursor an order shows in game, by the command at the front of its action, so its row + -- carries the picture a player already knows the order by. File stems in anims/: the engine's + -- own pairing (MouseHandler.cpp), and the cursors BAR's custom commands declare, which borrow + -- the attack one. An order with no cursor of its own, like stop or cloak, has no entry. + cursors = { + move = "move", + attack = "attack", + areaattack = "attack", + manuallaunch = "attack", + manualfire = "dgun", + settarget = "settarget", + settargetnoground = "settarget", + fight = "fight", + patrol = "patrol", + guard = "defend", + repair = "repair", + reclaim = "reclamate", + resurrect = "revive", + restore = "restore", + capture = "capture", + loadunits = "pickup", + unloadunits = "unload", + wait = "wait", + gatherwait = "gather", + selfd = "selfd", + }, + -- How strongly those pictures draw. At full strength they outshout the names beside them. + cursorAlpha = 0.85, +} + +-- FlowUI's Button gradients from a bottom stop to a top one. Left to its defaults it +-- fades black up to near-transparent white, which washes a tinted button out to grey, so +-- each fill becomes a darker bottom and itself on top - the same shape gui_pregameui +-- gives its ready button. Derived once per fill and kept, since the pair is passed every +-- draw and a table per button per frame is what the rest of this file avoids. +look.gradients = setmetatable({}, { + __index = function(self, fill) + local pair = { + { fill[1] * 0.55, fill[2] * 0.55, fill[3] * 0.55, fill[4] or 1 }, + { fill[1], fill[2], fill[3], fill[4] or 1 }, + } + self[fill] = pair + + return pair + end, +}) + +-- The first frame of a cursor, looked up once per cursor. The 48 px set is the nearest to a +-- row's height. Most cursors number their frames from 0 and a few from 1; false when there is +-- neither, and the row goes without a picture. +look.cursorTextures = setmetatable({}, { + __index = function(self, stem) + local base = "anims/icexuick_100/cursor" .. stem + local path = (VFS.FileExists(base .. "_0.png") and base .. "_0.png") + or (VFS.FileExists(base .. "_1.png") and base .. "_1.png") + or false + self[stem] = path + + return path + end, +}) + +---@type table +local searchBox +---@type table +local presetDropdown +---@type table +local nameBox +---@type function? +local menuToggle +local switchToPreset, scrollFromY + +---@type table? +local dialog + +-- `tip` names the tooltip's text in L, and `tipLocked` the text shown instead while the +-- active preset is a default. That is when Edit is greyed out, and its tooltip is then the +-- one place saying why. +-- The two with icons act on the active preset; the two with captions carry presets in and out +-- through the clipboard. The last is the page toggle, set apart by a wider gap: it swaps the +-- list for the keyboard overview and back, and sits pressed while the keyboard is showing. +local headerButtons = { + { + id = "duplicate", + icon = "LuaUI/Images/keybinds/duplicate.png", + tooltipId = "keybind_duplicate", + tip = "duplicateTooltip", + }, + { + id = "edit", + icon = "LuaUI/Images/keybinds/edit.png", + tooltipId = "keybind_edit", + tip = "editTooltip", + tipLocked = "editLockedTooltip", + }, + { id = "export", tooltipId = "keybind_export", tip = "exportTooltip" }, + { id = "import", tooltipId = "keybind_import", tip = "importTooltip" }, + { id = "keyboard", tooltipId = "keybind_keyboard", tip = "keyboardTooltip", toggle = true, gap = 2 }, +} + +-- Discarding is destructive and saving is not, so the two footer buttons are coloured for +-- what they do rather than left to read alike. +local footerButtons = { + { id = "reset", fill = dangerFill, fillHover = dangerFillHover, fillMuted = dangerFillMuted }, + { id = "save", fill = confirmFill, fillHover = confirmFillHover, fillMuted = confirmFillMuted }, +} + +local buttonSets = { headerButtons, footerButtons } + +-- Panel state that is neither a size nor a colour, in one table: this chunk is at Lua's +-- ceiling of 200 locals, and the functions that only this state needs hang off it too, the +-- way `shade` does. +-- headerH/footerH: the header and footer bands, shared by the layout and by every +-- geometry derived from it. +-- layoutPending: the layout ran before the font existed and has to run again. +-- tooltipsRegistered: header tooltips are registered once per layout rather than per +-- frame, since registering with a value throws the tooltip's cached text away each time. +-- panelList/panelSig: the panel below the header controls, baked once and replayed until +-- something it was painted from changes. +-- hidden: the catalog's hidden actions. They share keys with listed ones on purpose, so +-- they are no conflict. +-- labels: each action's listed name, for naming it where another action's key clashes. +-- base: the shipped preset the active one is measured against, with its keysets by +-- action; nil when the active preset has no known origin. +-- changedKey/changedCount: the column entry listing the rows that differ from the base, +-- and how many there are, which its label says. +-- refit: the column's labels changed and have to be fitted again before they are drawn. +-- undo/snapshot: the staged keymap as it stood before each edit, and as it stands now, +-- which the next edit files. batching/batchEdited: one gesture making several edits +-- files one snapshot for the lot. +-- tipKey/tipTitle/tipText: the tooltip last built, kept until the cursor is on something +-- else, since building one wraps text. +-- page: "list" or "keyboard", the page the body shows. keyboard is the keyboard page +-- itself, and keyboardGen the rowsGen its bindings were placed from, so it is placed +-- again only once the staged keymap has changed. keyInfo: each action's card for it, +-- built from the catalog on first use and dropped with the catalog. +local state = { + headerH = 0, + footerH = 0, + layoutPending = false, + tooltipsRegistered = false, + hidden = {}, + labels = {}, + changedKey = {}, + changedCount = -1, + refit = false, + undo = {}, + batching = false, + batchEdited = false, + ---@type string + page = "list", + keyboard = VFS.Include("luaui/Include/keybind_keyboard.lua").new(), + keyboardGen = -1, +} + +-- A copy of the staged keymap, for putting back. Binds and keysets are copied rather than +-- shared: the edits rewrite entries in place. +function state.snapshotOf() + local binds, byAction = {}, {} + for i, b in ipairs(working.binds) do + binds[i] = { keyset = b.keyset, action = b.action } + end + for action, ks in pairs(working.byAction) do + local copy = {} + for i, k in ipairs(ks) do + copy[i] = { raw = k.raw, display = k.display } + end + byAction[action] = copy + end + + return { binds = binds, byAction = byAction } +end + +---------------------------------------------------------------- +-- Profiles and the picker +---------------------------------------------------------------- + +local presetOptions = {} + +-- Picker contents: the shipped presets, tagged as defaults, then the player's own, which the +-- open list sets apart with a rule. Staged edits mark whichever one is active with a "*", a +-- default included: the edits are real and unsaved either way, and where they will be saved +-- is for the footer to say, beside the button that saves them. +local function buildPresetOptions() + local active = profiles.activeName() + + presetOptions = {} + for _, b in ipairs(profiles.builtins) do + local label = (dirty and b.name == active) and (b.name .. " *") or b.name + presetOptions[#presetOptions + 1] = { label = label, name = b.name, tag = L.defaultTag, group = "default" } + end + for _, name in ipairs(profiles.list()) do + local label = (dirty and name == active) and (name .. " *") or name + presetOptions[#presetOptions + 1] = { label = label, name = name, group = "own" } + end + + return presetOptions +end + +-- Shipped profiles can be copied but not renamed or deleted. +local function activeIsOwn() + return profiles.get(profiles.activeName()) ~= nil +end + +-- Single source for whether a button is live, so it cannot draw enabled and do nothing. +local function buttonEnabled(id) + if id == "save" or id == "reset" then + return dirty + end + if id == "edit" then + return activeIsOwn() + end + + return true +end + +local function currentPresetIndex() + local name = profiles.activeName() + for i = 1, #presetOptions do + if presetOptions[i].name == name then + return i + end + end + + return 1 +end + +---------------------------------------------------------------- +-- Scrolling +---------------------------------------------------------------- + +local function listBottom() + return area.y1 + state.footerH + metrics.footerGap +end + +-- Whole rows the band can paint. +-- A category heading is taller than the bindings under it, so a row's position is a sum of +-- what is above it rather than its index times one height. The running total is stamped +-- onto the rows, which rebuildRows replaces wholesale, and redone when the layout moves. +local rowMetrics = { gen = -1, rows = -1, totalH = 0 } + +local function rowHeightOf(row) + return row.type == "header" and metrics.headerRowHeight or rowHeight +end + +local function ensureRowMetrics() + if rowMetrics.gen == layoutGen and rowMetrics.rows == rowsGen then + return + end + + local off = 0 + for i = 1, #rows do + local row = rows[i] + row.off = off + off = off + rowHeightOf(row) + end + rowMetrics.totalH = off + rowMetrics.gen, rowMetrics.rows = layoutGen, rowsGen +end + +-- Pixels of content above the first painted row. +local function scrollOffset() + ensureRowMetrics() + local first = rows[scroll + 1] + + return first and first.off or 0 +end + +-- Furthest offset that still fills the band. Walked from the end, so it does not depend on +-- where the list is scrolled to now. +local function maxScroll() + ensureRowMetrics() + local band = listTop - listBottom() + local used = 0 + local i = #rows + while i > 0 do + local h = rowHeightOf(rows[i]) + if used + h > band then + break + end + used = used + h + i = i - 1 + end + + return i +end + +-- The painted row under y, as its offset from the first painted one, plus the edges it was +-- painted with. Every hover test, click and the panel signature go through this, so none of +-- them can disagree with what was drawn. nil when y is outside the band or past the last +-- whole row the band can hold. +local function rowAt(y) + ensureRowMetrics() + local lb = listBottom() + if y > listTop or y <= lb then + return nil + end + + local base = scrollOffset() + for i = scroll + 1, #rows do + local top = listTop - (rows[i].off - base) + local bottom = top - rowHeightOf(rows[i]) + if bottom < lb then + break + end + -- Half-open on the shared edge: rows stack, so one row's top is the next one's + -- bottom and a closed test would put the cursor in both. + if y <= top and y > bottom then + return i - scroll, top, bottom + end + end + + return nil +end + +local function clampScroll() + if scroll < 0 then + scroll = 0 + end + if scroll > maxScroll() then + scroll = maxScroll() + end +end + +---------------------------------------------------------------- +-- Catalog and the row list +---------------------------------------------------------------- + +-- Whether a label wants the prefix argument placed inside it. Probed rather than declared, +-- because a label shared with the command card names the thing rather than a numbered +-- variant of it, and then the argument goes on the end instead. +local labelPlacesArg = {} +local function prefixRowLabel(key, arg, row, col) + local places = labelPlacesArg[key] + if places == nil then + places = BAR.I18N(key, { n = "", row = "", col = "" }):find("", 1, true) ~= nil + labelPlacesArg[key] = places + end + + if places then + return BAR.I18N(key, { n = arg, row = row, col = col }) + end + + return BAR.I18N(key) .. " " .. arg +end + +-- Resolve i18n labels once (search rebuilds rows per keystroke); redone on refresh. +local function buildResolvedCatalog() + labelPlacesArg = {} + resolvedCatalog = {} + catalogAny, catalogAnyPrefixes, catalogShiftPair = {}, {}, {} + state.hidden, state.labels = {}, {} + + -- What an action does, for its tooltip: the catalog's own key when it names one, else the + -- command card's tooltip for a row labelled off the card, else the engine's description of + -- the command - a string of its own, the heading of a structured one, or a gadget's. Asked + -- for with an empty default, so a missing key is silent and reads as none. + local function describe(item) + local command = (item.action or item.prefix or ""):match("^%S+") + -- Appended one by one: a nil in a table constructor ends what ipairs walks. + local keys = {} + if item.description then + keys[#keys + 1] = item.description + end + if item.label and item.label:sub(1, 9) == "commands." then + keys[#keys + 1] = item.label .. "_tooltip" + end + if command then + keys[#keys + 1] = "cmd." .. command + keys[#keys + 1] = "cmd." .. command .. "._description" + keys[#keys + 1] = "cmd.luarules." .. command + end + for _, key in ipairs(keys) do + local found = BAR.I18N(key, { default = "" }) + if type(found) == "string" and found ~= "" and found ~= key then + return found + end + end + + return nil + end + + for _, group in ipairs(catalog) do + if group.hidden then + resolvedCatalog[#resolvedCatalog + 1] = { hidden = group.hidden, title = "", titleLower = "", items = {} } + for _, h in ipairs(group.hidden) do + state.hidden[h] = true + end + else + local title = BAR.I18N(group.category) + local g = { + category = group.category, + layout = group.layout, + title = title, + titleLower = title:lower(), + items = {}, + } + for _, item in ipairs(group.items) do + if item.prefix then + if item.alwaysModifier == "any" then + catalogAnyPrefixes[#catalogAnyPrefixes + 1] = item.prefix + end + g.items[#g.items + 1] = { + prefix = item.prefix, + label = item.label, + unit = item.unit, + members = item.members, + description = describe(item), + icon = (item.icon and VFS.FileExists(item.icon) and item.icon) or nil, + } + else + if item.action then + if item.alwaysModifier == "any" then + catalogAny[item.action] = true + elseif item.alwaysModifier == "shift" then + catalogShiftPair[item.action] = true + end + end + local label = BAR.I18N(item.label) + local stem = item.action and look.cursors[item.action:match("^%S+")] + local cursor = stem and look.cursorTextures[stem] or nil + g.items[#g.items + 1] = { + action = item.action, + actionLower = item.action and item.action:lower(), + label = label, + labelLower = label:lower(), + cursor = cursor, + -- The picture a key shows for the action: the catalog's own where it names + -- one, else the cursor an order is already known by. + icon = (item.icon and VFS.FileExists(item.icon) and item.icon) or cursor, + description = describe(item), + } + if item.action then + state.labels[item.action] = label + end + -- One picture in a group gives every row in it the column, so the names line up. + g.hasCursors = g.hasCursors or cursor ~= nil + end + end + if g.layout == "grid" then + -- Pulled off the same catalog entries the list would have used, so the grid and + -- the flat form name things identically. + g.categoryLabels = {} + for _, it in ipairs(group.items) do + local n = it.action and it.action:match("^gridmenu_category%s+(%d+)$") + if n then + g.categoryLabels[tonumber(n)] = BAR.I18N(it.label) + end + if it.prefix == "gridmenu_key" and it.label then + local key = it.label + g.cellLabel = function(row, col) + return BAR.I18N(key, { n = row .. " " .. col, row = row, col = col }) + end + end + end + g.cellLabel = g.cellLabel or function(row, col) + return row .. " " .. col + end + for _, it in ipairs(group.items) do + if it.action == "gridmenu_cycle_builder" and it.label then + g.cycleLabel = BAR.I18N(it.label) + end + end + g.cycleLabel = g.cycleLabel or "gridmenu_cycle_builder" + end + resolvedCatalog[#resolvedCatalog + 1] = g + end + end + + L.other = BAR.I18N("categories.other") + L.otherLower = L.other:lower() + L.title = BAR.I18N("ui.keybinds.title") + L.titleText = colorText .. L.title + L.allCategories = BAR.I18N("ui.keybinds.editor.allCategories") + L.gridNextPage = BAR.I18N("actions.gridMenu.nextPage") + -- gui_gridmenu hardcodes both the caption and the key on this button, so it is not + -- bindable and there is no i18n key to read. + -- Shared with gui_gridmenu, which draws the button this mirrors. + L.gridBack = BAR.I18N("ui.buildMenu.back") + + categories = { { label = L.allCategories } } + otherCategoryKey = generatedOtherKey + local seen = {} + for _, g in ipairs(resolvedCatalog) do + -- Keyed like the row filter below, not by title: two categories that translate to + -- the same words are still separate, and one has to not vanish from the column. + if not g.hidden and not seen[g.category] then + seen[g.category] = true + categories[#categories + 1] = { label = g.title, key = g.category } + -- A catalog category of the same name takes the leftovers, matching the row + -- order below, rather than a second column entry appearing beside it. + if g.title == L.other then + otherCategoryKey = g.category + end + end + end + if otherCategoryKey == generatedOtherKey then + categories[#categories + 1] = { label = L.other, key = otherCategoryKey } + end + -- A fresh column has no Changed entry, whatever the count was: forgotten here so the next + -- rebuild of the rows puts it back. Every keyreload comes through here, so without this + -- the entry went missing until a preset switch happened to move the count. + state.changedCount = -1 + L.pressKey = BAR.I18N("ui.keybinds.editor.pressKey") + L.preset = BAR.I18N("ui.keybinds.editor.preset") + -- Dim, so the preset name in the picker beside it stays the thing that is read. + L.presetText = colorDim .. L.preset + L.defaultTag = BAR.I18N("ui.keybinds.editor.defaultTag") + L.newProfile = BAR.I18N("ui.keybinds.editor.newProfile") + L.duplicate = BAR.I18N("ui.keybinds.editor.duplicate") + L.duplicateTooltip = BAR.I18N("ui.keybinds.editor.duplicateTooltip") + L.edit = BAR.I18N("ui.keybinds.editor.edit") + L.editTooltip = BAR.I18N("ui.keybinds.editor.editTooltip") + L.editLockedTooltip = BAR.I18N("ui.keybinds.editor.editLockedTooltip") + L.saveAsNew = BAR.I18N("ui.keybinds.editor.saveAsNew") + L.noticeDefault = BAR.I18N("ui.keybinds.editor.noticeDefault") + L.noticeDefaultUnsaved = BAR.I18N("ui.keybinds.editor.noticeDefaultUnsaved") + L.noticeUnsaved = BAR.I18N("ui.keybinds.editor.noticeUnsaved") + L.changed = BAR.I18N("ui.keybinds.editor.changed") + L.boundToAny = BAR.I18N("ui.keybinds.editor.boundToAny") + L.changedUnknown = BAR.I18N("ui.keybinds.editor.changedUnknown") + L.changedNoneTooltip = BAR.I18N("ui.keybinds.editor.changedNoneTooltip") + L.compareWith = BAR.I18N("ui.keybinds.editor.compareWith") + L.compareNone = BAR.I18N("ui.keybinds.editor.compareNone") + L.compareNoneHint = BAR.I18N("ui.keybinds.editor.compareNoneHint") + L.conflictOrder = BAR.I18N("ui.keybinds.editor.conflictOrder") + L.conflictShipped = BAR.I18N("ui.keybinds.editor.conflictShipped") + L.revertHint = BAR.I18N("ui.keybinds.editor.revertHint") + L.revertNone = BAR.I18N("ui.keybinds.editor.revertNone") + L.presetDefault = BAR.I18N("ui.keybinds.editor.presetDefault") + L.presetOwn = BAR.I18N("ui.keybinds.editor.presetOwn") + L.export = BAR.I18N("ui.keybinds.editor.export") + L.exportTooltip = BAR.I18N("ui.keybinds.editor.exportTooltip") + L.import = BAR.I18N("ui.keybinds.editor.import") + L.importTooltip = BAR.I18N("ui.keybinds.editor.importTooltip") + L.keyboard = BAR.I18N("ui.keybinds.editor.keyboard") + L.keyboardTooltip = BAR.I18N("ui.keybinds.editor.keyboardTooltip") + -- The keyboard page's cards are built from this catalog, so they go with it. + state.keyInfo = nil + state.keyboard:refreshStrings() + L.importTitle = BAR.I18N("ui.keybinds.editor.importTitle") + L.importEmpty = BAR.I18N("ui.keybinds.editor.importEmpty") + L.importNone = BAR.I18N("ui.keybinds.editor.importNone") + L.ok = BAR.I18N("ui.keybinds.editor.ok") + L.editTitle = BAR.I18N("ui.keybinds.editor.editTitle") + L.delete = BAR.I18N("ui.keybinds.editor.delete") + L.duplicateTitle = BAR.I18N("ui.keybinds.editor.duplicateTitle") + L.save = BAR.I18N("ui.keybinds.editor.save") + L.reset = BAR.I18N("ui.keybinds.editor.reset") + L.resetConfirm = BAR.I18N("ui.keybinds.editor.resetConfirm") + L.saveTitle = BAR.I18N("ui.keybinds.editor.saveTitle") + L.discard = BAR.I18N("ui.keybinds.editor.discard") + L.unsavedTitle = BAR.I18N("ui.keybinds.editor.unsavedTitle") + L.unsavedMessage = BAR.I18N("ui.keybinds.editor.unsavedMessage") + L.applyFailedTitle = BAR.I18N("ui.keybinds.editor.applyFailedTitle") + L.accept = BAR.I18N("ui.keybinds.editor.accept") + L.cancel = BAR.I18N("ui.keybinds.editor.cancel") +end + +-- A keyset's canonical form, kept on the keyset record against the raw it came from: the +-- change and conflict checks below run for every row on every rebuild. +local function canonOf(k) + if k.canonFor ~= k.raw then + k.canon, k.canonFor = keybindModel.canonicalKeyset(k.raw), k.raw + end + + return k.canon +end + +-- How an action's keys differ from the preset the active one is measured against: nil when +-- they match, or there is nothing to measure against; else the base's raws for the action, +-- which may be none at all. Compared as sets of canonical keysets, so spelling and order do +-- not count as a change. +local function rowChange(action) + local base = state.base + if not base then + return nil + end + + local theirs = base.byAction[action] + local seen, n = {}, 0 + for _, k in ipairs(working.byAction[action] or look.noRaws) do + local c = canonOf(k) + if not seen[c] then + seen[c] = true + n = n + 1 + if not (theirs and theirs.set[c]) then + return theirs and theirs.raws or look.noRaws + end + end + end + if (theirs and theirs.n or 0) ~= n then + return theirs and theirs.raws or look.noRaws + end + + return nil +end + +-- The other listed actions these keysets drive, in bind order, each flagged when the engine +-- tries it before this action, and when the game itself ships the two on one key - sharing +-- by design, which is no clash of the player's making. Nil when there are none. Hidden +-- actions are left out: one sharing a key with a listed action is how the catalog says the +-- two belong together. +local function conflictsOf(action, raws) + local byKeyset = working.byKeyset + if not byKeyset then + return nil + end + + local out, seen + for _, raw in ipairs(raws) do + -- Any holder at all: for a key being captured this action is not among them yet. + local list = byKeyset[keybindModel.canonicalKeyset(raw)] + if list then + local mine + for i = 1, #list do + if list[i] == action then + mine = i + break + end + end + for i = 1, #list do + local other = list[i] + if other ~= action and not state.hidden[other] and not (seen and seen[other]) then + seen = seen or {} + seen[other] = true + out = out or {} + local pair = (action < other) and (action .. "\n" .. other) or (other .. "\n" .. action) + out[#out + 1] = { + action = other, + before = mine ~= nil and i < mine, + shipped = state.shippedPairs ~= nil and state.shippedPairs[pair] == true, + } + end + end + end + end + + return out +end + +-- Rebuilds the display list from the catalog and the staged binds, honouring both the +-- search box and the category column. +local function rebuildRows() + chipGroups = {} + rowsGen = rowsGen + 1 + if not resolvedCatalog then + buildResolvedCatalog() + end + -- The keyboard page searches by the same text, lighting the keys it finds. + state.keyboard:setQuery(searchBox and searchBox:getText()) + -- The Changed section lowers the rows for its picker; any other section has them back. + state.applyListTop() + state.layoutCompare() + + rows = {} + + -- A grid category replaces the list outright: its keys only make sense laid out the + -- way the grid menu itself draws them, so there are no rows to build. + gridGroup = nil + if selectedCategory then + for _, group in ipairs(resolvedCatalog) do + if group.category == selectedCategory and group.layout == "grid" then + gridGroup = group + end + end + end + if gridGroup then + clampScroll() + + return + end + local query = Search.query(searchBox and searchBox:getText()) + + -- Which actions share each keyset, in bind order, so a chip can say what else its key + -- drives and a capture can warn before a key is taken. Rebuilt with the rows, which every + -- edit rebuilds. + local byKeyset = {} + for _, b in ipairs(working.binds) do + local c = keybindModel.canonicalKeyset(b.keyset) + local list = byKeyset[c] + if not list then + list = {} + byKeyset[c] = list + end + local listed = false + for i = 1, #list do + if list[i] == b.action then + listed = true + break + end + end + if not listed then + list[#list + 1] = b.action + end + end + working.byKeyset = byKeyset + + -- The column's Changed entry keeps only rows that differ from the base preset. How many + -- there are is counted whatever is shown, since its label says so. + local changedOnly = selectedCategory == state.changedKey + local changedCount = 0 + -- A key clicked on the keyboard page: the list shows what is bound to it and nothing else, + -- whatever the category, the search text narrowing that by name. + local filter = state.keyFilter + + -- A query can name keys as well as words. An action matches by key when one of its chips holds + -- every key the query names, modifiers included and in any order, so "ctrl+q", "ctrl q" and + -- "q ctrl" all find what Ctrl+Q does. Whole keys only, as the chips print them: "f1" does not + -- find F11, and a paired action's hidden Shift half does not answer to "shift". + local wantKeys = {} + for key in query.text:gmatch("[^%s%+]+") do + wantKeys[#wantKeys + 1] = key + end + local function boundToQuery(action) + if not (wantKeys[1] and action) then + return false + end + local pair = catalogShiftPair[action] + for _, k in ipairs(working.byAction[action] or {}) do + -- The chip's text, which for a paired action is not the keyset's own. Kept on the keyset + -- against the raw it came from, since this runs for every row on every keystroke. + local shown = k.display + if pair then + if k.unshiftedFor ~= k.raw then + k.unshifted, k.unshiftedFor = keybindModel.displayWithoutShift(k.raw, working.layout), k.raw + end + shown = k.unshifted + end + if keybindModel.holdsKeys(shown, wantKeys) then + return true + end + end + + return false + end + -- How one of the action's keysets fires from the filtered key on its layer: "exact" when + -- its first tap lands on the key (any of the engine's spellings of it) and names exactly + -- the layer's modifiers, "any" when it carries Any+ instead, which fires on every layer; + -- false when neither. Precise where the typed key search is loose: "1" here is the 1 key + -- with nothing held, not every chip holding a 1. + local function boundToFilter(action) + local any = false + for _, k in ipairs(working.byAction[action] or look.noRaws) do + local mods, keyToken = keybindModel.splitElement(canonOf(k)) + if keyToken and filter.tokens[keyToken] then + if mods.any then + any = true + else + local same = true + for name in pairs(mods) do + if not filter.mods[name] then + same = false + end + end + for name in pairs(filter.mods) do + if not mods[name] then + same = false + end + end + if same then + return "exact" + end + end + end + end + + return any and "any" or false + end + -- Whether an action is listed by key: under a filter, by the filtered key and then the + -- search text, answering how it is bound there; otherwise by the keys the search text + -- names, within the category shown. + local function keyHit(action, label, inCategory) + if filter then + local how = boundToFilter(action) + + return how and (Search.matches(query, label:lower()) or Search.matches(query, action:lower())) and how + end + + return inCategory and boundToQuery(action) + end + -- Rows found by key are listed ahead of everything found by name, under a heading of their + -- own, and only there. Gathered as they are met, so they keep the catalog's order. + local keyRows = {} + local catalogActions = {} + local otherGroupEnd + + -- Claim hidden actions up front so they never surface, as a row or under Other. + -- Exact ids only (not prefixes), so a future action can't be hidden by coincidence. + for _, group in ipairs(resolvedCatalog) do + if group.hidden then + for _, h in ipairs(group.hidden) do + catalogActions[h] = true + end + end + end + + for _, group in ipairs(resolvedCatalog) do + -- Non-selected groups are still walked: they have to claim their actions or the + -- leftovers below would sweep them all into Other. + local inCategory = filter ~= nil or not selectedCategory or changedOnly or group.category == selectedCategory + -- A group whose own title matches keeps every row under it, so searching for a + -- category's name shows the category rather than emptying it. + local categoryMatch = Search.claims(query, group.titleLower) + local groupRows = {} + local keyLink + for _, item in ipairs(group.items) do + -- An empty prefix would claim every bound action, so treat it as no prefix. + if item.prefix and item.prefix ~= "" then + -- A declared member is a row whether or not it is bound, so unbinding the last + -- key of "group select 3" leaves the row there to bind again. Families the + -- catalog cannot enumerate (buildunit_ is per unit) list no members and are + -- still discovered from what is bound. + local matched = {} + for _, member in ipairs(item.members or {}) do + local action = item.prefix .. member + -- Skipped when an explicit entry already covers it, or a family whose + -- members are also listed individually renders each of them twice. + if not catalogActions[action] then + catalogActions[action] = true + matched[#matched + 1] = action + end + end + + local found = {} + for action in pairs(working.byAction) do + if not catalogActions[action] and action:sub(1, #item.prefix) == item.prefix then + catalogActions[action] = true + found[#found + 1] = action + end + end + table.sort(found) + for i = 1, #found do + matched[#matched + 1] = found[i] + end + for i = 1, #matched do + local action = matched[i] + local arg = action:sub(#item.prefix + 1) + if item.unit then + local def = UnitDefNames[arg] + if def then + arg = def.translatedHumanName or arg + else + -- Factions gated behind modoptions (Legion, scavengers) aren't in + -- UnitDefNames, but their names live in the units i18n regardless. + local key = "units.names." .. arg + local name = BAR.I18N(key) + if name ~= key then + arg = name + end + end + end + local row, col = arg:match("^%s*(%S+)%s+(%S+)") + local label = item.label and prefixRowLabel(item.label, arg, row, col) or action + state.labels[action] = label + local change = rowChange(action) + if change then + changedCount = changedCount + 1 + end + local byKey = keyHit(action, label, inCategory) + if + (change or not changedOnly) + and ( + byKey + or ( + not filter + and ( + categoryMatch + or Search.matches(query, action:lower()) + or Search.matches(query, label:lower()) + ) + ) + ) + then + local entry = { + type = "editable", + action = action, + label = label, + description = item.description, + change = change, + filterAny = byKey == "any", + } + if not byKey then + groupRows[#groupRows + 1] = entry + elseif group.layout == "grid" then + keyLink = group + else + keyRows[#keyRows + 1] = entry + end + end + end + -- Skip an action a hidden entry or an earlier prefix already claimed, so a + -- hide stays authoritative and entry order can't produce a duplicate row. + elseif not (item.action and catalogActions[item.action]) then + if item.action then + catalogActions[item.action] = true + end + local change = rowChange(item.action) + if change then + changedCount = changedCount + 1 + end + local byKey = keyHit(item.action, item.label, inCategory) + if + (change or not changedOnly) + and ( + byKey + or ( + not filter + and ( + categoryMatch + or Search.matches(query, item.labelLower) + or Search.matches(query, item.actionLower) + ) + ) + ) + then + local entry = { + type = "editable", + action = item.action, + label = item.label, + cursor = item.cursor, + cursorColumn = group.hasCursors, + description = item.description, + change = change, + filterAny = byKey == "any", + } + if not byKey then + groupRows[#groupRows + 1] = entry + elseif group.layout == "grid" then + keyLink = group + else + keyRows[#keyRows + 1] = entry + end + end + end + end + + -- A grid category's keys only read laid out, so a key found among them points at that view. + if keyLink then + keyRows[#keyRows + 1] = { type = "link", label = group.title, category = group.category } + end + + if inCategory and #groupRows > 0 then + rows[#rows + 1] = { type = "header", text = group.title } + if group.layout == "grid" then + -- Its keys only read laid out, so the list points at that view rather than + -- repeating them flat. Still driven by the rows a search matched, so hunting + -- for one of them surfaces the way in. + rows[#rows + 1] = { type = "link", label = L.edit, category = group.category } + else + for i = 1, #groupRows do + rows[#rows + 1] = groupRows[i] + end + end + if group.title == L.other then + otherGroupEnd = #rows + end + end + end + + local otherMatch = Search.claims(query, L.otherLower) + local others, otherKeyed = {}, {} + local inOther = not selectedCategory or changedOnly or selectedCategory == otherCategoryKey + for action in pairs(working.byAction) do + if not catalogActions[action] then + local change = rowChange(action) + if change then + changedCount = changedCount + 1 + end + if changedOnly and not change then + -- Not what the column entry asked for. + elseif keyHit(action, action, inOther) then + otherKeyed[#otherKeyed + 1] = action + elseif not filter and (otherMatch or Search.matches(query, action:lower())) then + others[#others + 1] = action + end + end + end + -- Leftovers found by key join the other key rows, in a steady order. + table.sort(otherKeyed) + for _, action in ipairs(otherKeyed) do + keyRows[#keyRows + 1] = { + type = "editable", + action = action, + label = action, + change = rowChange(action), + filterAny = filter ~= nil and boundToFilter(action) == "any", + } + end + + if #others > 0 and inOther then + table.sort(others) + + -- A catalog category can be titled the same as this generated one; when it is, + -- the leftovers join it after its own items instead of repeating the header. + local tail = {} + if otherGroupEnd then + for i = otherGroupEnd + 1, #rows do + tail[#tail + 1] = rows[i] + rows[i] = nil + end + else + rows[#rows + 1] = { type = "header", text = L.other } + end + + for _, action in ipairs(others) do + rows[#rows + 1] = { type = "editable", action = action, label = action, change = rowChange(action) } + end + for i = 1, #tail do + rows[#rows + 1] = tail[i] + end + end + + -- The key rows go on top, under a heading that names the keys the way a chip would. One + -- cursor among them gives them all the column, as it does within a category. + -- Under a key filter the heading is always there, since it is where the filter is cleared. + if #keyRows > 0 or filter then + -- Modifiers ahead of the key, as a chip prints them, whatever order they were typed in. + local modifierAt = { ctrl = 1, alt = 2, meta = 3, shift = 4 } + local keys, column = {}, false + for i = 1, #wantKeys do + keys[i] = { name = wantKeys[i]:upper(), at = (modifierAt[wantKeys[i]] or 5) * 100 + i } + end + table.sort(keys, function(a, b) + return a.at < b.at + end) + for i = 1, #keys do + keys[i] = keys[i].name + end + for i = 1, #keyRows do + column = column or keyRows[i].cursor ~= nil + end + local named = filter and filter.display or table.concat(keys, " + ") + local ordered = { + { type = "header", text = BAR.I18N("ui.keybinds.editor.boundTo", { keys = named }), clear = filter ~= nil }, + } + -- Under a filter on a layer with modifiers, what fires through Any+ is set apart under a + -- heading of its own: it does fire on that layer, but its chip reads as the bare key, + -- and side by side with the exact bindings that reads as a mistake. + local anyRows = {} + for i = 1, #keyRows do + keyRows[i].cursorColumn = column + keyRows[i].hitKeys = wantKeys + if keyRows[i].filterAny and filter and next(filter.mods) then + anyRows[#anyRows + 1] = keyRows[i] + else + ordered[#ordered + 1] = keyRows[i] + end + end + if #anyRows > 0 then + ordered[#ordered + 1] = { type = "header", text = L.boundToAny } + for i = 1, #anyRows do + ordered[#ordered + 1] = anyRows[i] + end + end + for i = 1, #rows do + ordered[#ordered + 1] = rows[i] + end + rows = ordered + end + + -- The column's Changed entry comes and goes with the count, and says it. Taking the entry + -- away from under the selection sends the column back to everything, which is a different + -- list from the one just built: built again, once, with the selection gone. + if state.changedCount ~= changedCount then + state.changedCount = changedCount + state.syncChangedEntry() + if changedOnly and selectedCategory ~= state.changedKey then + return rebuildRows() + end + end + + -- The Changed section with nothing to list says why: no preset is being compared with, + -- or nothing differs from the one that is. + if changedOnly and not filter and #rows == 0 then + if not state.base then + rows[1] = { type = "note", text = L.compareNoneHint } + else + rows[1] = { + type = "note", + text = BAR.I18N("ui.keybinds.editor.changedNothing", { name = state.base.name }), + } + end + end + + clampScroll() +end + +-- Where the rows start: the band's top, or a picker's band lower when the Changed section +-- is showing. Everything that draws, scrolls or hit-tests rows reads listTop, so the whole +-- band moves as one. +function state.applyListTop() + if not metrics.listTopBase then + return + end + listTop = metrics.listTopBase - (state.compareBand() and metrics.compareBandH or 0) +end + +-- Whether the comparison picker's band is up: the Changed section, on the list page. +function state.compareBand() + return state.page == "list" and selectedCategory == state.changedKey and not gridGroup +end + +-- Places the comparison strip and the picker at its right end, once the band is placed. The +-- strip's rect and the caption's place are kept in metrics for the draw. +function state.layoutCompare() + local dd = state.compareDropdown + if not (dd and metrics.listTopBase and metrics.compareStripH) then + return + end + local y2 = metrics.listTopBase - floor(2 * scale) + local y1 = y2 - metrics.compareStripH + metrics.compareY1, metrics.compareY2 = y1, y2 + local inset = floor(4 * scale) + local w = floor(280 * scale) + local x2 = listRight - metrics.rowPad + dd:setRect(x2 - w, y1 + inset, x2, y2 - inset, floor((y2 - y1 - inset * 2) * 0.5)) + -- The caption sits right against the picker, as the header's "Preset" does. + metrics.compareCaptionX = x2 - w - floor(8 * scale) + metrics.compareFs = floor((y2 - y1 - inset * 2) * 0.5) +end + +-- The picker's options: none, then every other preset, the shipped ones tagged as defaults +-- and ruled off from the player's own. Selected: whatever the active preset is compared +-- with now. +function state.compareOptions() + local active = profiles.activeName() + local options = { { label = L.compareNone or "None", none = true } } + for _, b in ipairs(profiles.builtins) do + if b.name ~= active then + options[#options + 1] = { label = b.name, name = b.name, tag = L.defaultTag, group = "default" } + end + end + -- The store lists its profiles by name. + for _, name in ipairs(profiles.list()) do + if name ~= active then + options[#options + 1] = { label = name, name = name, group = "own" } + end + end + local selected = 1 + local base = state.base and state.base.name + for i, o in ipairs(options) do + if o.name and o.name == base then + selected = i + end + end + + return options, selected +end + +function state.refreshCompare() + local dd = state.compareDropdown + if not dd then + return + end + local options, selected = state.compareOptions() + dd:setOptions(options) + dd:setSelected(selected) + state.layoutCompare() +end + +-- The player picked what to compare the active preset with. Recorded on the preset, so it +-- holds across sessions; "none" is a choice too, and stays one. +function state.pickBase(option) + if not activeIsOwn() then + return + end + profiles.setBase(profiles.activeName(), option and option.name or nil) + state.refreshBase() + rebuildRows() +end + +-- Filters the list to one key of the keyboard page, or clears the filter. The keyboard +-- lights the key while the filter stands. +function state.setKeyFilter(filter) + state.keyFilter = filter + state.keyboard:setFilter(filter and { id = filter.id, layer = filter.layer } or nil) + scroll = 0 + rebuildRows() +end + +---------------------------------------------------------------- +-- Staging +---------------------------------------------------------------- + +-- Staged-edits flag, read by the picker marker and the footer buttons alike. +local function setDirty(value) + dirty = value +end + +-- Re-reading the engine replaces whatever was staged, so the flag describing those edits +-- goes with them. Without that a language or layout change leaves the flag armed over +-- bindings it no longer describes, and Save writes the engine's keymap back as an edit. +local function seedWorkingFromEngine() + local model = keybindModel.build() + working = { byAction = {}, layout = model.layout, binds = model.binds } + for _, entry in ipairs(model.actions) do + local copy = {} + for _, k in ipairs(entry.keysets) do + copy[#copy + 1] = { raw = k.raw, display = k.display } + end + working.byAction[entry.action] = copy + end + + setDirty(false) + -- A fresh keymap has nothing to take back; what comes after is measured from here. + state.undo = {} + state.snapshot = state.snapshotOf() +end + +-- Detached copy of the staged binds, for handing to the store. +local function stagedBinds() + local out = {} + for i, b in ipairs(working.binds) do + out[i] = { keyset = b.keyset, action = b.action } + end + + return out +end + +-- Nothing in the editor rebinds the meta key, so a profile made from another one keeps +-- whatever that had; dropping it would silently change the new profile's modifiers. +local function activeFakeMeta() + local name = profiles.activeName() + local source = profiles.get(name) or profiles.isBuiltin(name) + + return source and source.fakeMeta or nil +end + +-- Which build menu a profile implies. Only the shipped ones imply anything: a profile of +-- the player's own leaves the menu alone, so the settings toggle stays theirs to set. +-- Read from the binds rather than the name, since the grid menu is inert without its +-- gridmenu_* keys and the build menu is what buildunit_* hotkeys drive. +local function wantsGridMenu(name) + local source = profiles.isBuiltin(name) + if not source then + return nil + end + + local buildunit = false + for _, b in ipairs(source.binds or {}) do + local action = b.action:lower() + if action:find("gridmenu_", 1, true) == 1 then + return true + elseif action:find("buildunit_", 1, true) == 1 then + buildunit = true + end + end + + if buildunit then + return false + end + + return nil +end + +-- Points the engine at a profile and brings the rest of the UI in line with it. +local function applyActiveProfile(name, fromName) + Spring.SetConfigString("KeybindingFile", customKeysFile) + if fromName and fromName ~= name then + Spring.Echo("Keybind profile: " .. fromName .. " -> " .. name) + end + if menuToggle then + menuToggle(wantsGridMenu(name)) + end + + if WG["bar_hotkeys"] and WG["bar_hotkeys"].reloadBindings then + WG["bar_hotkeys"].reloadBindings() + else + view.refresh() + end +end + +-- The shipped preset the active one is measured against, and its keysets by action, redone +-- when the active preset's origin changes. The column's Changed entry comes and goes with it: +-- a preset with no known origin has nothing to have changed from. +function state.refreshBase() + -- Which pairs of actions the game itself puts on one key, in any shipped preset. Built + -- once, the shipped presets not changing. + if not state.shippedPairs then + local shipped = {} + for _, b in ipairs(profiles.builtins) do + local byKeyset = {} + for _, bind in ipairs(b.binds or {}) do + local c = keybindModel.canonicalKeyset(bind.keyset) + local list = byKeyset[c] + if not list then + list = {} + byKeyset[c] = list + end + local listed = false + for i = 1, #list do + if list[i] == bind.action then + listed = true + end + end + if not listed then + list[#list + 1] = bind.action + end + end + for _, list in pairs(byKeyset) do + for i = 1, #list do + for j = i + 1, #list do + local a, o = list[i], list[j] + shipped[(a < o) and (a .. "\n" .. o) or (o .. "\n" .. a)] = true + end + end + end + end + state.shippedPairs = shipped + end + + local base = profiles.baseOf(profiles.activeName()) + local wanted = base and base.name or nil + if (state.base and state.base.name) ~= wanted then + if base then + local byAction = {} + for _, b in ipairs(base.binds or {}) do + local entry = byAction[b.action] + if not entry then + entry = { set = {}, n = 0, raws = {} } + byAction[b.action] = entry + end + local c = keybindModel.canonicalKeyset(b.keyset) + if not entry.set[c] then + entry.set[c] = true + entry.n = entry.n + 1 + entry.raws[#entry.raws + 1] = b.keyset + end + end + state.base = { name = wanted, byAction = byAction } + else + state.base = nil + end + state.changedCount = -1 + end + state.syncChangedEntry() + state.refreshCompare() +end + +-- The column's Changed entry: there for every preset of the player's own, with the count of +-- rows differing from the compared preset in its label, or a question mark while nothing is +-- being compared with. A shipped preset is measured against itself, so it only has the entry +-- while staged edits differ from it: a "Changed (0)" on a default is noise. With the entry +-- gone from under the selection, the column falls back to everything. +function state.syncChangedEntry() + local listed = categories[2] ~= nil and categories[2].key == state.changedKey + if activeIsOwn() or (state.base and state.changedCount > 0) then + local label = L.changedUnknown or "?" + if state.base then + label = BAR.I18N("ui.keybinds.editor.changedCount", { n = math.max(0, state.changedCount) }) + end + if not listed then + table.insert(categories, 2, { label = label, key = state.changedKey }) + state.refit = true + elseif categories[2].label ~= label then + categories[2].label = label + state.refit = true + end + elseif listed then + table.remove(categories, 2) + if selectedCategory == state.changedKey then + selectedCategory = nil + end + state.refit = true + end +end + +local function refreshPicker() + buildPresetOptions() + presetDropdown:setOptions(presetOptions) + presetDropdown:setSelected(currentPresetIndex()) + state.refreshBase() + -- Whether the active preset is a default settles the Save button's wording, and so its + -- width, and what the header tooltips say. Laid out again on the next draw, once, however + -- many times this runs before it. + state.layoutPending = true +end + +-- Files the keymap as it stood before the edit just made, then takes the one it stands at +-- now, for the edit after. +function state.pushUndo() + state.undo[#state.undo + 1] = state.snapshot + state.snapshot = state.snapshotOf() +end + +-- Staging changes the picker too: the active profile picks up the unsaved marker. A gesture +-- that stages several edits files one snapshot for the lot, once it is done. +local function markStaged() + if state.batching then + state.batchEdited = true + else + state.pushUndo() + end + setDirty(true) + refreshPicker() + rebuildRows() +end + +-- Ctrl+Z: the last edit taken back. With none left the keymap is what was loaded, so there +-- is nothing unsaved either. Answers whether there was anything to take back. +function state.undoEdit() + local snap = table.remove(state.undo) + if not snap then + return false + end + + working.binds, working.byAction = snap.binds, snap.byAction + -- Copied again: the restored tables are live now, and the next edit rewrites them. + state.snapshot = state.snapshotOf() + setDirty(#state.undo > 0) + refreshPicker() + rebuildRows() + + return true +end + +-- Staged edits live only in `working`, so throwing them away means re-reading the engine. +-- Clearing the flag on its own would leave the edits on screen and still saveable. +local function discardStaged() + seedWorkingFromEngine() + refreshPicker() + rebuildRows() +end + +---------------------------------------------------------------- +-- Dialogs +---------------------------------------------------------------- + +-- Raises a modal, taking focus off the search box. +local function openDialog(d) + -- Replacing a live modal outright would drop the rollback its cancel was holding, which + -- is how the picker ends up naming a profile that was never switched to. Run it first, + -- with the slot already empty so the cancel cannot clobber the incoming dialog. + local previous = dialog + dialog = nil + if previous and previous.cancel then + previous.cancel() + end + + -- A modal draws over the capture and takes its keys, so leaving one running behind would + -- drop the player back into it on cancel, seeded from bindings the modal may have changed. + capturing = nil + + dialog = d + searchBox:blur() + if not d.message then + nameBox:setText(d.initial or "") + nameBox:focus() + end +end + +-- Drops the modal, handing it back so the caller can act on it. +local function closeDialog() + local d = dialog + dialog = nil + nameBox:blur() + + return d +end + +local function cancelDialog() + local d = closeDialog() + if d and d.cancel then + d.cancel() + end +end + +-- The optional third button: "Discard" when leaving unsaved edits, "Delete" when +-- editing a profile. +local function middleDialog() + local d = closeDialog() + if d and d.middle then + d.middle.action() + end +end + +-- A name already in use would be renumbered on the way into the store, handing back a +-- profile nobody asked for. +local function dialogName() + if not dialog or dialog.message then + return "", false + end + + local name = nameBox:getText():gsub("^%s+", ""):gsub("%s+$", "") + local taken = name ~= dialog.allow and (profiles.get(name) ~= nil or profiles.isBuiltin(name) ~= nil) + + -- A dialog can be blocked outright, like an import with nothing to import. + return name, name == "" or taken or dialog.blocked == true +end + +-- Confirmation path; only a dialog with a name field has text to read. +local function acceptDialog() + local name, blocked = dialogName() + if blocked then + return + end + + local d = closeDialog() + if not d then + return + end + + d.accept(name) +end + +---------------------------------------------------------------- +-- Profile commands +---------------------------------------------------------------- + +-- Makes a profile the live one, leaving its stored binds alone. Answers whether it took. +-- A keymap that never reached disk must not clear the staged flag: the reload below would +-- load whatever file is still there and the player would watch their edits revert. +local function selectProfile(name, fromName) + if not profiles.materialize(name) then + openDialog({ + title = L.applyFailedTitle, + message = BAR.I18N("ui.keybinds.editor.applyFailedMessage", { name = name }), + accept = function() end, + }) + + return false + end + + profiles.setActive(name) + setDirty(false) + -- What was staged is now the preset's own, or gone with the switch: nothing to take back. + state.undo = {} + state.snapshot = state.snapshotOf() + refreshPicker() + applyActiveProfile(name, fromName) + + return true +end + +-- Commit point: the staged keymap reaches the engine and the store together. Answers +-- whether it landed, so a caller that closes the panel on the way out does not do so over +-- a save that failed. +local function applyStaged(name, fromName) + local profile = profiles.get(name) + if not profile then + return selectProfile(name, fromName) + end + + -- The store has to hold the new binds before materialize can write them out, so a failed + -- apply is undone rather than avoided. Left as-is, disk would keep edits the editor still + -- reports as unsaved, and Reset would appear to discard something already persisted. + local previous = profile.binds + profile.binds = stagedBinds() + profiles.save() + + if selectProfile(name, fromName) then + return true + end + + profile.binds = previous + profiles.save() + + return false +end + +-- Saving over a shipped profile is a fork: it asks for a name and writes a new one. +local function startSave(andThen, onCancel) + local name = profiles.activeName() + if profiles.get(name) then + if applyStaged(name) and andThen then + andThen() + end + + return + end + + openDialog({ + title = L.saveTitle, + initial = profiles.uniqueName(L.newProfile), + accept = function(newName) + -- Forked from the default on screen, which the new preset records as its origin. + local created = profiles.create(newName, stagedBinds(), activeFakeMeta(), name) + if applyStaged(created, name) and andThen then + andThen() + end + end, + cancel = onCancel, + }) +end + +-- Staged edits are not in the engine yet, so anything that would replace them asks +-- first. Returns whether it could go ahead immediately. +local function guardDirty(proceed, onCancel) + if not dirty then + proceed() + + return true + end + + openDialog({ + title = L.unsavedTitle, + message = L.unsavedMessage, + -- Worded like the footer's Save, which this stands in for. + acceptLabel = activeIsOwn() and L.save or L.saveAsNew, + save = true, + accept = function() + startSave(proceed, onCancel) + end, + middle = { + label = L.discard, + danger = true, + action = function() + discardStaged() + proceed() + end, + }, + cancel = onCancel, + }) + + return false +end + +switchToPreset = function(opt) + -- Already what is on screen, staged edits and all, so picking it again is not a switch: + -- it would only ask about edits the player has not tried to leave. + if opt.name == profiles.activeName() then + return + end + + guardDirty(function() + -- The picker committed the new name before the guard ran, so a switch that does not + -- happen has to put the selection back. + if not selectProfile(opt.name, profiles.activeName()) then + refreshPicker() + end + end, refreshPicker) +end + +-- Throws staged edits away, back to the active profile as last saved. +local function startReset() + openDialog({ + title = L.reset, + message = L.resetConfirm, + -- Named for what it does. Without this it falls back to the generic "Accept", which + -- says nothing about the edits being thrown away, and coloured for it. + acceptLabel = L.discard, + danger = true, + accept = function() + discardStaged() + end, + }) +end + +local function startDuplicate() + local from = profiles.activeName() + openDialog({ + title = L.duplicateTitle, + initial = profiles.uniqueName(from), + accept = function(name) + -- Copies what is on screen rather than what was last saved, so pending + -- edits come along instead of being silently dropped. The copy descends from + -- whatever the original did. + local base = profiles.baseOf(from) + applyStaged(profiles.create(name, stagedBinds(), activeFakeMeta(), base and base.name), from) + end, + }) +end + +-- Export copies the preset on screen, staged edits included, to the clipboard as the text the +-- engine loads; Import reads such text back as a new preset of the player's own. +local function startClipboard(exporting) + if exporting then + local name = profiles.activeName() + Spring.SetClipboard(profiles.exportText({ name = name, binds = stagedBinds(), fakeMeta = activeFakeMeta() })) + openDialog({ + title = L.export, + message = BAR.I18N("ui.keybinds.editor.exportDone", { name = name }), + info = true, + acceptLabel = L.ok, + accept = function() end, + }) + + return + end + + local clip = Spring.GetClipboard() + if type(clip) ~= "string" or clip:match("^%s*$") then + openDialog({ title = L.import, message = L.importEmpty, info = true, acceptLabel = L.ok, accept = function() end }) + + return + end + + -- What the reader will take, line by line, shown before it is taken: the lines it will + -- drop in red, and a count of each above them. With nothing readable the dialog still + -- opens, so the player can see why, but cannot be accepted. + local lines, count, errors = profiles.classifyBindFile(clip) + local binds, fakeMeta, stamped = profiles.parseBindFile(clip) + local summary = binds and (colorText .. BAR.I18N("ui.keybinds.editor.importSummary", { n = count })) + or (colorDanger .. L.importNone) + if errors > 0 then + summary = summary .. colorDim .. ", " .. colorHeader .. BAR.I18N("ui.keybinds.editor.importErrors", { n = errors }) + end + local function open() + openDialog({ + title = L.importTitle, + initial = profiles.uniqueName(stamped or L.newProfile), + preview = { lines = lines, summary = summary, scroll = 0 }, + blocked = binds == nil, + acceptLabel = L.import, + accept = function(newName) + -- Named like a copy is, then made live: importing is switching to it. + selectProfile(profiles.create(newName, binds, fakeMeta), profiles.activeName()) + end, + }) + end + + -- Importing replaces what is on screen, so staged edits are asked about first - but not + -- over a preview that cannot be accepted anyway. + if binds then + guardDirty(open) + else + open() + end +end + +-- Renaming and deleting share one dialog: the name field commits a rename, the +-- middle button deletes. Deleting asks again, since it cannot be undone. +local function startEdit() + local name = profiles.activeName() + openDialog({ + title = L.editTitle, + initial = name, + allow = name, + accept = function(newName) + profiles.rename(name, newName) + refreshPicker() + end, + middle = { + label = L.delete, + danger = true, + action = function() + openDialog({ + title = L.delete, + message = BAR.I18N("ui.keybinds.editor.deleteConfirm", { name = name }), + acceptLabel = L.delete, + danger = true, + accept = function() + -- Re-seeded rather than just unflagged: clearing the flag alone would + -- leave the deleted profile's edits on screen with Save greyed out. + discardStaged() + profiles.delete(name) + + -- Whatever the store fell back to has to be made live; the deleted profile + -- is still what the engine has loaded. Selected rather than committed: the + -- staged keymap belongs to the profile just deleted. + selectProfile(profiles.activeName(), name) + end, + }) + end, + }, + }) +end + +---------------------------------------------------------------- +-- Layout and geometry +---------------------------------------------------------------- + +-- Builds the controls on first use, the font not existing at include time. +local function ensureControls() + if searchBox and presetDropdown then + return + end + + -- Each control prints live, every frame, on the shared font, so each pins the panel's + -- outline for itself. + searchBox = Editbox.new({ + placeholder = BAR.I18N("ui.keybinds.editor.search"), + clearable = true, + onChange = rebuildRows, + outline = look.outline, + }) + presetDropdown = Dropdown.new({ + options = presetOptions, + onSelect = switchToPreset, + markSelected = true, + outline = look.outline, + }) + nameBox = Editbox.new({ maxChars = 40, outline = look.outline }) + -- The Changed section's picker of what to compare the active preset with. + state.compareDropdown = Dropdown.new({ + options = {}, + onSelect = state.pickBase, + markSelected = true, + outline = look.outline, + }) +end + +-- Buttons size to their own label so a longer translation is not clipped and a short +-- one is not padded out. Layout can run before view.init has a font, so that case +-- falls back to a width and asks draw to lay out again once the font is there. +local function labelWidth(label, size, pad) + if not font then + state.layoutPending = true + + return floor(110 * scale) + end + + return floor(font:GetTextWidth(label) * size) + pad * 2 +end + +-- Accept wording, needed by the geometry as well as the drawing. +local function acceptLabelFor(d) + return d.acceptLabel or (d.message and L.accept or L.save) +end + +-- Header and footer rects, placed right to left from the panel edge. +local function layoutHeader() + state.headerH = floor(34 * scale) + state.footerH = floor(34 * scale) + + if not (searchBox and presetDropdown) then + return + end + + state.layoutPending = false + + local gap = floor(8 * scale) + local rowTop = area.y2 - floor(4 * scale) + local rowBottom = area.y2 - state.headerH + floor(4 * scale) + -- Room for the longest shipped name beside its Default tag. + local presetW = floor(280 * scale) + local btnFs = floor((rowTop - rowBottom) * 0.5) + local bfs = floor(rowHeight * 0.55) + + -- Right to left: the clipboard buttons, the edit dialog opener, duplicate, the picker they + -- act on, then the picker's caption. Icon buttons are square; captioned ones fit their word. + local iconW = rowTop - rowBottom + local bx2 = area.x2 - metrics.edgeInset + for i = #headerButtons, 1, -1 do + local b = headerButtons[i] + local w = iconW + if not b.icon then + local label = L[b.id] or b.id + w = labelWidth(label, bfs, floor(10 * scale)) + if font then + b.textOn = colorText .. label + b.textOff = colorFaded .. label + end + end + b.rect = { bx2 - w, rowBottom, bx2, rowTop } + bx2 = floor(bx2 - w - gap * (b.gap or 1)) + end + local pickerX1 = bx2 - presetW + metrics.presetLabelX = pickerX1 - gap - labelWidth(L.preset or "", btnFs, 0) + metrics.presetLabelFs = btnFs + if font then + -- The baseline the picker and the search field print their own text on. + metrics.presetLabelY = text.baseline(font, rowBottom, rowTop, btnFs) + end + + presetDropdown:setRect(pickerX1, rowBottom, pickerX1 + presetW, rowTop, btnFs) + -- Twice the gap on this side, so the caption reads as the picker's and not the field's. + searchBox:setRect(listX1, rowBottom, metrics.presetLabelX - gap * 2, rowTop, btnFs) + + local fTop = area.y1 + state.footerH - floor(4 * scale) + local fBottom = area.y1 + floor(4 * scale) + local fFs = floor((fTop - fBottom) * 0.5) + local fPad = floor(14 * scale) + local x2 = area.x2 - metrics.edgeInset + -- A default cannot take the edits, so its Save is worded for where they go instead. + local own = activeIsOwn() + for i = #footerButtons, 1, -1 do + local b = footerButtons[i] + local label = (b.id == "save" and not own and L.saveAsNew) or L[b.id] or b.id + local w = labelWidth(label, fFs, fPad) + b.rect = { x2 - w, fBottom, x2, fTop } + x2 = x2 - w - gap + -- Both states of the caption, fitted and coloured here so the draw only picks one. + if font then + local fitted = text.fit(font, label, w - metrics.rowPad * 2, bfs) + b.textOn = colorText .. fitted + b.textOff = colorFaded .. fitted + end + end + + -- The footer notice gets what the buttons leave: from the list's left edge to a double gap + -- short of the first button. Each wording is fitted here, so the bake only picks one. + metrics.noticeX = listX1 + metrics.noticeFs = floor(rowHeight * 0.5) + if font then + local nfs = metrics.noticeFs + local noticeW = x2 - gap - listX1 + metrics.noticeY = text.baseline(font, fBottom, fTop, nfs) + L.noticeDefaultText = colorDim .. text.fit(font, L.noticeDefault or "", noticeW, nfs) + L.noticeDefaultUnsavedText = colorHeader .. text.fit(font, L.noticeDefaultUnsaved or "", noticeW, nfs) + L.noticeUnsavedText = colorHeader .. text.fit(font, L.noticeUnsaved or "", noticeW, nfs) + end + + -- New rects, so the tooltip areas have to be handed over again. + state.tooltipsRegistered = false +end + +-- Profile-modal geometry, derived in one place so draw and mousePress agree. +-- A dialog with a preview is wider and taller, the preview taking the room above the name +-- field; an information dialog has one button, OK, in the middle, and no Cancel. +local function dialogGeometry() + local preview = dialog and dialog.preview + local w = floor((preview and 620 or 315) * scale) + local h = floor((preview and 420 or 150) * scale) + local messageLines, messageStep + if dialog and dialog.message and font then + messageStep = floor(rowHeight * 0.75) + messageLines = text.wrap(font, dialog.message, w - floor(32 * scale), floor(rowHeight * 0.5)) + h = h + math.max(0, #messageLines - 1) * messageStep + end + local cx = (area.x1 + area.x2) * 0.5 + local cy = (area.y1 + area.y2) * 0.5 + local bx1, bx2 = floor(cx - w * 0.5), floor(cx + w * 0.5) + local by1, by2 = floor(cy - h * 0.5), floor(cy + h * 0.5) + local bh = floor(28 * scale) + local pad = floor(16 * scale) + local btnY1 = by1 + pad + local bfs = floor(bh * 0.5) + local bpad = floor(14 * scale) + + local okW = labelWidth(dialog and acceptLabelFor(dialog) or L.save, bfs, bpad) + local ok, cancel + if dialog and dialog.info then + ok = { floor(cx - okW * 0.5), btnY1, floor(cx + okW * 0.5), btnY1 + bh } + else + local cancelW = labelWidth(L.cancel, bfs, bpad) + cancel = { bx1 + pad, btnY1, bx1 + pad + cancelW, btnY1 + bh } + ok = { bx2 - pad - okW, btnY1, bx2 - pad, btnY1 + bh } + end + + local midW = labelWidth(dialog and dialog.middle and dialog.middle.label or L.discard, bfs, bpad) + local midX = (bx1 + bx2) * 0.5 + local discard = { floor(midX - midW * 0.5), btnY1, floor(midX + midW * 0.5), btnY1 + bh } + local fieldY1 = btnY1 + bh + floor(20 * scale) + local field = { bx1 + pad, fieldY1, bx2 - pad, fieldY1 + floor(26 * scale) } + + -- The preview box, from above the field to under the summary line beneath the title. + local box + if preview then + box = { bx1 + pad, field[4] + floor(14 * scale), bx2 - pad, by2 - floor(66 * scale) } + end + + return bx1, by1, bx2, by2, ok, cancel, field, discard, messageLines, messageStep, box +end + +-- Capture-modal geometry, derived in one place so draw and mousePress agree. +local function captureGeometry() + local w = floor(420 * scale) + local h = floor(200 * scale) + local cx = (area.x1 + area.x2) * 0.5 + local cy = (area.y1 + area.y2) * 0.5 + local bx1, bx2 = floor(cx - w * 0.5), floor(cx + w * 0.5) + local by1, by2 = floor(cy - h * 0.5), floor(cy + h * 0.5) + local bw = floor(120 * scale) + local bh = floor(28 * scale) + local pad = floor(16 * scale) + local btnY1 = by1 + pad + local cancel = { bx1 + pad, btnY1, bx1 + pad + bw, btnY1 + bh } + local ok = { bx2 - pad - bw, btnY1, bx2 - pad, btnY1 + bh } + return bx1, by1, bx2, by2, ok, cancel +end + +-- Category labels, shortened to the column and carrying their colour codes, so the +-- sidebar draws them as they are. Redone when the column resizes or the catalog is +-- rebuilt; a no-op until the font exists, and the sidebar asks again once it does. +local function fitCategories() + if not font then + return + end + + local labelW = sidebarW - metrics.sidePad * 2 + -- Fitted at the size they are actually drawn at, so a label is not shortened for a + -- size the column never uses. + for _, c in ipairs(categories) do + local fitted = text.fit(font, c.label, labelW, metrics.catFs) + c.textSel = colorAction .. fitted + c.textDim = colorDim .. fitted + end +end + +---------------------------------------------------------------- +-- Panel lifecycle +---------------------------------------------------------------- + +-- Picks up the font and the FlowUI entry points, which do not exist at include time. +function view.init() + font = WG["fonts"].getFont() + RectRound = WG.FlowUI.Draw.RectRound + Scroller = WG.FlowUI.Draw.Scroller + UiElement = WG.FlowUI.Draw.Element + Highlight = WG.FlowUI.Draw.SelectHighlight + UiButton = WG.FlowUI.Draw.Button + UiUnitFrame = WG.FlowUI.Draw.UnitFrame + state.keyboard:init(font) + ensureControls() +end + +-- Re-reads the engine and rebuilds everything shown from it. +function view.refresh() + ensureControls() + seedWorkingFromEngine() + resolvedCatalog = nil + -- Ahead of the picker, which labels its "new profile" entry from L. + buildResolvedCatalog() + fitCategories() + refreshPicker() + layoutHeader() + rebuildRows() +end + +-- Takes the panel rect from the host; every band and column is derived from it. +-- `wx1..wy2` is the window the area sits inside; without it a modal can only dim as far +-- as the area goes, leaving the panel's own border lit. Kept in `metrics` rather than a +-- local of its own, this chunk being at Lua's ceiling of 200. +function view.setArea(x1, y1, x2, y2, s, wx1, wy1, wx2, wy2) + ensureControls() + area.x1, area.y1, area.x2, area.y2 = x1, y1, x2, y2 + metrics.winX1, metrics.winY1 = wx1 or x1, wy1 or y1 + metrics.winX2, metrics.winY2 = wx2 or x2, wy2 or y2 + scale = s or 1 + rowHeight = floor(24 * scale) + metrics.catRowHeight = floor(29 * scale) + metrics.catBarW = math.max(3, floor(6 * scale)) + -- Whole pixels throughout: a size or a corner landing on a fraction puts glyph and + -- rectangle edges between pixels, which the renderer then blends across both. + metrics.rowFs = floor(rowHeight * 0.55) + metrics.headerRowHeight = floor(rowHeight * 1.35) + -- The heading was set at 0.95 of a row's size; 13% up from there. + metrics.headerFs = floor(metrics.rowFs * 0.95 * 1.13) + metrics.catFs = floor(metrics.catRowHeight * 0.55 * 0.85) + metrics.underlineH = math.max(1, floor(2 * scale)) + metrics.rowPad = floor(6 * scale) + metrics.sidePad = floor(12 * scale) + metrics.catInset = floor(4 * scale) + metrics.chipInset = floor(3 * scale) + metrics.cursorIcon = floor(rowHeight * 0.8) + -- Set before layoutHeader below, which places the header and footer buttons against it. + metrics.edgeInset = floor(4 * scale) + metrics.footerGap = floor(8 * scale) + metrics.listGap = floor(12 * scale) + metrics.cardLip = floor(5 * scale) + metrics.titleY = floor(17 * scale) + metrics.sidebarDrop = floor(8 * scale) + metrics.titleFs = floor(rowHeight * 0.85) + + -- Rounded like the settings panel's inner elements, which take a share of this too. + local corner = WG.FlowUI.elementCorner + metrics.csPanel = floor(corner) + metrics.csButton = floor(corner * 0.8) + metrics.csSmall = floor(corner * 0.66) + + sidebarW = floor(240 * scale) + listX1 = area.x1 + sidebarW + floor(12 * scale) + + layoutHeader() + + listTop = area.y2 - state.headerH - floor(4 * scale) + metrics.listTopBase = listTop + -- The strip the Changed section puts its comparison picker in, above its rows, and the + -- room it takes from them: the strip plus a gap, so it stands apart from the first heading. + metrics.compareStripH = floor(rowHeight * 1.45) + metrics.compareBandH = metrics.compareStripH + floor(rowHeight * 0.5) + -- The scrollbar owns a column of its own: its right edge lines up with the buttons + -- above it, and the list stops a clear gap short of it rather than running up against + -- it. That gap matches the one the bar keeps from the panel edge on its other side, so + -- the bar sits in a channel rather than hugging the rows. + local barW = floor(14 * scale) + barX1 = area.x2 - metrics.edgeInset - barW + listRight = barX1 - metrics.listGap + metrics.keyAreaX1 = listX1 + floor((listRight - listX1) * 0.45) + + -- The keyboard page takes the whole band the column and the list share, inset from the + -- panel's sides like the column's own text. + state.keyboard:setArea( + area.x1 + metrics.sidePad, + listBottom(), + area.x2 - metrics.sidePad, + metrics.listTopBase, + scale, + metrics.titleFs + ) + state.applyListTop() + state.layoutCompare() + + -- Shortened here rather than in the draw loop: the column width and the font size are + -- both settled by now, and this runs on a resize where the loop runs every frame. + fitCategories() + + layoutGen = layoutGen + 1 + clampScroll() +end + +-- Panel closing: drop focus, tooltips and any open modal. +function view.blur() + if WG["tooltip"] then + for _, b in ipairs(headerButtons) do + WG["tooltip"].RemoveTooltip(b.tooltipId) + end + end + state.tooltipsRegistered = false + state.tipKey = nil + if state.panelList then + gl.DeleteList(state.panelList) + state.panelList = nil + state.panelSig = nil + end + if searchBox then + searchBox:blur() + end + if presetDropdown then + presetDropdown:close() + end + if state.compareDropdown then + state.compareDropdown:close() + end + if nameBox then + nameBox:blur() + end + capturing = nil + -- A key filter is a view of the moment; the panel opens on the whole list next time. + if state.keyFilter then + state.keyFilter = nil + state.keyboard:setFilter(nil) + end + + -- Or the blur outlives the panel: guishader keeps drawing a rect nobody owns any more. + shade.clear() + + -- Through cancel rather than dropped: a live modal is holding a rollback, and the picker + -- names a profile that was never switched to until that runs. + cancelDialog() +end + +-- The host calls this before closing; false means a dialog is now asking what to do +-- with staged edits and the close should not happen yet. +function view.confirmClose(proceed) + return guardDirty(proceed) +end + +-- The host widget, handed over so guishader can drop this panel's blur rects with it when +-- the widget goes away. Optional: with no owner the rects are simply always allowed. +function view.setOwner(w) + shade.owner = w +end +-- Host hook for swapping the build menu when a profile implies one. +function view.setMenuToggle(fn) + menuToggle = fn +end + +-- Which page the body shows: "keyboard" for the overview, anything else for the list. The +-- host's action takes it as a word, so a key can open the panel straight onto the keyboard. +function view.setPage(page) + state.setPage(page == "keyboard" and "keyboard" or "list") +end + +-- Host hook, called with the page whenever it changes, so the host can size the panel to it. +function view.setPageHook(fn) + state.pageHook = fn +end + +---------------------------------------------------------------- +-- Editing keysets +---------------------------------------------------------------- + +-- Whether the action already carries this binding. Compared canonically rather than by +-- the printed label: the label drops Any+ and resolves scancodes through the layout, so it +-- reports two bindings the engine resolves differently as the same one. exceptRaw skips +-- the keyset being rebound. +local function actionHasKeyset(action, newKeyset, exceptRaw) + local ks = working.byAction[action] + if not ks then + return false + end + local c = keybindModel.canonicalKeyset(newKeyset) + for _, k in ipairs(ks) do + if k.raw ~= exceptRaw and keybindModel.canonicalKeyset(k.raw) == c then + return true + end + end + return false +end + +-- The bind list is kept in engine order because two actions on one keyset are tried +-- in bind order; edits touch it in place rather than rebuilding it. +local function stageAdd(action, raw) + working.binds[#working.binds + 1] = { keyset = raw, action = action } + + local ks = working.byAction[action] + if not ks then + ks = {} + working.byAction[action] = ks + end + ks[#ks + 1] = { raw = raw, display = keybindModel.displayKeyset(raw, working.layout) } +end + +local function stageRemove(action, raw) + for i = #working.binds, 1, -1 do + local b = working.binds[i] + if b.action == action and b.keyset == raw then + table.remove(working.binds, i) + break + end + end + + local ks = working.byAction[action] or {} + for i = #ks, 1, -1 do + if ks[i].raw == raw then + table.remove(ks, i) + break + end + end + -- The entry stays when its last keyset goes. Rows the catalog does not name are derived + -- from this table, so dropping it takes the row with it and there is nothing left to + -- click to bind the action again. +end + +-- Rewrite a binding where it sits. Two actions on one keyset are tried in bind order, +-- so re-adding at the end would hand the other one priority. +local function stageReplace(action, oldRaw, newRaw) + local entry + for _, b in ipairs(working.binds) do + if b.action == action and b.keyset == oldRaw then + entry = b + break + end + end + + if not entry then + return false + end + + entry.keyset = newRaw + + for _, k in ipairs(working.byAction[action] or {}) do + if k.raw == oldRaw then + k.raw = newRaw + k.display = keybindModel.displayKeyset(newRaw, working.layout) + break + end + end + + return true +end + +-- The grid menu answers its category keys whether or not Shift is held, which the engine +-- can only express as two binds. Deriving both from one capture keeps a rebind from +-- leaving the halves on different keys. +-- Built from the captured elements rather than the joined keyset, so Shift lands after any +-- other modifiers the way the engine writes them: Ctrl+K pairs as Ctrl+K and Ctrl+Shift+K. +-- Shift qualifies the first tap only; later taps in a chain are the same in both halves. +local function shiftPairRaws(elems) + local bare, shifted = {}, {} + for i = 1, #elems do + local e = elems[i] + bare[i] = e.mods .. e.sym + shifted[i] = (i == 1) and (e.mods .. "Shift+" .. e.sym) or bare[i] + end + + return { table.concat(bare, ","), table.concat(shifted, ",") } +end + +-- Rewrite every keyset an action carries, reusing the slots it already holds so the +-- rewrite does not disturb bind order. Answers whether anything actually changed. +local function stageSetKeysets(action, raws) + local ks = working.byAction[action] or {} + local existing = {} + for i = 1, #ks do + existing[i] = ks[i].raw + end + + -- Compared as a set: these are all the same action, so which keyset sits in which slot + -- carries no meaning, and a positional check would call a reordered pair a change and + -- then rewrite the slots into the order they already had. + if #existing == #raws then + local wanted = {} + for i = 1, #raws do + wanted[raws[i]] = (wanted[raws[i]] or 0) + 1 + end + for i = 1, #existing do + wanted[existing[i]] = (wanted[existing[i]] or 0) - 1 + end + + local same = true + for _, count in pairs(wanted) do + if count ~= 0 then + same = false + break + end + end + + if same then + return false + end + end + + local shared = (#existing < #raws) and #existing or #raws + for i = 1, shared do + stageReplace(action, existing[i], raws[i]) + end + for i = shared + 1, #existing do + stageRemove(action, existing[i]) + end + for i = shared + 1, #raws do + stageAdd(action, raws[i]) + end + + return true +end + +-- Edit entry point: move a binding, and mark the profile staged. +local function rebindKeyset(action, oldRaw, newKeyset) + -- Accepting the capture unchanged is not an edit. Staging it would arm Save, mark the + -- preset unsaved, and raise the unsaved-changes guard over nothing. + if newKeyset == oldRaw then + return + end + + if actionHasKeyset(action, newKeyset, oldRaw) then + stageRemove(action, oldRaw) + elseif not stageReplace(action, oldRaw, newKeyset) then + stageRemove(action, oldRaw) + stageAdd(action, newKeyset) + end + + markStaged() +end + +-- Edit entry point: extra binding for an action, and mark the profile staged. +local function addKeyset(action, newKeyset) + if actionHasKeyset(action, newKeyset) then + return + end + + stageAdd(action, newKeyset) + markStaged() +end + +-- Edit entry point: drop a binding, and mark the profile staged. +local function removeKeyset(action, raw) + -- Exactly the keyset asked for. A chip hands over every raw it stands for, so a paired + -- action loses its pair and nothing else; clearing the action outright would take any + -- other key it happens to carry with it. + stageRemove(action, raw) + markStaged() +end + +-- Puts the base preset's keys back on an action: what clicking the ghost chip does. +function state.revert(action) + local change = rowChange(action) + if not change then + return + end + + local raws = {} + for i = 1, #change do + raws[i] = change[i] + end + if stageSetKeysets(action, raws) then + markStaged() + end +end + +-- One key can drive several actions (e.g. backspace = mutesound + edit_backspace), +-- so add the binding without disturbing others on the same keyset. +local function commitCapture(keyset) + ---@type table + local c = capturing + + -- Left open rather than closed on a key the action already carries. Closing with nothing + -- changed is indistinguishable from the editor having dropped the press. + if not c.oldRaw and not catalogShiftPair[c.action] and actionHasKeyset(c.action, keyset) then + return + end + + capturing = nil + + -- One gesture, however many edits it comes to below, files one snapshot to take back. + state.batching, state.batchEdited = true, false + if catalogShiftPair[c.action] then + if stageSetKeysets(c.action, shiftPairRaws(c.elems)) then + markStaged() + end + elseif c.oldRaw then + rebindKeyset(c.action, c.oldRaw, keyset) + -- One chip stood for every binding that read as the same key, so they all move to + -- the new one. Collapsing them costs nothing: they were interchangeable already. + -- Skip the one the rebind already landed on, or this undoes it and leaves the + -- action bound to nothing. + if keyset ~= c.oldRaw then + for i = 2, #(c.oldRaws or {}) do + if c.oldRaws[i] ~= keyset then + removeKeyset(c.action, c.oldRaws[i]) + end + end + end + else + addKeyset(c.action, keyset) + end + state.batching = false + if state.batchEdited then + state.pushUndo() + end +end + +---------------------------------------------------------------- +-- Modifiers and key capture +---------------------------------------------------------------- + +local function rawHasAny(raw) + return raw ~= nil and raw:find("[Aa][Nn][Yy]%+") ~= nil +end + +-- Nothing lets a player choose this, so a rebind infers it: the catalog's alwaysModifier +-- flag first, then whatever the action is bound with today. The engine's own stateful +-- commands (CKeyBindings::statefulCommands - drawinmap, the move* family) carry the flag in +-- the catalog rather than a list here, so one place states it and every surface can read it. +local function actionUsesAny(action, oldRaw) + if catalogAny[action] then + return true + end + + for _, prefix in ipairs(catalogAnyPrefixes) do + if action:sub(1, #prefix) == prefix then + return true + end + end + + -- Rebinding one keyset keeps that keyset's own qualifier: another keyset of the same + -- action carrying Any+ says nothing about this one, and inheriting it would silently + -- drop the modifiers the player just pressed. + if oldRaw then + return rawHasAny(oldRaw) + end + + -- Actions the catalog does not list still reach the editor under Other, so fall back + -- to what they are bound with today. + for _, k in ipairs(working.byAction[action] or {}) do + if rawHasAny(k.raw) then + return true + end + end + + return false +end + +-- A press within the timeout extends the sequence; a slower one starts over. +local function appendChain(el) + local c = capturing + if not c then + return + end + + -- The first real press replaces what the modal opened showing. + if c.seeded then + c.seeded = false + c.elems = {} + end + + local now = spGetTimer() + if #c.elems == 0 then + c.elems[1] = el + elseif c.lastPress and spDiffTimers(now, c.lastPress) * 1000 <= c.timeout then + c.elems[#c.elems + 1] = el + else + c.elems = { el } + end + + c.lastPress = now +end + +-- Derived from the one canonical list so a modifier added there is understood here too. +-- modPrefix below emits in the same order, reading the state Spring returns positionally. +local modNames = {} +for i, name in ipairs(keyConfig.modifierOrder) do + modNames[i] = name .. "+" +end + +-- Strip modifiers by name so a "+"-key (e.g. numpad+) survives; the Any+ qualifier is +-- carried on the capture rather than in the element. +local function parseElem(raw) + raw = raw:gsub("[Aa][Nn][Yy]%+", "") + local mods = "" + local stripped = true + while stripped do + stripped = false + for _, m in ipairs(modNames) do + if raw:sub(1, #m):lower() == m:lower() then + mods = mods .. raw:sub(1, #m) + raw = raw:sub(#m + 1) + stripped = true + end + end + end + + return { sym = raw, mods = mods } +end + +local function startCapture(action, label, oldRaws) + -- The grid page rebinds one keyset by name; a row chip hands over every binding it + -- stands for, the first of which is the one being edited. + if type(oldRaws) == "string" then + oldRaws = { oldRaws } + end + local oldRaw = oldRaws and oldRaws[1] or nil + + -- Rebinding seeds the modal with the current binding; the first press clears it. + local pair = catalogShiftPair[action] + local elems = {} + if oldRaw then + for _, part in ipairs(keybindModel.splitChain(oldRaw)) do + local elem = parseElem(part) + if pair then + elem.mods = (elem.mods:gsub("[Ss][Hh][Ii][Ff][Tt]%+", "")) + end + elems[#elems + 1] = elem + end + end + + local fakeMeta = activeFakeMeta() + + capturing = { + action = action, + label = label, + oldRaw = oldRaw, + oldRaws = oldRaws, + pair = pair, + elems = elems, + -- Showing the existing binding rather than anything the player has pressed. + seeded = #elems > 0, + pressed = {}, + lastPress = nil, + -- Matches the engine's KeyChainTimeout default; BAR ships a tighter 333ms. + timeout = 750, + any = actionUsesAny(action, oldRaw), + fakeMetaCode = fakeMeta and Spring.GetKeyCode(fakeMeta) or nil, + } +end + +local function modPrefix() + -- An action that ignores modifiers can only ever produce Any+, never a + -- contradictory Any+Shift+, so held modifiers are dropped outright. + if capturing and capturing.any then + return "" + end + + -- Not localised like its neighbours: this chunk is at Lua's ceiling of 200 locals and + -- a slot is worth more elsewhere. It runs on a key press, not on a frame. + local alt, ctrl, meta, shift = Spring.GetModKeyState() + local prefix = "" + if alt then + prefix = prefix .. "Alt+" + end + if ctrl then + prefix = prefix .. "Ctrl+" + end + if meta then + prefix = prefix .. "Meta+" + end + -- A paired action answers held or not held, so Shift is not a modifier the player picks + -- for it: holding it must read as the bare key and get its partner written behind. + if shift and not (capturing and capturing.pair) then + prefix = prefix .. "Shift+" + end + + return prefix +end + +-- Whether the capture is holding something worth committing. The Accept button is shown +-- only when this is true and acts only when this is true, so a button that is not on screen +-- cannot be clicked. Seeded means the modal is still showing the binding it was opened on +-- and nothing has been pressed yet: accepting that would rewrite a keyset to itself, so +-- there is nothing to offer and the way out is Cancel. +local function captureCanAccept() + local c = capturing + + return c ~= nil and #c.elems > 0 and not c.seeded +end + +-- Scancode to keyset symbol, refusing modifier keys and the stand-in Meta key so they +-- cannot bind alone; the latter is held down while the player picks what goes with it. +local function pressSym(key, scanCode) + if capturing and key == capturing.fakeMetaCode then + return nil + end + + -- Not localised like its neighbours: this chunk is at Lua's ceiling of 200 locals and + -- a slot is worth more elsewhere. It runs on a key press, not on a frame. + local sym = scanCode and Spring.GetScanSymbol(scanCode) + if not sym or sym == "" then + return nil + end + if sym:find("ctrl") or sym:find("alt") or sym:find("shift") or sym:find("meta") or sym:find("gui") then + return nil + end + + return sym +end + +-- Any+ replaces the held modifiers, so toggling the checkbox re-derives each element. +local function elemRaw(e) + return ((capturing and capturing.any) and "Any+" or e.mods) .. e.sym +end + +-- The captured sequence as one engine keyset string. +local function chainRaw() + local parts = {} + for i = 1, #capturing.elems do + parts[i] = elemRaw(capturing.elems[i]) + end + + return table.concat(parts, ",") +end + +---------------------------------------------------------------- +-- Chip layout and text batching +---------------------------------------------------------------- + +-- Fits a chip to its box, shrinking to a readable floor before it truncates. +local function chipMetrics(display, fs, pad, rightGap, chipArea) + local tw = font:GetTextWidth(display) * fs + if pad + tw + rightGap <= chipArea then + return display, fs, floor(pad + tw + rightGap) + end + + -- Keep inner positive, since a tiny share can drive it negative and the fit would then + -- return the string whole instead of truncating. + local inner = math.max(floor(fs), chipArea - pad - rightGap) + local chipFs = math.max(floor(fs * 0.75), floor(fs * inner / math.max(1, tw))) + local disp = text.fit(font, display, inner, chipFs) + + return disp, chipFs, floor(pad + font:GetTextWidth(disp) * chipFs + rightGap) +end + +-- Chain tokens over two lines at most; the second truncates rather than a third appearing. +local function wrapChainTwoLines(tokens, sep, maxW, fs) + local line1, i = "", 1 + while i <= #tokens do + local cand = (line1 == "") and tokens[i] or (line1 .. sep .. tokens[i]) + if line1 ~= "" and font:GetTextWidth(cand) * fs > maxW then + break + end + line1, i = cand, i + 1 + end + + if i > #tokens then + return { line1 } + end + + local rest = {} + for j = i, #tokens do + rest[#rest + 1] = tokens[j] + end + local line2 = table.concat(rest, sep) + if font:GetTextWidth(line2) * fs > maxW then + line2 = text.fit(font, line2, maxW, fs) + end + + return { line1 .. sep, line2 } +end + +-- One chip per distinct label rather than per binding. The Any+ qualifier is deliberately +-- never shown, so an action carrying both a plain and an Any+ binding of the same key +-- reads as that key twice; the chip stands for every binding behind it. +local function rowChipGroups(action) + local cached = chipGroups[action] + if cached then + return cached + end + + -- A paired action's two halves are one binding, so they read as one chip showing the bare + -- key. Grouping on the Shift-stripped form is what puts them together; the chip carries + -- both raws, so removing it takes the pair and rebinding moves the pair. + local pair = catalogShiftPair[action] + local groups, byDisplay = {}, {} + for _, k in ipairs(working.byAction[action] or {}) do + local shown = pair and keybindModel.displayWithoutShift(k.raw, working.layout) or k.display + + local group = byDisplay[shown] + if not group then + group = { display = shown, raws = {} } + byDisplay[shown] = group + groups[#groups + 1] = group + end + group.raws[#group.raws + 1] = k.raw + end + + chipGroups[action] = groups + + return groups +end + +-- Shares a row's width across its chips so every one stays clickable when they overflow. +local function layoutRowChips(action, fs, pad, rightGap, chipArea, gap) + local groups = rowChipGroups(action) + local n = #groups + local mets = {} + if n == 0 then + return mets, metrics.keyAreaX1 + end + + local total = 0 + for i = 1, n do + local disp, cfs, w = chipMetrics(groups[i].display, fs, pad, rightGap, chipArea) + mets[i] = { group = groups[i], disp = disp, fs = cfs, w = w } + total = total + w + (i > 1 and gap or 0) + end + + if total > chipArea then + local share = floor((chipArea - (n - 1) * gap) / n) + for i = 1, n do + local disp, cfs, w = chipMetrics(groups[i].display, fs, pad, rightGap, share) + mets[i] = { group = groups[i], disp = disp, fs = cfs, w = w } + end + end + + local cx = metrics.keyAreaX1 + for i = 1, n do + mets[i].x = cx + mets[i].removeX1 = cx + mets[i].w - rightGap + cx = cx + mets[i].w + gap + end + + return mets, cx +end + +-- The chip band for a row: where each chip sits, where "+" starts after them, and the widths +-- both callers need. Drawing and hit testing take it from here rather than each deriving the +-- same eight constants, so the click zones cannot drift from what was painted. +local function rowChipBand(action, fs, pad, reserve) + local gap = floor(6 * scale) + local rightGap = pad + floor(fs * 0.9) + local addW = floor(fs + pad * 2) + -- Room reserved on the right so "+" always fits, and whatever the caller wants after it. + local chipArea = listRight - addW - floor(8 * scale) - metrics.keyAreaX1 - (reserve or 0) + local mets, cx = layoutRowChips(action, fs, pad, rightGap, chipArea, gap) + + return mets, cx, addW, rightGap +end + +-- Everything drawRow needs that does not move with the mouse: the fitted label and the +-- chip band, each string already carrying its colour code. Built on first use and kept on +-- the row, which rebuildRows replaces outright; a geometry change bumps layoutGen so a +-- row laid out against the old widths is measured again. +local function rowLayout(row) + local lay = row.layout + if lay and lay.gen == layoutGen then + return lay + end + + lay = { gen = layoutGen } + if row.type == "header" then + lay.text = colorHeader .. row.text + elseif row.type == "note" then + lay.text = colorDim .. text.fit(font, row.text, listRight - listX1 - metrics.rowPad * 4, metrics.rowFs) + elseif row.type == "link" then + lay.text = colorAction .. row.label + lay.arrow = look.arrow + lay.arrowX = listX1 + + metrics.rowPad * 5 + + floor(font:GetTextWidth(row.label) * metrics.rowFs) + + metrics.rowPad * 2 + else + -- The cursor column, when the row's group has one, comes out of the name's room. + local indent = row.cursorColumn and (metrics.cursorIcon + metrics.rowPad) or 0 + lay.textX = listX1 + metrics.rowPad + indent + lay.icon = row.cursor + lay.iconX = listX1 + metrics.rowPad + local labelW = metrics.keyAreaX1 - lay.textX - metrics.rowPad + lay.text = colorAction .. text.fit(font, row.label, labelW, metrics.rowFs) + + -- The key the base preset had, when the row's differs: a hollow chip after the row's + -- own, which the chips make room for. Paired halves read as one key, as the chips do. + local change = row.change + if change then + local shown, seen = {}, {} + local pair = catalogShiftPair[row.action] + for _, raw in ipairs(change) do + local disp = pair and keybindModel.displayWithoutShift(raw, working.layout) + or keybindModel.displayKeyset(raw, working.layout) + if not seen[disp] then + seen[disp] = true + shown[#shown + 1] = disp + end + end + local keys = #shown > 0 and table.concat(shown, ", ") or L.revertNone + lay.ghostFs = floor(metrics.rowFs * 0.9) + keys = text.fit(font, keys, floor((listRight - metrics.keyAreaX1) * 0.3), lay.ghostFs) + lay.ghostKeys = keys + -- The caption is the same string twice with the keys marked off, so the colour split + -- lands on the keys wherever a translation puts them. + local caption = BAR.I18N("ui.keybinds.editor.revertChip", { keys = "\1" }) + local before, after = caption:match("^(.-)\1(.*)$") + before, after = before or caption, after or "" + lay.ghostText = colorFaded .. before .. look.ghostKeys .. keys .. colorFaded .. after + lay.ghostTextHover = colorDim .. before .. colorHeader .. keys .. colorDim .. after + lay.ghostW = floor(font:GetTextWidth(before .. keys .. after) * lay.ghostFs) + metrics.rowPad * 2 + -- Against the list's right edge, clear of the row's own keys and "+". + lay.ghostX = listRight - metrics.rowPad - lay.ghostW + end + + local reserve = lay.ghostW and (lay.ghostW + metrics.rowPad * 2) or 0 + local mets, cx, addW, rightGap = rowChipBand(row.action, metrics.rowFs, metrics.rowPad, reserve) + for i = 1, #mets do + local m = mets[i] + m.textKey = colorKey .. m.disp + m.textHover = colorText .. m.disp + m.removeCx = floor(m.removeX1 + rightGap * 0.5) + -- On a row found by key, the chip that answered is lit, so it reads why the row is here. + m.hit = row.hitKeys ~= nil and keybindModel.holdsKeys(m.group.display, row.hitKeys) + -- What else the chip's key drives, for its tooltip; reddened only for sharing of the + -- player's own making. + m.others = conflictsOf(row.action, m.group.raws) + m.clash = false + for _, o in ipairs(m.others or look.noRaws) do + if not o.shipped then + m.clash = true + end + end + end + lay.mets = mets + lay.cx = cx + lay.addW = addW + -- A paired action holds one key expressed as two binds, so once it has one there is + -- no second to add: capturing again rewrites the pair, and "+" would read as "add + -- another" while silently replacing it. With nothing bound it is the only way in. + lay.showAdd = not (catalogShiftPair[row.action] and #mets > 0) + end + row.layout = lay + + return lay +end + +-- Which zone of an editable row sits under x: a chip body, its remove mark, or "+", with +-- the chip's index. y is checked against the chip band when given, so hover matches what +-- is painted; a click passes nil and takes the whole row height. +local function rowZone(lay, x, y, c1, c2) + if y and (y < c1 or y > c2) then + return nil + end + + local mets = lay.mets + for i = 1, #mets do + local m = mets[i] + if x >= m.x and x < m.removeX1 then + return "rebind", i + elseif x >= m.removeX1 and x <= m.x + m.w then + return "remove", i + end + end + + if lay.showAdd and x >= lay.cx and x <= lay.cx + lay.addW then + return "add" + end + + if lay.ghostW and x >= lay.ghostX and x <= lay.ghostX + lay.ghostW then + return "revert" + end + + return nil +end + +-- Geometry drawn between font:Begin and font:End interleaves with the font's batched +-- glyphs and makes both flicker. The list alternates shapes and text row by row, so it +-- queues here and flushes once all the shapes are down; the modals draw every shape +-- before any text and so print directly. Held flat and refilled in place, since a table +-- per string per frame is hundreds of allocations a second. +local pendingText = {} +local pendingCount = 0 + +local function queueText(str, x, y, size, opts) + local at = pendingCount * 5 + pendingText[at + 1] = str + pendingText[at + 2] = x + pendingText[at + 3] = y + pendingText[at + 4] = size + pendingText[at + 5] = opts + pendingCount = pendingCount + 1 +end + +local function flushText() + if pendingCount == 0 then + return + end + + font:Begin() + font:SetOutlineColor(look.outline) + for i = 0, pendingCount - 1 do + local at = i * 5 + font:Print( + pendingText[at + 1], + pendingText[at + 2], + pendingText[at + 3], + pendingText[at + 4], + pendingText[at + 5] + ) + end + font:End() + + pendingCount = 0 +end + +---------------------------------------------------------------- +-- Drawing +---------------------------------------------------------------- + +-- Rows are laid out from the top of the list band down, so the column lines up with the +-- keybind rows beside it. +-- The category column starts below where the keybind rows do, so the title above it is not +-- crowded by the first entry. Everything in the column measures from here. +local function sidebarTop() + -- Off the band's fixed top, not the rows' own: the Changed section lowers the rows for its + -- comparison picker, and the column beside them must not move with it. + return (metrics.listTopBase or listTop) - metrics.sidebarDrop +end + +-- `i` is the entry's place in `categories`, not its place on screen: the two differ by +-- however far the column is scrolled. That offset rides in `hover` for the same reason +-- `grab` does - this chunk is at Lua's ceiling of 200 locals. +local function categoryRect(i) + local top = sidebarTop() - (i - 1 - hover.cat) * metrics.catRowHeight + -- The right edge gives way to the bar when there is one. Without that an entry runs + -- under it and its hover plate disappears beneath the bar rather than stopping beside + -- it. The page count is worked out inline: this chunk is at Lua's 200. + local right = area.x1 + sidebarW + if #categories > math.max(1, floor((sidebarTop() - listBottom()) / metrics.catRowHeight)) then + right = right - metrics.catInset - metrics.catBarW - metrics.catInset + end + + return area.x1, top - metrics.catRowHeight, right, top +end + +-- Scrolls the category column by `delta` entries and answers how far it can be scrolled +-- at all, so nought means everything fits. One function rather than the usual three, +-- this chunk being at the local ceiling; passing 0 just clamps. +local function catScrolled(delta) + local page = math.max(1, floor((sidebarTop() - listBottom()) / metrics.catRowHeight)) + local most = math.max(0, #categories - page) + local n = hover.cat + delta + hover.cat = (n < 0 and 0) or (n > most and most) or n + + return most +end + +-- The category entry under x,y, or nil. Half-open on the shared edge, like the rows, so +-- one point never lands in two entries. +local function sidebarIndexAt(x, y) + local top = sidebarTop() + if x < area.x1 or x > area.x1 + sidebarW or y > top or y <= listBottom() then + return nil + end + + local i = floor((top - y) / metrics.catRowHeight) + 1 + hover.cat + if not categories[i] then + return nil + end + + local _, y1 = categoryRect(i) + if y1 < listBottom() then + return nil + end + + return i +end + +-- The grid menu is 3x4 with row 1 along the bottom, matching the keyboard rows it is +-- bound to (ZXCV under ASDF under QWER) and the order gui_gridmenu draws them in. +local gridRows, gridCols = 3, 4 + +-- The ids never change, so they are built once rather than concatenated per cell per frame. +local gridKeyActions, gridCategoryActions = {}, {} +for row = 1, gridRows do + gridKeyActions[row] = {} + for col = 1, gridCols do + gridKeyActions[row][col] = "gridmenu_key " .. row .. " " .. col + end +end +for c = 1, gridCols do + gridCategoryActions[c] = "gridmenu_category " .. c +end + +-- Two grids side by side: the build grid as it opens, and the same grid once a category +-- is picked, which is where Back and Next page live. Sized to roughly what the menu +-- occupies in game - 0.2125 of screen width over four columns - rather than stretched. +local function gridGeometry() + -- The heading here is the list's heading, so it takes the taller heading row rather + -- than an ordinary one. + local headH = metrics.headerRowHeight + local top = listTop - headH - floor(8 * scale) + local bottom = listBottom() + floor(8 * scale) + local strip = floor(rowHeight * 1.2) + local gap = floor(4 * scale) + local blockGap = floor(28 * scale) + + local availH = (top - bottom) - (strip + gap) * 2 + local availW = (listRight - listX1) - blockGap + local cell = math.min(floor(availH / gridRows), floor(availW / (gridCols * 2)), floor(100 * scale)) + if cell < 1 then + cell = 1 + end + + local blockW = cell * gridCols + local blockH = cell * gridRows + (strip + gap) * 2 + -- Pinned to the top left of the list band, like the rows it replaces, rather than + -- floating in the middle of it. + local x1 = listX1 + local stripY = top - blockH + local gridBottom = stripY + strip + gap + + return x1, x1 + blockW + blockGap, gridBottom, cell, strip, gap, stripY, gridBottom + cell * gridRows + gap, headH +end + +-- Cell rect for a grid position. Row 1 is the bottom row, so it is laid out upward. +local function gridCellRect(row, col, x1, gridBottom, cell) + local cx = x1 + (col - 1) * cell + local cy = gridBottom + (row - 1) * cell + + return cx, cy, cx + cell, cy + cell +end + +-- Through FlowUI's Button so these carry the same border, gloss and corner as every other +-- button in the UI. It serves a repeated draw from a display-list cache; the cached form +-- was checked against the immediate one and is identical, so a button does not change as +-- the cache takes over. +local function drawButtonFace(r, base) + local pair = look.gradients[base] + + UiButton(r[1], r[2], r[3], r[4], 1, 1, 1, 1, 1, 1, 1, 1, nil, pair[1], pair[2]) +end + +-- The band a category heading sits on: the sheen, the line closing it off underneath, and +-- the caption. Shared, so the grid view's heading is the same object as the list's rather +-- than a second one that has to be kept looking like it. +local function drawHeaderBand(top, bottom, caption) + RectRound(listX1, bottom, listRight, top - metrics.csSmall, metrics.csSmall, 1, 1, 0, 0, sheenTop, sheenTop) + -- Underline: a thin bar fading up out of the bottom edge, so the heading closes off the + -- block above it rather than floating in the middle of the list. + RectRound( + listX1, + bottom, + listRight, + bottom + metrics.underlineH, + 0, + 0, + 0, + 0, + 0, + look.headerLine, + look.headerLineFade + ) + queueText(caption, listX1 + metrics.rowPad, floor((top + bottom) * 0.5), metrics.headerFs, "ov") +end + +-- The category column: its own card under the title, then one entry per category, with +-- hoverIdx the entry under the cursor. +local function drawSidebar(hoverIdx) + -- Derived from the first category rather than measured from the panel top, so the card + -- keeps its lip above the entries wherever the column starts. + RectRound( + area.x1, + area.y1, + area.x1 + sidebarW, + sidebarTop() + metrics.cardLip, + metrics.csPanel, + 1, + 1, + 1, + 1, + look.sidebarFill, + look.sidebarFillTop + ) + queueText(L.titleText, area.x1 + metrics.sidePad, area.y2 - metrics.titleY, metrics.titleFs, "ov") + + -- A bar of its own, and a slim one: the column is narrow and this only shows up when + -- there are more categories than the card has room for. + if catScrolled(0) > 0 then + local bx2 = area.x1 + sidebarW - metrics.catInset + Scroller( + bx2 - metrics.catBarW, + -- Over the entries rather than the whole card: the last row rarely lands exactly on + -- the bottom, and a bar running past it reads as dead space at the foot of the column + -- - and its thumb then says more fits than does. + sidebarTop() + - math.max(1, floor((sidebarTop() - listBottom()) / metrics.catRowHeight)) * metrics.catRowHeight, + bx2, + sidebarTop(), + #categories * metrics.catRowHeight, + hover.cat * metrics.catRowHeight + ) + end + + -- Laid out before the font existed, so the labels are still waiting to be fitted; or one of + -- them changed since, which is the Changed entry's count. + if state.refit or (categories[1] and not categories[1].textDim) then + state.refit = false + fitCategories() + end + + local lb = listBottom() + for i = hover.cat + 1, #categories do + local c = categories[i] + local x1, y1, x2, y2 = categoryRect(i) + if y1 >= lb then + local selected = selectedCategory == c.key + if selected then + local sx1, sx2 = x1 + metrics.catInset, x2 - metrics.catInset + RectRound(sx1, y1, sx2, y2, metrics.csSmall, 1, 1, 1, 1, look.selectedFill) + elseif i == hoverIdx then + Highlight( + x1 + metrics.catInset, + y1, + x2 - metrics.catInset, + y2, + metrics.csSmall, + look.rowHoverOpacity, + look.white + ) + end + local ty = floor((y1 + y2) * 0.5) + queueText((selected and c.textSel or c.textDim) or c.label, x1 + metrics.sidePad, ty, metrics.catFs, "ov") + end + end +end + +-- Label left, key right, sized like a category button. Used for every pill in this view. +local function drawGridPill(x1, y1, x2, y2, label, key, fs, pad, hovered, dim) + -- Through FlowUI's Button, the way gui_gridmenu draws these same category and page + -- buttons: the cells above them are unit slots and carry a tile frame, so these need + -- the raised button face to not read as more of the same. + local pair = look.gradients[pillFill] + UiButton(x1, y1, x2, y2, 1, 1, 1, 1, 1, 1, 1, 1, nil, pair[1], pair[2]) + if hovered and not dim then + Highlight(x1, y1, x2, y2, metrics.csButton, hoverOpacity, look.white) + end + -- Key is right aligned and gets only the width it needs; the label is centred in + -- whatever is left over. With no key bound that is the whole button, which is why an + -- unbound category reads as a plain centred caption rather than one pushed to the left. + local keyW = floor(font:GetTextWidth(key) * fs) + local lx1 = x1 + pad * 2 + local lx2 = x2 - pad * 2 - (keyW > 0 and keyW + pad * 2 or 0) + local ty = floor((y1 + y2) * 0.5) + queueText( + (dim and colorDim or colorAction) .. text.fit(font, label, lx2 - lx1, fs), + floor((lx1 + lx2) * 0.5), + ty, + fs, + "cov" + ) + queueText((dim and colorDim or colorKey) .. key, x2 - pad * 2, ty, fs, "rov") +end + +-- The raw keyset a grid action carries, for seeding a rebind. nil when it has none. +local function gridKeyRaw(action) + local ks = working.byAction[action] + + return ks and ks[1] and ks[1].raw or nil +end + +-- The key a grid action currently carries, blank when it has none. +local function gridKeyText(action) + local ks = working.byAction[action] + + return ks and ks[1] and ks[1].display or "" +end + +-- Sized to its own label and key, so a longer translation widens it rather than being +-- clipped. +local function gridCycleRect(x1, bsize) + local fs = floor(bsize * 0.45) + local pad = floor(3 * scale) + -- Inset to match the cells below, which sit a pad in from the block edge. + local cx1 = x1 + pad + local need = floor( + (font:GetTextWidth(gridGroup.cycleLabel) + font:GetTextWidth(gridKeyText("gridmenu_cycle_builder"))) * fs + ) + pad * 10 + + return cx1, cx1 + math.max(need, floor(bsize * 2)) +end + +-- Which grid element sits under x,y: a build cell (row, col), a category pill (index), +-- Next page or the cycle-builder pill. Hover, the baked panel's signature and clicks all +-- read it, so the three cannot disagree. Back is left out on purpose: gui_gridmenu +-- hardcodes its key, so there is nothing to rebind and nothing to light up. +local function gridZone(x, y) + local x1, x2, gridBottom, cell, strip, _, stripY, builderY = gridGeometry() + local pad = floor(3 * scale) + + for row = 1, gridRows do + for col = 1, gridCols do + local cx1, cy1, cx2, cy2 = gridCellRect(row, col, x1, gridBottom, cell) + if isInRect(x, y, cx1 + pad, cy1 + pad, cx2 - pad, cy2 - pad) then + return "cell", row, col + end + end + end + + if y >= stripY and y <= stripY + strip then + for c = 1, gridCols do + local cx1 = x1 + (c - 1) * cell + if x >= cx1 + pad and x <= cx1 + cell - pad then + return "category", c + end + end + + local third = floor(cell * gridCols / 3) + if x >= x2 + gridCols * cell - third and x <= x2 + gridCols * cell - pad then + return "next" + end + end + + local ccx1, ccx2 = gridCycleRect(x1, strip) + if isInRect(x, y, ccx1, builderY, ccx2, builderY + strip) then + return "cycle" + end + + return nil +end + +-- Draws the grid menu as it sits on screen. Cells stay empty: what fills them in game +-- comes from the selected builder, which the editor has no notion of. zone (with its +-- two arguments) is what gridZone found under the cursor, and is what lights up. +local function drawGridMenu(zone, zoneA, zoneB) + local x1, x2, gridBottom, cell, strip, _, stripY, builderY, headH = gridGeometry() + local pad = floor(3 * scale) + local keyFs = floor(cell * 0.2) + local stripFs = floor(strip * 0.45) + + -- The same heading the list puts above a category, drawn by the same code. + drawHeaderBand(listTop, listTop - headH, colorHeader .. gridGroup.title) + + -- These stand in for the build menu's unit tiles, so they take the same frame FlowUI + -- puts around a unit picture, minus the picture. Every cell is the same size, so the + -- corner is derived once and shared with the fill under it; left to itself the frame + -- would derive its own and the two would not quite line up. + local cellInner = cell - pad * 2 + local frameCs = math.max(1, floor(cellInner * 0.024)) + + for pass = 1, 2 do + local gx = (pass == 1) and x1 or x2 + for row = 1, gridRows do + for col = 1, gridCols do + local cx1, cy1, cx2, cy2 = gridCellRect(row, col, gx, gridBottom, cell) + -- Solid enough to read against the panel on its own, so the cells need no + -- container or outline behind them. + RectRound(cx1 + pad, cy1 + pad, cx2 - pad, cy2 - pad, frameCs, 1, 1, 1, 1, pillFill, pillFill) + -- Only the first grid carries the build keys; the second is the category view, + -- whose cells hold the same bindings and would just repeat them. + if pass == 1 then + -- Under the frame, so the hover lifts the tile without softening its edge. + if zone == "cell" and zoneA == row and zoneB == col then + Highlight(cx1 + pad, cy1 + pad, cx2 - pad, cy2 - pad, frameCs, look.rowHoverOpacity, look.white) + end + queueText( + colorKey .. gridKeyText(gridKeyActions[row][col]), + cx2 - pad * 3, + cy2 - pad * 2 - keyFs, + keyFs, + "ro" + ) + end + -- Last, so the border and shine sit over the fill and the hover rather than + -- under them. Plain: a group icon would name a group these cells do not have. + -- The second grid is the same keys seen from the category view and binds + -- nothing, so its frames are drawn faint: it is there to show the layout, not + -- to be clicked, and a full-strength frame invites the click. + local border = (pass == 1) and nil or look.idleBorder + UiUnitFrame(cx1 + pad, cy1 + pad, cx2 - pad, cy2 - pad, frameCs, 1, 1, 1, 1, nil, border) + end + end + end + + for c = 1, gridCols do + local cx1 = x1 + (c - 1) * cell + drawGridPill( + cx1 + pad, + stripY, + cx1 + cell - pad, + stripY + strip, + gridGroup.categoryLabels[c] or "", + gridKeyText(gridCategoryActions[c]), + stripFs, + pad, + zone == "category" and zoneA == c + ) + end + + -- Second grid is the view after a category is picked: Back on the left, Next page on + -- the right, matching how gui_gridmenu splits that strip into thirds. + local third = floor(cell * gridCols / 3) + drawGridPill( + x2 + pad, + stripY, + x2 + third, + stripY + strip, + L.gridBack, + keybindModel.displayKeyset("shift", working.layout), + stripFs, + pad, + false, + true + ) + drawGridPill( + x2 + gridCols * cell - third, + stripY, + x2 + gridCols * cell - pad, + stripY + strip, + L.gridNextPage, + gridKeyText("gridmenu_next_page"), + stripFs, + pad, + zone == "next" + ) + + local ccx1, ccx2 = gridCycleRect(x1, strip) + drawGridPill( + ccx1, + builderY, + ccx2, + builderY + strip, + gridGroup.cycleLabel, + gridKeyText("gridmenu_cycle_builder"), + stripFs, + pad, + zone == "cycle" + ) +end + +-- Routes a click in the grid view to the action that cell or button binds. +local function gridPress(x, y) + local zone, a, b = gridZone(x, y) + + if zone == "cell" then + local action = gridKeyActions[a][b] + startCapture(action, gridGroup.cellLabel(a, b), gridKeyRaw(action)) + elseif zone == "category" then + local action = gridCategoryActions[a] + startCapture(action, gridGroup.categoryLabels[a], gridKeyRaw(action)) + elseif zone == "next" then + startCapture("gridmenu_next_page", L.gridNextPage, gridKeyRaw("gridmenu_next_page")) + elseif zone == "cycle" then + startCapture("gridmenu_cycle_builder", gridGroup.cycleLabel, gridKeyRaw("gridmenu_cycle_builder")) + end + + return true +end + +-- One list row. hovered says the cursor is on it; zone and zoneIdx are then which chip +-- or button of it, in rowZone's terms. +local function drawRow(row, top, bottom, hovered, zone, zoneIdx) + local cyc = floor((top + bottom) * 0.5) + local lay = rowLayout(row) + local fs = metrics.rowFs + + if row.type == "header" then + drawHeaderBand(top, bottom, lay.text) + -- A heading that stands for a key filter carries the mark that clears it. + if row.clear then + local mark = zone == "clear" and look.removeHot or look.removeCold + queueText(mark, listRight - metrics.rowPad * 2, cyc, fs, "cov") + end + return + end + + -- A note explains an empty section; it is neither lit nor clicked. + if row.type == "note" then + queueText(lay.text, listX1 + metrics.rowPad * 2, cyc, fs, "ov") + return + end + + if hovered then + Highlight(listX1, bottom, listRight, top, metrics.csSmall, look.rowHoverOpacity, look.white) + end + + -- Indented and followed by an arrow, to read as a way through rather than a binding. + if row.type == "link" then + queueText(lay.text, listX1 + metrics.rowPad * 5, cyc, fs, "ov") + queueText(lay.arrow, lay.arrowX, cyc, fs, "ov") + return + end + + -- The order's cursor: geometry, so it goes down ahead of the queued text. Blending is set + -- rather than assumed, as for the header icons, since whatever drew before can leave one + -- that shows the picture's transparent surround as a solid square. + if lay.icon then + local s = metrics.cursorIcon + local iy = floor((top + bottom - s) * 0.5) + glBlending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + glColor(1, 1, 1, look.cursorAlpha) + glTexture(lay.icon) + glTexRect(lay.iconX, iy, lay.iconX + s, iy + s) + glTexture(false) + glColor(1, 1, 1, 1) + end + queueText(lay.text, lay.textX, cyc, fs, "ov") + + local c1, c2 = bottom + metrics.chipInset, top - metrics.chipInset + local mets = lay.mets + for i = 1, #mets do + local m = mets[i] + local overBody = zone == "rebind" and zoneIdx == i + local overRemove = zone == "remove" and zoneIdx == i + local chipFill = (overBody and look.chipFillHover) + or (m.hit and look.chipFillHit) + or (m.clash and look.chipFillConflict) + or look.chipFill + RectRound(m.x, c1, m.x + m.w, c2, metrics.csSmall, 1, 1, 1, 1, chipFill) + queueText(overBody and m.textHover or m.textKey, m.x + metrics.rowPad, cyc, m.fs, "ov") + queueText(overRemove and look.removeHot or look.removeCold, m.removeCx, cyc, fs, "cov") + end + + if lay.showAdd then + local cx = lay.cx + local overAdd = zone == "add" + RectRound(cx, c1, cx + lay.addW, c2, metrics.csSmall, 1, 1, 1, 1, overAdd and look.addFillHover or look.addFill) + queueText(overAdd and look.plusTextHover or look.plusText, floor(cx + lay.addW * 0.5), cyc, fs, "cov") + end + + -- The base preset's key, as a hollow chip: a border with the row's own dark inside it. + if lay.ghostW then + local gx, over = lay.ghostX, zone == "revert" + RectRound(gx, c1, gx + lay.ghostW, c2, metrics.csSmall, 1, 1, 1, 1, over and look.ghostBorderHover or look.ghostBorder) + RectRound(gx + 1, c1 + 1, gx + lay.ghostW - 1, c2 - 1, metrics.csSmall, 1, 1, 1, 1, look.ghostInner) + queueText(over and lay.ghostTextHover or lay.ghostText, gx + metrics.rowPad, cyc, lay.ghostFs, "ov") + end +end + +-- Split out of view.draw: each modal is self-contained, and one function holding every +-- draw path ran past the 60-upvalue ceiling. +local function drawCaptureModal(mx, my) + local bx1, by1, bx2, by2, ok, cancel = captureGeometry() + local cs = metrics.csButton + local cx = floor((bx1 + bx2) * 0.5) + + -- The whole window, not the inset area inside it: a modal that leaves the panel's own + -- border lit does not read as covering it. + RectRound( + metrics.winX1, + metrics.winY1, + metrics.winX2, + metrics.winY2, + metrics.csPanel, + 1, + 1, + 1, + 1, + { 0, 0, 0, 0.55 } + ) + UiElement(bx1, by1, bx2, by2, 1, 1, 1, 1, 1, 1, 1, 1, WG.FlowUI.clampedOpacity) + + local tfs = floor(rowHeight * 0.6) + local sfs = floor(rowHeight * 0.5) + local bigfs = floor(rowHeight * 0.95) + -- Preview held modifiers while forming the first element, through the same formatter a + -- finished keyset uses so the two do not render differently. A capture opened on an + -- existing binding still shows it, but a held modifier previews over the top - the player + -- is part-way through a replacement - and letting go puts the original back. + local heldRaw = modPrefix() + local held = heldRaw ~= "" and keybindModel.displayKeyset(heldRaw, working.layout) or "" + -- What the big line in the middle shows: the keyset formed so far, which includes the + -- one the modal opened on. Whether that is worth committing is a separate question. + local hasChain = #capturing.elems > 0 and not (capturing.seeded and held ~= "") + local canAccept = captureCanAccept() + + drawButtonFace(cancel, buttonFill) + if isInRect(mx, my, cancel[1], cancel[2], cancel[3], cancel[4]) then + Highlight(cancel[1], cancel[2], cancel[3], cancel[4], cs, hoverOpacity, look.white) + end + -- Absent until there is a change to accept, rather than present and dead: a greyed + -- button invites a click that does nothing. Green like the other commits, and + -- brightening its own fill on hover, which the white overlay would wash out. + if canAccept then + local overOk = isInRect(mx, my, ok[1], ok[2], ok[3], ok[4]) + drawButtonFace(ok, overOk and confirmFillHover or confirmFill) + end + + local chainStr + if hasChain then + chainStr = keybindModel.displayKeyset(chainRaw(), working.layout) + elseif held ~= "" then + chainStr = held .. "_" + else + chainStr = L.pressKey + end + local hasContent = hasChain or held ~= "" + + -- Shrink toward a readable floor, then wrap to a second line, then ellipsize. + local chainMaxW = (bx2 - bx1) - floor(32 * scale) + local minFs = floor(rowHeight * 0.5) + local chainLines, chainFs = { chainStr }, bigfs + if hasChain then + local naturalW = font:GetTextWidth(chainStr) * bigfs + if naturalW <= chainMaxW then + chainLines, chainFs = { chainStr }, bigfs + elseif floor(bigfs * chainMaxW / naturalW) >= minFs then + chainLines, chainFs = { chainStr }, floor(bigfs * chainMaxW / naturalW) + else + local tokens = {} + for _, e in ipairs(capturing.elems) do + tokens[#tokens + 1] = keybindModel.displayKeyset(elemRaw(e), working.layout) + end + chainLines, chainFs = wrapChainTwoLines(tokens, keybindModel.chainSep, chainMaxW, minFs), minFs + end + else + local w = font:GetTextWidth(chainStr) * bigfs + if w > chainMaxW then + chainFs = math.max(minFs, floor(bigfs * chainMaxW / w)) + end + end + + -- Bar draining over the chain window: time left to extend before it resets. The + -- track is always drawn so the modal does not gain a row the moment a key lands. + local barW = floor((bx2 - bx1) * 0.5) + local barX = floor(cx - barW * 0.5) + local barY = by1 + floor(88 * scale) + local barH = floor(4 * scale) + RectRound(barX, barY, barX + barW, barY + barH, floor(2 * scale), 1, 1, 1, 1, { 1, 1, 1, 0.1 }) + if hasChain and capturing.lastPress then + local frac = 1 - (spDiffTimers(spGetTimer(), capturing.lastPress) * 1000) / capturing.timeout + if frac < 0 then + frac = 0 + end + if frac > 0 then + RectRound( + barX, + barY, + barX + floor(barW * frac), + barY + barH, + floor(2 * scale), + 1, + 1, + 1, + 1, + { 0.9, 0.7, 0.2, 0.9 } + ) + end + end + + local chainCy = by1 + floor(122 * scale) + local lineStep = floor(chainFs * 1.15) + + -- Other actions already on the keyset being formed, named under it before it is accepted. + local clash + if canAccept then + local raws = capturing.pair and shiftPairRaws(capturing.elems) or { chainRaw() } + local others = conflictsOf(capturing.action, raws) + if others then + local names = {} + for i = 1, #others do + names[i] = state.labels[others[i].action] or others[i].action + end + local line = BAR.I18N("ui.keybinds.editor.conflictCapture", { actions = table.concat(names, ", ") }) + clash = colorHeader .. text.fit(font, line, chainMaxW, sfs) + end + end + + font:Begin() + font:SetOutlineColor(look.outline) + if clash then + font:Print(clash, cx, by1 + floor(64 * scale), sfs, "cov") + end + font:Print( + colorText .. text.fit(font, capturing.label or capturing.action, chainMaxW, tfs), + cx, + by2 - floor(26 * scale), + tfs, + "cov" + ) + for li = 1, #chainLines do + local ly = floor(chainCy + (#chainLines - 1) * lineStep * 0.5 - (li - 1) * lineStep) + font:Print((hasContent and colorKey or colorDim) .. chainLines[li], cx, ly, chainFs, "cov") + end + font:Print( + colorText .. L.cancel, + floor((cancel[1] + cancel[3]) * 0.5), + floor((cancel[2] + cancel[4]) * 0.5), + sfs, + "cov" + ) + if canAccept then + font:Print(colorText .. L.accept, floor((ok[1] + ok[3]) * 0.5), floor((ok[2] + ok[4]) * 0.5), sfs, "cov") + end + font:End() +end + +-- The import preview's sizes, shared by the drawing and the bar's hit test so a press lands on +-- what was painted: the line pitch, the inset, how many lines the box holds, how far it can +-- scroll, and the bar's rect - nil while every line fits. +function state.previewGeometry(pv, x1, y1, x2, y2) + local lineH = floor(rowHeight * 0.66) + local pad = floor(6 * scale) + local barW = floor(10 * scale) + local visible = math.max(1, floor((y2 - y1 - pad * 2) / lineH)) + local most = math.max(0, #pv.lines - visible) + local bar = most > 0 and { x2 - pad - barW, y1 + pad, x2 - pad, y2 - pad } or nil + + return lineH, pad, visible, most, bar +end + +-- Scrolls the preview so the thumb's top sits where the cursor has dragged it, the offset +-- taken at the grab keeping it relative - as the list's own bar does. +function state.previewScrollFromY(pv, bar, lineH, most, y) + local _, _, trackTop, travel = + WG.FlowUI.Draw.ScrollerGeometry(bar[1], bar[2], bar[3], bar[4], #pv.lines * lineH, pv.scroll * lineH) + if not travel or travel <= 0 then + return + end + + local f = (trackTop - (y - pv.grab)) / travel + if f < 0 then + f = 0 + elseif f > 1 then + f = 1 + end + pv.scroll = floor(f * most + 0.5) +end + +-- The import preview: the clipboard's lines in a box, in the monospaced face source gets, +-- numbered down a gutter of their own, each in the colour of what the reader makes of it, the +-- ones it will drop on a red band. Scrolled by the wheel or by the bar, whose thumb can be +-- taken hold of. The lines are fitted to the box once per width and face. +function state.drawPreview(pv, x1, y1, x2, y2, mx, my) + local mono = WG.fonts.getFont(3) or font + local fs = floor(rowHeight * 0.5) + local lineH, pad, visible, most, bar = state.previewGeometry(pv, x1, y1, x2, y2) + pv.visible = visible + + -- A drag in progress follows the cursor and ends with the button. + if pv.drag then + local _, _, lmb = spGetMouseState() + if lmb and bar then + state.previewScrollFromY(pv, bar, lineH, most, my) + else + pv.drag = false + end + end + if pv.scroll > most then + pv.scroll = most + end + if pv.scroll < 0 then + pv.scroll = 0 + end + + RectRound(x1, y1, x2, y2, metrics.csSmall, 1, 1, 1, 1, look.previewFill) + -- The gutter: wide enough for the last line's number, set off from the lines by its own + -- shade, rounded with the box on its outer corners. + local gutterW = floor(mono:GetTextWidth(tostring(#pv.lines)) * fs) + pad * 2 + RectRound(x1, y1, x1 + gutterW, y2, metrics.csSmall, 1, 0, 0, 1, look.previewGutter) + + local textX1 = x1 + gutterW + pad + local textX2 = bar and (bar[1] - pad) or (x2 - pad) + if pv.fitW ~= textX2 - textX1 or pv.fitFont ~= mono then + pv.fitW, pv.fitFont = textX2 - textX1, mono + for i, line in ipairs(pv.lines) do + local fitted = text.fit(mono, line.text, pv.fitW, fs) + -- A binding reads as the chips do: its key in gold, its action in the row colour. + local keyset, action = fitted:match("^%s*bind%s+(%S+)%s+(.*)$") + if line.kind == "bind" and keyset then + line.shown = colorFaded .. "bind " .. colorKey .. keyset .. " " .. colorAction .. action + else + line.shown = look.previewColours[line.kind] .. fitted + end + line.num = (line.kind == "error" and colorDanger or colorFaded) .. i + end + end + + local last = math.min(#pv.lines, pv.scroll + visible) + for i = pv.scroll + 1, last do + if pv.lines[i].kind == "error" then + local top = y2 - pad - (i - pv.scroll - 1) * lineH + RectRound(x1 + gutterW, top - lineH, textX2 + pad, top, 0, 1, 1, 1, 1, look.previewErrorFill) + end + end + if bar then + local content, pos = #pv.lines * lineH, pv.scroll * lineH + local top, thumbH = WG.FlowUI.Draw.ScrollerGeometry(bar[1], bar[2], bar[3], bar[4], content, pos) + local onThumb = top ~= nil and isInRect(mx, my, bar[1], top - thumbH, bar[3], top) + Scroller(bar[1], bar[2], bar[3], bar[4], content, pos, onThumb, pv.drag) + end + + mono:Begin() + mono:SetOutlineColor(look.outline) + for i = pv.scroll + 1, last do + local line = pv.lines[i] + local cy = floor(y2 - pad - (i - pv.scroll - 0.5) * lineH) + mono:Print(line.num, x1 + gutterW - pad, cy, fs, "rov") + mono:Print(line.shown, textX1, cy, fs, "ov") + end + mono:End() +end + +local function drawProfileDialog(mx, my) + local bx1, by1, bx2, by2, ok, cancel, field, discard, messageLines, messageStep, box = dialogGeometry() + local cs = metrics.csButton + local cx = floor((bx1 + bx2) * 0.5) + local tfs = floor(rowHeight * 0.6) + local sfs = floor(rowHeight * 0.5) + + -- The whole window, not the inset area inside it: a modal that leaves the panel's own + -- border lit does not read as covering it. + RectRound( + metrics.winX1, + metrics.winY1, + metrics.winX2, + metrics.winY2, + metrics.csPanel, + 1, + 1, + 1, + 1, + { 0, 0, 0, 0.55 } + ) + UiElement(bx1, by1, bx2, by2, 1, 1, 1, 1, 1, 1, 1, 1, WG.FlowUI.clampedOpacity) + + -- Anything whose accept saves is green, anything destructive is red, wherever it + -- appears; a tinted button brightens on hover instead of taking the white overlay. + local _, blocked = dialogName() + local acceptSaves = not blocked and (dialog.save or (not dialog.message and not dialog.danger)) + local buttons = { + { r = ok, danger = not blocked and dialog.danger, confirm = acceptSaves, inert = blocked }, + } + if cancel then + buttons[#buttons + 1] = { r = cancel } + end + if dialog.middle then + buttons[#buttons + 1] = { r = discard, danger = dialog.middle.danger } + end + for _, b in ipairs(buttons) do + local r = b.r + local hovered = isInRect(mx, my, r[1], r[2], r[3], r[4]) + local base = (b.danger and dangerFill) or (b.confirm and confirmFill) + local lift = (b.danger and dangerFillHover) or (b.confirm and confirmFillHover) + local fill = base and (hovered and lift or base) + drawButtonFace(r, fill or buttonFill) + if not fill and hovered and not b.inert then + Highlight(r[1], r[2], r[3], r[4], cs, hoverOpacity, { 1, 1, 1 }) + end + end + + -- Its own geometry and text, ahead of the dialog's own batch of text. + if box then + state.drawPreview(dialog.preview, box[1], box[2], box[3], box[4], mx, my) + end + + font:Begin() + font:SetOutlineColor(look.outline) + font:Print( + colorText .. text.fit(font, dialog.title, bx2 - bx1 - floor(32 * scale), tfs), + cx, + by2 - floor(26 * scale), + tfs, + "cov" + ) + if box then + font:Print( + text.fit(font, dialog.preview.summary, bx2 - bx1 - floor(32 * scale), sfs), + cx, + by2 - floor(48 * scale), + sfs, + "cov" + ) + end + if dialog.middle then + font:Print( + colorText .. dialog.middle.label, + floor((discard[1] + discard[3]) * 0.5), + floor((discard[2] + discard[4]) * 0.5), + sfs, + "cov" + ) + end + if messageLines then + -- Centred between the title and the buttons: a message dialog has no field, and a + -- message sitting where the field would be reads as pushed down against the buttons. + local titleBottom = by2 - floor(26 * scale) - floor(tfs * 0.5) + local top = floor((titleBottom + ok[4]) * 0.5 + (#messageLines - 1) * messageStep * 0.5) + for i = 1, #messageLines do + font:Print( + colorDim .. text.fit(font, messageLines[i], bx2 - bx1 - floor(32 * scale), sfs), + cx, + top - (i - 1) * messageStep, + sfs, + "cov" + ) + end + end + if cancel then + font:Print( + colorText .. L.cancel, + floor((cancel[1] + cancel[3]) * 0.5), + floor((cancel[2] + cancel[4]) * 0.5), + sfs, + "cov" + ) + end + font:Print( + (blocked and colorDim or colorText) .. acceptLabelFor(dialog), + floor((ok[1] + ok[3]) * 0.5), + floor((ok[2] + ok[4]) * 0.5), + sfs, + "cov" + ) + font:End() + + if not dialog.message then + nameBox:setRect(field[1], field[2], field[3], field[4], sfs) + nameBox:draw() + end +end + +-- Header and footer buttons, with hotId the one under the cursor. +local function drawButtons(hotId) + local bfs = floor(rowHeight * 0.55) + for _, set in ipairs(buttonSets) do + for _, b in ipairs(set) do + local r = b.rect + if r then + local enabled = buttonEnabled(b.id) + local hovered = enabled and hotId == b.id + -- A tinted button loses its colour under the usual white hover overlay, so it + -- brightens its own fill instead. + local fill = b.fill and ((not enabled and b.fillMuted) or (hovered and b.fillHover) or b.fill) + -- The page toggle sits pressed while its page is showing. + if b.toggle and state.page == "keyboard" then + fill = hovered and look.toggleFillHover or look.toggleFill + end + drawButtonFace(r, fill or buttonFill) + + -- The face lights under the cursor the way a row or the search field does. A + -- tinted button is the exception: it would lose its colour under the overlay, so + -- it brightens its own fill above instead. + if hovered and not fill then + Highlight(r[1], r[2], r[3], r[4], metrics.csButton, hoverOpacity, look.white) + end + + if b.icon then + -- Square inset so the 64x64 art keeps its aspect inside a wider button. The + -- icon brightens with the face, so the whole button reads as one control. + local inset = floor((r[4] - r[2]) * 0.22) + local side = (r[4] - r[2]) - inset * 2 + local ix = floor((r[1] + r[3] - side) * 0.5) + local iy = r[2] + inset + local shade = (not enabled and 0.4) or (hovered and 1 or 0.82) + -- Set explicitly: the icons are white-on-transparent, and whatever drew + -- before could leave a blend mode that renders them as solid squares. + glBlending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + glColor(shade, shade, shade, 1) + glTexture(b.icon) + glTexRect(ix, iy, ix + side, iy + side) + glTexture(false) + glColor(1, 1, 1, 1) + else + queueText( + (enabled and b.textOn or b.textOff) or L[b.id], + floor((r[1] + r[3]) * 0.5), + floor((r[2] + r[4]) * 0.5), + bfs, + "cov" + ) + end + end + end + end +end + +-- The thumb, where it is now. Nil when the list fits and no bar is drawn. Reached through +-- WG rather than a local of its own, this chunk being at the 200-local ceiling; it is only +-- asked for on a press or a hover test, so the lookup costs nothing that matters. +local function scrollerThumb() + return WG.FlowUI.Draw.ScrollerGeometry( + barX1, + listBottom(), + area.x2 - metrics.edgeInset, + listTop, + rowMetrics.totalH, + scrollOffset() + ) +end + +-- Reads the hover state and answers a signature of everything the baked panel is painted +-- from. Same signature, same picture, so the display list is replayed as it is. +local function panelSignature(mx, my) + local h = hover + local keyboardPage = state.page == "keyboard" + h.sb = (not keyboardPage and sidebarIndexAt(mx, my)) or 0 + h.row, h.zone, h.idx = 0, "", 0 + h.gk, h.ga, h.gb = "", 0, 0 + h.btn = "" + h.bar = 0 + h.kb = 0 + + -- Over the thumb itself, which lights it. The track either side is not part of this: + -- only the thumb is something to take hold of. + if not keyboardPage and mx >= barX1 and mx <= area.x2 - metrics.edgeInset then + local top, height = scrollerThumb() + if top and my <= top and my >= top - height then + h.bar = 1 + end + end + + if keyboardPage then + h.kb = state.keyboard:hitTest(mx, my) or 0 + elseif gridGroup then + if isInRect(mx, my, listX1, listBottom(), area.x2, listTop) then + local kind, a, b = gridZone(mx, my) + h.gk, h.ga, h.gb = kind or "", a or 0, b or 0 + end + elseif mx >= listX1 and mx <= listRight then + local r, top, bottom = rowAt(my) + local row = r and rows[scroll + r] + if row then + h.row = r + if row.type == "editable" then + local c1, c2 = bottom + metrics.chipInset, top - metrics.chipInset + local zone, idx = rowZone(rowLayout(row), mx, my, c1, c2) + h.zone, h.idx = zone or "", idx or 0 + elseif row.type == "header" and row.clear and mx >= listRight - metrics.rowPad * 4 then + h.zone = "clear" + end + end + end + + for _, set in ipairs(buttonSets) do + for _, b in ipairs(set) do + local r = b.rect + if r and isInRect(mx, my, r[1], r[2], r[3], r[4]) then + h.btn = b.id + end + end + end + + -- What the buttons read their enabled state from, alongside the hover and the list. + return h.sb + .. "|" + .. h.row + .. "|" + .. h.zone + .. "|" + .. h.idx + .. "|" + .. h.gk + .. "|" + .. h.ga + .. "|" + .. h.gb + .. "|" + .. h.btn + .. "|" + .. scroll + .. "|" + .. rowsGen + .. "|" + .. layoutGen + .. "|" + .. (dirty and 1 or 0) + .. "|" + .. (activeIsOwn() and 1 or 0) + .. "|" + .. h.bar + .. "|" + .. h.cat + .. "|" + .. (hover.drag and 1 or 0) + .. "|" + .. state.page + .. "|" + .. (keyboardPage and state.keyboard:signature(h.kb) or "") +end + +-- The card the keyboard page shows for an action: its label, what it does, its picture, its +-- category and where the catalog ranks it. Built from the resolved catalog on first use and +-- dropped with it; an action under a prefix family takes the family's card with the label the +-- list gave its row, and one the catalog never lists is its own id under Other. +function state.keyInfoFor(action) + local info = state.keyInfo + if not info then + info = { byAction = {}, prefixes = {} } + state.keyInfo = info + for gi, g in ipairs(resolvedCatalog or {}) do + if not g.hidden then + for ii, item in ipairs(g.items) do + local rank = gi * 1000 + ii + if item.prefix then + info.prefixes[#info.prefixes + 1] = { + prefix = item.prefix, + description = item.description, + icon = item.icon, + category = g.category, + rank = rank, + } + elseif item.action then + info.byAction[item.action] = { + label = item.label, + description = item.description, + icon = item.icon, + category = g.category, + rank = rank, + } + end + end + end + end + end + + local card = info.byAction[action] + if card then + return card + end + local best + for _, p in ipairs(info.prefixes) do + if p.prefix ~= "" and action:sub(1, #p.prefix) == p.prefix and (not best or #p.prefix > #best.prefix) then + best = p + end + end + card = { + label = state.labels[action] or action, + description = best and best.description, + icon = best and best.icon, + category = (best and best.category) or "categories.other", + rank = (best and best.rank) or math.huge, + } + info.byAction[action] = card + + return card +end + +-- Places the staged keymap on the keyboard, once per change to it. +function state.ensureKeyboard() + if state.keyboardGen ~= rowsGen then + state.keyboardGen = rowsGen + state.keyboard:place(working.binds, state.hidden, working.layout, catalogShiftPair, state.keyInfoFor) + end +end + +-- Shows the list or the keyboard. The tooltip is forgotten with the page: the same cursor +-- position means something else on the other one. +function state.setPage(page) + if state.page == page then + return + end + state.page = page + state.tipKey = nil + hover.kb = 0 + -- The rows' band depends on the page: the comparison band only shows on the list. + state.applyListTop() + -- The host sizes the panel to the page. + if state.pageHook then + state.pageHook(page) + end +end + +-- The keyboard page's body: the panel title where the list page puts it, then the keyboard. +function state.drawKeyboardPage(hoverIdx) + state.ensureKeyboard() + queueText(L.titleText, area.x1 + metrics.sidePad, area.y2 - metrics.titleY, metrics.titleFs, "ov") + state.keyboard:draw(hoverIdx) +end + +-- Everything under the header controls and above the modals: the sidebar, the list or +-- grid, the scroller and the buttons. Compiled into the panel display list, so an idle +-- frame replays it for one call instead of a few hundred draws. +local function drawPanel() + local h = hover + if state.page == "keyboard" then + state.drawKeyboardPage(h.kb) + else + drawSidebar(h.sb) + end + + if state.page == "keyboard" then + flushText() + elseif gridGroup then + drawGridMenu(h.gk, h.ga, h.gb) + flushText() + else + -- The Changed section's comparison strip, above its rows: a dark strip rather than a + -- heading, so it reads as a control and not as a second title over the first heading, + -- with a dim caption against the picker, which draws live over the strip's right end + -- since its list can open. + if state.compareBand() and metrics.compareY1 then + local y1, y2 = metrics.compareY1, metrics.compareY2 + RectRound(listX1, y1, listRight, y2, metrics.csSmall, 1, 1, 1, 1, look.previewFill) + local inset = floor(4 * scale) + queueText( + colorDim .. (L.compareWith or ""), + metrics.compareCaptionX, + text.baseline(font, y1 + inset, y2 - inset, metrics.compareFs), + metrics.compareFs, + "ro" + ) + end + -- Whole rows only: the band can end mid-row, and a row painted across the footer + -- would be clipped by nothing. + local base = scrollOffset() + local lb = listBottom() + for r = 1, #rows - scroll do + local row = rows[scroll + r] + if not row then + break + end + local top = listTop - (row.off - base) + local bottom = top - rowHeightOf(row) + if bottom < lb then + break + end + local hovered = h.row == r + drawRow(row, top, bottom, hovered, hovered and h.zone or "", h.idx) + end + flushText() + + Scroller(barX1, lb, area.x2 - metrics.edgeInset, listTop, rowMetrics.totalH, base, h.bar == 1, hover.drag) + end + + -- The picker's caption. Nothing about it changes between layouts, so it bakes with the + -- body rather than printing live beside the picker it names. + queueText(L.presetText, metrics.presetLabelX, metrics.presetLabelY, metrics.presetLabelFs, "o") + + -- Where staged edits go. On a default it shows before anything is staged, too: that is + -- when a player is working out whether editing it is safe. + local own = activeIsOwn() + local notice = (dirty and (own and L.noticeUnsavedText or L.noticeDefaultUnsavedText)) + or (not own and L.noticeDefaultText) + if notice then + queueText(notice, metrics.noticeX, metrics.noticeY, metrics.noticeFs, "o") + end + + drawButtons(h.btn) + flushText() +end + +-- The tooltip widget owns the hover delay and only draws once the cursor settles; it +-- keeps the area table, so this is redone whenever layoutHeader makes new rects. +local function registerTooltips() + local own = activeIsOwn() + for _, b in ipairs(headerButtons) do + if b.rect then + WG["tooltip"].AddTooltip(b.tooltipId, b.rect, L[(not own and b.tipLocked) or b.tip], nil, L[b.id]) + end + end + state.tooltipsRegistered = true +end + +-- What the cursor is over, said in a tooltip: a preset's description in the picker, what the +-- column's Changed entry lists, and on a row the action's description, the other actions its +-- hovered key drives, and the key the base preset had. Built once per thing hovered and shown +-- every frame after, the tooltip widget showing only what it was told this frame. +function state.showTooltips(mx, my) + local tip = WG["tooltip"] + if not tip or dialog or capturing then + return + end + + local key, title, lines + -- The preset picker's options, and the comparison picker's while its band is up: both + -- name presets, so both get the preset's description. + local pick = presetDropdown:optionAt(mx, my) + local pickOptions, pickSelected = presetOptions, presetDropdown.selected + if not pick and state.compareBand() then + pick = state.compareDropdown:optionAt(mx, my) + pickOptions, pickSelected = state.compareDropdown.options, state.compareDropdown.selected + end + if pick then + local opt = pick > 0 and pickOptions[pick] or pickOptions[pickSelected] + if opt and not opt.name then + opt = nil + end + if opt then + key = "preset|" .. opt.name + title = opt.name + if key ~= state.tipKey then + lines = {} + local builtin = profiles.isBuiltin(opt.name) + if builtin then + if builtin.description then + lines[#lines + 1] = colorText .. BAR.I18N(builtin.description) + end + lines[#lines + 1] = colorDim .. L.presetDefault + else + local base = profiles.baseOf(opt.name) + lines[#lines + 1] = colorDim + .. (base and BAR.I18N("ui.keybinds.editor.presetBasedOn", { name = base.name }) or L.presetOwn) + end + end + end + elseif state.page ~= "list" then + -- The keyboard page: the key under the cursor, on the layer showing, or the view toggle. + if hover.kb ~= 0 then + state.ensureKeyboard() + key, title = state.keyboard:tooltip(hover.kb) + if key and key ~= state.tipKey then + lines = state.keyboard:tooltipLines(hover.kb) + end + end + elseif hover.sb > 0 and categories[hover.sb] and categories[hover.sb].key == state.changedKey then + key = "changed|" .. tostring(state.base and state.base.name) + title = categories[hover.sb].label + if key ~= state.tipKey then + if state.base then + lines = { colorText .. BAR.I18N("ui.keybinds.editor.changedTooltip", { name = state.base.name }) } + else + lines = { colorDim .. L.changedNoneTooltip } + end + end + elseif hover.row > 0 then + local row = rows[scroll + hover.row] + if row and row.type == "editable" then + key = "row|" .. row.action .. "|" .. hover.zone .. "|" .. hover.idx .. "|" .. rowsGen .. "|" .. layoutGen + title = row.label + if key ~= state.tipKey then + lines = {} + if row.description then + lines[#lines + 1] = colorText .. row.description + end + local lay = rowLayout(row) + local m = hover.idx > 0 and lay.mets[hover.idx] + if m and m.others then + local names = {} + for i, o in ipairs(m.others) do + local name = state.labels[o.action] or o.action + names[i] = o.before and BAR.I18N("ui.keybinds.editor.conflictFirst", { action = name }) or name + end + -- A warning when the sharing is the player's; a note when the game ships it so. + lines[#lines + 1] = (m.clash and colorDanger or colorDim) + .. BAR.I18N( + "ui.keybinds.editor.conflict", + { keys = m.group.display, actions = table.concat(names, ", ") } + ) + lines[#lines + 1] = colorDim .. (m.clash and L.conflictOrder or L.conflictShipped) + end + if row.change and state.base then + if #row.change > 0 then + lines[#lines + 1] = colorHeader + .. BAR.I18N("ui.keybinds.editor.defaultIn", { name = state.base.name, keys = lay.ghostKeys }) + else + lines[#lines + 1] = colorHeader + .. BAR.I18N("ui.keybinds.editor.defaultNone", { name = state.base.name }) + end + lines[#lines + 1] = colorDim .. L.revertHint + end + end + end + end + + if not key then + state.tipKey = nil + + return + end + if key ~= state.tipKey then + state.tipKey, state.tipTitle = key, title + if lines and #lines > 0 then + local body = table.concat(lines, "\n") + if font.WrapText then + body = font:WrapText(body, (tip.getFontsize and tip.getFontsize() or 12) * 90) + end + state.tipText = text.carryColors(body) + else + state.tipText = nil + end + end + if state.tipText then + tip.ShowTooltip("keybindeditor", state.tipText, nil, nil, state.tipTitle) + end +end + +-- Paints the whole panel. The header controls and the modals draw live; the body is +-- replayed from its display list until panelSignature says something in it moved. +-- Which of the popups is up, and where. Defined down here rather than beside the rest of +-- `shade`: it reads the popup state and geometry, none of which exists that far up. +function shade.update() + if capturing then + local bx1, by1, bx2, by2 = captureGeometry() + shade.rect("capture", bx1, by1, bx2, by2) + else + shade.rect("capture") + end + + if dialog then + local bx1, by1, bx2, by2 = dialogGeometry() + shade.rect("dialog", bx1, by1, bx2, by2) + else + shade.rect("dialog") + end + + -- The list the picker drops, which stands clear of the control and over the rows. + local opts = presetDropdown and presetDropdown:isOpen() and presetDropdown.optRects + if opts and opts[1] then + shade.rect("picker", opts[1].x1, opts[#opts].y1, opts[1].x2, opts[1].y2) + else + shade.rect("picker") + end + + local compare = state.compareDropdown + local copts = compare and state.compareBand() and compare:isOpen() and compare.optRects + if copts and copts[1] then + shade.rect("compare", copts[1].x1, copts[#copts].y1, copts[1].x2, copts[1].y2) + else + shade.rect("compare") + end +end +function view.draw() + if not font then + view.init() + end + if not working then + view.refresh() + end + if state.layoutPending then + layoutHeader() + end + + -- Pinned rather than assumed: widgets on lower layers draw first and leave blending, + -- colour and depth wherever they finished, which changes how everything below + -- composites from one frame to the next. + glTexture(false) + glColor(1, 1, 1, 1) + glBlending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + gl.DepthTest(false) + + local rawMx, rawMy, lmb = spGetMouseState() + if hover.drag then + if lmb then + scrollFromY(rawMy) + else + hover.drag = false + end + end + + -- Prevent the hover over preset options and modals from also being detected by the + -- regular rows, sidebar and buttons sitting underneath them. + local mx, my = rawMx, rawMy + if dialog or capturing or presetDropdown:isOpen() or state.compareDropdown:isOpen() then + mx, my = -1, -1 + end + + -- The keyboard page shows the layer of whatever is held on the real keyboard, for as long + -- as it is held; the signature below carries the layer, so the picture follows. + if state.page == "keyboard" then + local alt, ctrl, meta, shift = Spring.GetModKeyState() + state.keyboard:setHeld(alt, ctrl, meta, shift) + end + + local sig = panelSignature(mx, my) + if sig ~= state.panelSig then + if state.panelList then + gl.DeleteList(state.panelList) + end + state.panelList = gl.CreateList(drawPanel) + state.panelSig = sig + end + gl.CallList(state.panelList) + + searchBox:draw() + + -- The comparison picker, live like the preset picker: its list opens over the rows. + if state.compareBand() then + if state.compareDropdown:isOpen() then + shade.float("compare", function() + state.compareDropdown:draw() + end) + else + shade.drop("compare") + state.compareDropdown:draw() + end + else + shade.drop("compare") + end + + if not state.tooltipsRegistered and WG["tooltip"] then + registerTooltips() + end + + -- Each of these covers UI rather than map, so it takes the blur with it and is drawn + -- back on top of it. See `shade` for why that is two steps and not one. + if presetDropdown:isOpen() then + shade.float("picker", function() + presetDropdown:draw() + end) + else + shade.drop("picker") + presetDropdown:draw() + end + + -- Real cursor: these are the overlay, so the hover is theirs to detect. + if capturing then + shade.float("capture", function() + drawCaptureModal(rawMx, rawMy) + end) + else + shade.drop("capture") + end + + if dialog then + shade.float("dialog", function() + drawProfileDialog(rawMx, rawMy) + end) + else + shade.drop("dialog") + end + + -- After they have laid themselves out, so the blur behind one is the right size on + -- the frame it appears rather than the one after. + shade.update() + + state.showTooltips(rawMx, rawMy) +end + +-- Scrolls so the thumb's top sits where the cursor has dragged it. The offset taken at +-- the grab is what keeps this relative: the thumb moves with the cursor rather than +-- centring itself on it, so taking hold of it does not shift the list before the drag. +scrollFromY = function(y) + local _, _, trackTop, travel = scrollerThumb() + if not travel or travel <= 0 then + return + end + + local f = (trackTop - (y - hover.grab)) / travel + if f < 0 then + f = 0 + elseif f > 1 then + f = 1 + end + scroll = floor(f * maxScroll() + 0.5) + clampScroll() +end + +---------------------------------------------------------------- +-- Input +---------------------------------------------------------------- + +-- Scrolls the list; a modal swallows the wheel instead, the import preview scrolling its +-- own lines with it. +function view.mouseWheel(up, value) + if dialog then + local pv = dialog.preview + if pv then + local mx, my = spGetMouseState() + local _, _, _, _, _, _, _, _, _, _, box = dialogGeometry() + if box and isInRect(mx, my, box[1], box[2], box[3], box[4]) then + -- Clamped at the top here and at the bottom by the draw, which knows how many + -- lines the box holds. + pv.scroll = math.max(0, pv.scroll + (up and -3 or 3)) + end + end + + return + end + if capturing or gridGroup or state.page == "keyboard" then + return + end + + local mx, my = spGetMouseState() + -- Over the column it scrolls the column, over anything else the list. A wheel that + -- moved the list while the cursor was on the categories would read as broken. + if mx <= area.x1 + sidebarW and my > listBottom() and my <= sidebarTop() then + catScrolled(up and -1 or 1) + elseif my >= listBottom() and my <= listTop then + -- The chat history's modifiers: Ctrl moves three notches' worth at once, Shift a whole + -- page - the rows the band holds from where the list is now, since headings are taller + -- than the bindings under them. + local _, ctrl, _, shift = Spring.GetModKeyState() + local step = ctrl and 9 or 3 + if shift then + ensureRowMetrics() + local band, used = listTop - listBottom(), 0 + step = 0 + for i = scroll + 1, #rows do + used = used + rowHeightOf(rows[i]) + if used > band then + break + end + step = step + 1 + end + step = math.max(1, step) + end + scroll = scroll + (up and -step or step) + clampScroll() + end +end + +-- Which zone of an editable row a click hit, through the same layout drawRow painted. +local function hitTestRow(row, x) + local lay = rowLayout(row) + local zone, i = rowZone(lay, x) + if i then + return zone, lay.mets[i].group.raws + end + + return zone +end + +-- Returns true when the click landed in the column, selected or not, so it never falls +-- through to the list behind it. +local function sidebarPress(x, y) + if x < area.x1 or x > area.x1 + sidebarW or y < listBottom() or y > sidebarTop() then + return false + end + + local i = sidebarIndexAt(x, y) + local c = i and categories[i] + if c and selectedCategory ~= c.key then + selectedCategory = c.key + scroll = 0 + rebuildRows() + end + + return true +end + +-- Routes a click on a keybind row to the edit it implies. +local function handleZone(kind, action, label, raws) + if kind == "remove" then + -- One chip, one snapshot to take back, however many binds it stood for. + state.batching, state.batchEdited = true, false + for _, raw in ipairs(raws) do + removeKeyset(action, raw) + end + state.batching = false + if state.batchEdited then + state.pushUndo() + end + elseif kind == "revert" then + state.revert(action) + elseif kind == "add" then + startCapture(action, label) + elseif kind == "rebind" then + startCapture(action, label, raws) + end +end + +-- Routes a click to whichever layer is on top: modal, dropdown, sidebar, then the list. +function view.mousePress(x, y, button) + if not isInRect(x, y, area.x1, area.y1, area.x2, area.y2) then + return false + end + + if dialog then + if button == 1 then + local bx1, by1, bx2, by2, ok, cancel, field, discard, _, _, box = dialogGeometry() + if isInRect(x, y, ok[1], ok[2], ok[3], ok[4]) then + acceptDialog() + elseif dialog.middle and isInRect(x, y, discard[1], discard[2], discard[3], discard[4]) then + middleDialog() + elseif + (cancel ~= nil and isInRect(x, y, cancel[1], cancel[2], cancel[3], cancel[4])) + or x < bx1 + or x > bx2 + or y < by1 + or y > by2 + then + cancelDialog() + elseif box and isInRect(x, y, box[1], box[2], box[3], box[4]) then + -- Taking hold of the preview's bar: on the thumb a grab that keeps the lines put, + -- on the track a jump to the cursor and then a drag from the thumb's middle. + local pv = dialog.preview + local lineH, _, _, most, bar = state.previewGeometry(pv, box[1], box[2], box[3], box[4]) + if bar and isInRect(x, y, bar[1], bar[2], bar[3], bar[4]) then + local top, thumbH = WG.FlowUI.Draw.ScrollerGeometry( + bar[1], + bar[2], + bar[3], + bar[4], + #pv.lines * lineH, + pv.scroll * lineH + ) + if top then + pv.drag = true + if y <= top and y >= top - thumbH then + pv.grab = y - top + else + pv.grab = -floor(thumbH * 0.5) + state.previewScrollFromY(pv, bar, lineH, most, y) + end + end + end + elseif not dialog.message then + nameBox:mousePress(x, y) + end + end + + return true + end + + -- In the modal, mouse1 drives its controls; only side buttons (mouse4+) bind. + if capturing then + local bx1, by1, bx2, by2, ok, cancel = captureGeometry() + if button == 1 then + -- Only while Accept is actually on screen; where it would be is otherwise just + -- part of the modal and swallows the click. + if captureCanAccept() and isInRect(x, y, ok[1], ok[2], ok[3], ok[4]) then + commitCapture(chainRaw()) + elseif + (isInRect(x, y, cancel[1], cancel[2], cancel[3], cancel[4])) + or x < bx1 + or x > bx2 + or y < by1 + or y > by2 + then + capturing = nil + end + elseif button >= 4 then + appendChain({ sym = "mouse" .. button, mods = modPrefix() }) + end + return true + end + + if button ~= 1 then + return true + end + + -- The open list draws over the header and footer, so it gets the click before they do. + -- Closed, this only claims its own toggle and everything below still sees the press. + local ddWasOpen = presetDropdown:isOpen() + if presetDropdown:mousePress(x, y) then + searchBox:blur() + capturing = nil + + return true + end + if ddWasOpen then + return true + end + + -- The comparison picker likewise, while its band is up. + if state.compareBand() then + local wasOpen = state.compareDropdown:isOpen() + if state.compareDropdown:mousePress(x, y) then + searchBox:blur() + + return true + end + if wasOpen then + return true + end + end + + for _, set in ipairs(buttonSets) do + for _, b in ipairs(set) do + local r = b.rect + if r and isInRect(x, y, r[1], r[2], r[3], r[4]) then + searchBox:blur() + presetDropdown:close() + if buttonEnabled(b.id) then + if b.id == "save" then + startSave() + elseif b.id == "reset" then + startReset() + elseif b.id == "duplicate" then + startDuplicate() + elseif b.id == "edit" then + startEdit() + elseif b.id == "export" then + startClipboard(true) + elseif b.id == "import" then + startClipboard(false) + elseif b.id == "keyboard" then + state.setPage(state.page == "keyboard" and "list" or "keyboard") + end + end + + return true + end + end + end + + if searchBox:mousePress(x, y) then + capturing = nil + return true + end + searchBox:blur() + + -- The keyboard page: a modifier toggles its layer, the toggle swaps the view, and a bound + -- key goes to the list page filtered to that key on that layer, which lists everything on + -- it with its bindings to hand. The search text is left alone: the filter is its own thing. + if state.page == "keyboard" then + state.ensureKeyboard() + local kind, key, layer = state.keyboard:mousePress(x, y, button) + if kind == "key" then + local tokens, mods = {}, {} + for _, token in ipairs(key.tokens or {}) do + tokens[token] = true + end + for name in layer:gmatch("[^+]+") do + mods[name] = true + end + state.setPage("list") + selectedCategory = nil + state.setKeyFilter({ + id = key.id, + layer = layer, + tokens = tokens, + mods = mods, + display = state.keyboard:keysetName(key, layer), + }) + end + + return true + end + + -- Picking a category is asking for the whole of it, so a key filter goes first. + if state.keyFilter and x >= area.x1 and x <= area.x1 + sidebarW and y > listBottom() and y <= sidebarTop() then + state.setKeyFilter(nil) + end + if sidebarPress(x, y) then + return true + end + + if not gridGroup and isInRect(x, y, barX1, listBottom(), area.x2, listTop) then + -- Taking hold of the bar. On the thumb that is a grab and the list stays put; on the + -- track either side the thumb jumps to the cursor first and is then dragged from its + -- middle, which is what a press on bare track is asking for. Inline because this chunk + -- is at Lua's ceiling of 200 locals and a function of its own would need a slot. + local top, height = scrollerThumb() + if top then + hover.drag = true + if y <= top and y >= top - height then + hover.grab = y - top + else + hover.grab = -floor(height * 0.5) + scrollFromY(y) + end + end + + return true + end + + if gridGroup and isInRect(x, y, listX1, listBottom(), area.x2, listTop) then + return gridPress(x, y) + end + + if isInRect(x, y, listX1, listBottom(), listRight, listTop) then + -- Through the same lookup the drawing uses, so the band's last partial row - which + -- is never painted - cannot be clicked either. + local r = rowAt(y) + local row = r and rows[scroll + r] + if row and row.type == "editable" then + local kind, raw = hitTestRow(row, x) + if kind then + handleZone(kind, row.action, row.label, raw) + end + elseif row and row.type == "header" and row.clear and x >= listRight - metrics.rowPad * 4 then + state.setKeyFilter(nil) + elseif row and row.type == "link" then + selectedCategory = row.category + scroll = 0 + rebuildRows() + end + return true + end + + return true +end + +function view.textInput(char) + if dialog then + return not dialog.message and nameBox:textInput(char) + end + if searchBox and searchBox:isFocused() then + return searchBox:textInput(char) + end + + return false +end + +-- Keys, offered to the modal, the capture, the dropdown and the search box in that order. +function view.keyPress(key, scanCode) + if dialog then + if key == KEYSYMS.ESCAPE then + cancelDialog() + elseif key == KEYSYMS.RETURN then + acceptDialog() + elseif not dialog.message then + -- Focus is dropped by the editbox on Escape/Return, which are handled above. + nameBox:keyPress(key) + end + + return true + end + + if capturing then + if key == 27 then + capturing = nil + else + -- Skip auto-repeat; only the initial press adds an element (release clears pressed). + local sym = pressSym(key, scanCode) + if sym and not capturing.pressed[scanCode] then + capturing.pressed[scanCode] = true + appendChain({ sym = sym, mods = modPrefix() }) + end + end + return true + end + + if presetDropdown and presetDropdown:isOpen() then + if key == 27 then + presetDropdown:close() + end + return true + end + if state.compareDropdown and state.compareDropdown:isOpen() then + if key == 27 then + state.compareDropdown:close() + end + return true + end + + -- A grid category replaces the list outright, and picking another category in the + -- column is otherwise the only way back out of it. Escape is the other way, and it + -- has to come before the panel closes: leaving a view is what the key is for. + if gridGroup and key == 27 then + selectedCategory = nil + scroll = 0 + rebuildRows() + + return true + end + + -- Ctrl+Z takes the last edit back. Below the capture and the dropdown, which take every + -- key while they are up; above the search field, which has no use for it. + if key == KEYSYMS.Z then + local _, ctrl = Spring.GetModKeyState() + if ctrl then + state.undoEdit() + + return true + end + end + + -- Escape empties the search before it closes the panel: the list being read is the one + -- the search made, and the first Escape is asking for that back. With nothing left to + -- clear it goes unclaimed, and the widget above closes the panel on it. + if key == KEYSYMS.ESCAPE then + -- A key filter goes before the search text: it is the narrower of the two. + if state.keyFilter then + state.setKeyFilter(nil) + + return true + end + if searchBox and searchBox:getText() ~= "" then + -- Focus stays, so the next thing typed starts a new search. + searchBox:setText("") + + return true + end + if searchBox then + searchBox:blur() + end + + return false + end + + if searchBox and searchBox:isFocused() then + return searchBox:keyPress(key) + end + + return false +end + +-- Only a capture cares about releases, to know a held key has gone. +function view.keyRelease(key, scanCode) + if capturing then + capturing.pressed[scanCode] = nil + end +end + +return view diff --git a/luaui/Include/keybind_keyboard.lua b/luaui/Include/keybind_keyboard.lua new file mode 100644 index 00000000000..5cbb4c0a217 --- /dev/null +++ b/luaui/Include/keybind_keyboard.lua @@ -0,0 +1,1165 @@ +-- The keyboard page of the keybind editor: a full-size keyboard drawn key by key, each cap +-- carrying the action it fires on the layer shown. A layer is a set of modifiers. Clicking +-- Shift, Ctrl, Alt or Meta on the drawn keyboard toggles that modifier into the layer, and +-- holding the real one shows it for as long as it is held, so the page reads like the +-- keyboard it stands for. Two views share the page: the main block, and the arrows, +-- navigation keys and number pad, with the modifiers beside them so the layers still work. +-- It is an overview rather than an editor: a click on a bound key hands that key to the +-- list page, which is where bindings are changed. +-- +-- Bindings come in as the editor's working keymap, so staged edits show here before they +-- are saved. Each is placed on the key that starts it (a chain lands on its first tap) under +-- the modifiers it names. Any+ bindings fire whatever is held, so they sit on every layer, +-- after the bindings that name that layer exactly - the order the engine tries them in. +-- +-- The face of a key shows one action, and it is the one a player thinks of the key as +-- doing: the first by catalog order, not by bind order. The engine walks a key's actions +-- in bind order until one takes it, and the presets lean on that to put a special case +-- ahead of the general one - the Grid preset binds "stopproduction" ahead of "stop" on G, +-- and the spectator's "specteam" ahead of "group select" on the digits. Catalog order puts +-- the general action first, which is what the key is for. The tooltip lists them all. + +local keybindModel = VFS.Include("luaui/Include/keybind_model.lua") +local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") +local Search = VFS.Include("luaui/Include/search.lua") +local text = VFS.Include("luaui/Include/keybind_text.lua") + +local floor = math.floor +local max = math.max +local min = math.min +local isInRect = math.isInRect +local glColor = gl.Color +local glTexture = gl.Texture +local glTexRect = gl.TexRect +local glBlending = gl.Blending + +---@class KeybindKeyboard +---@field keys table[] Every drawn key, in definition order; `id` is its index +---@field shown integer[] The keys placed in the current view +---@field view string "main" or "numpad" +---@field toggled table Modifiers toggled on the drawn keyboard +---@field held table Modifiers held on the real one +---@field layoutName string? The keyboard layout the names were resolved for +---@field tokenIndex table? Canonical key token -> key index +---@field query table The search, from Search.query +---@field queryTokens string[] +---@field queryGen integer +---@field filter table? The key the list is filtered to: `id` and `layer` +---@field filterGen integer +---@field gen integer Bumped by every placement +---@field layoutGen integer Bumped by every resize +---@field unplaced integer Bindings on keys neither view draws +---@field L table The page's strings +---@field area table +---@field scale number +---@field font table? +---@field UiKey function? +---@field UiButton function? +---@field Highlight function? +---@field infoFor function? +---@field shiftPair table +---@field hintLines string[]? +---@field hintWidth number? +---@field unit number? +---@field frames table Per view: the origin the keys are placed from +---@field button table? The view toggle's rect +---@field cs number +---@field pad number +---@field padY number +---@field nameFs number +---@field labelFs number +---@field moreFs number +---@field iconSize number +---@field hintFs number +---@field titleFs number +---@field buttonFs number +local M = {} +M.__index = M + +---------------------------------------------------------------- +-- The keyboard +---------------------------------------------------------------- + +-- Every key the page can draw, once, with where it sits in each view that shows it: `main` +-- and `numpad` give `x` from the view's left edge and `y` from the top in key units, `w` +-- and `h` in units when not one. A view is as many units wide as `viewCols` says, and the +-- page is `ROWS` tall: the caption row on top, then the keys spaced the way they are on the +-- keyboard itself - the function row under the caption, the main block half a unit lower. +-- +-- Named keys carry the engine's names for them: `scan` for the scancode names (sc_) +-- and `code` for the keycode ones, every spelling the engine accepts. A character key +-- carries the qwerty character of its position instead. Its scancode name is that character +-- (or the engine's word for a punctuation key, `word`), and the player's keyboard layout +-- decides both the character printed on it and the keycode that lands there. `shifted` is +-- the symbol the US layout prints above a punctuation key, shown when the layout leaves +-- that key as it is. `mod` marks a modifier key, which toggles its layer when clicked; the +-- modifiers sit in both views, so a layer can be toggled from either. +local ROWS = 7.5 +local viewCols = { main = 15, numpad = 10 } +-- How large the text on the keys reads, over the base shares of the key set in setArea. +local KEY_TEXT_SCALE = 1.2 +local viewOrder = { "main", "numpad" } + +local up, down, left, right = "\226\134\145", "\226\134\147", "\226\134\144", "\226\134\146" + +local keyDefs = { + { main = { x = 14, y = 0 }, numpad = { x = 4.5, y = 1 }, name = "Pause", scan = { "pause" }, code = { "pause" } }, + + { main = { x = 0, y = 1 }, name = "Esc", scan = { "esc", "escape" }, code = { "esc", "escape" } }, + { main = { x = 2, y = 1 }, name = "F1", scan = { "f1" }, code = { "f1" } }, + { main = { x = 3, y = 1 }, name = "F2", scan = { "f2" }, code = { "f2" } }, + { main = { x = 4, y = 1 }, name = "F3", scan = { "f3" }, code = { "f3" } }, + { main = { x = 5, y = 1 }, name = "F4", scan = { "f4" }, code = { "f4" } }, + { main = { x = 6.5, y = 1 }, name = "F5", scan = { "f5" }, code = { "f5" } }, + { main = { x = 7.5, y = 1 }, name = "F6", scan = { "f6" }, code = { "f6" } }, + { main = { x = 8.5, y = 1 }, name = "F7", scan = { "f7" }, code = { "f7" } }, + { main = { x = 9.5, y = 1 }, name = "F8", scan = { "f8" }, code = { "f8" } }, + { main = { x = 11, y = 1 }, name = "F9", scan = { "f9" }, code = { "f9" } }, + { main = { x = 12, y = 1 }, name = "F10", scan = { "f10" }, code = { "f10" } }, + { main = { x = 13, y = 1 }, name = "F11", scan = { "f11" }, code = { "f11" } }, + { main = { x = 14, y = 1 }, name = "F12", scan = { "f12" }, code = { "f12" } }, + + { + main = { x = 0, y = 2.5 }, + char = "`", + word = "backquote", + shifted = "~", + scan = { "`" }, + code = { "~", "tilde", "backquote" }, + }, + { main = { x = 1, y = 2.5 }, char = "1" }, + { main = { x = 2, y = 2.5 }, char = "2" }, + { main = { x = 3, y = 2.5 }, char = "3" }, + { main = { x = 4, y = 2.5 }, char = "4" }, + { main = { x = 5, y = 2.5 }, char = "5" }, + { main = { x = 6, y = 2.5 }, char = "6" }, + { main = { x = 7, y = 2.5 }, char = "7" }, + { main = { x = 8, y = 2.5 }, char = "8" }, + { main = { x = 9, y = 2.5 }, char = "9" }, + { main = { x = 10, y = 2.5 }, char = "0" }, + { main = { x = 11, y = 2.5 }, char = "-", word = "minus", shifted = "_", scan = { "-" } }, + { main = { x = 12, y = 2.5 }, char = "=", word = "equals", shifted = "+", scan = { "=" } }, + { main = { x = 13, y = 2.5, w = 2 }, name = "Backspace", scan = { "backspace" }, code = { "backspace" } }, + + { main = { x = 0, y = 3.5, w = 1.5 }, name = "Tab", scan = { "tab" }, code = { "tab" } }, + { main = { x = 1.5, y = 3.5 }, char = "Q" }, + { main = { x = 2.5, y = 3.5 }, char = "W" }, + { main = { x = 3.5, y = 3.5 }, char = "E" }, + { main = { x = 4.5, y = 3.5 }, char = "R" }, + { main = { x = 5.5, y = 3.5 }, char = "T" }, + { main = { x = 6.5, y = 3.5 }, char = "Y" }, + { main = { x = 7.5, y = 3.5 }, char = "U" }, + { main = { x = 8.5, y = 3.5 }, char = "I" }, + { main = { x = 9.5, y = 3.5 }, char = "O" }, + { main = { x = 10.5, y = 3.5 }, char = "P" }, + { main = { x = 11.5, y = 3.5 }, char = "[", word = "leftbracket", shifted = "{", scan = { "[" } }, + { main = { x = 12.5, y = 3.5 }, char = "]", word = "rightbracket", shifted = "}", scan = { "]" } }, + { + main = { x = 13.5, y = 3.5, w = 1.5 }, + char = "\\", + word = "backslash", + shifted = "|", + scan = { "\\" }, + code = { "backslash" }, + }, + + { main = { x = 0, y = 4.5, w = 1.75 }, name = "Caps Lock", code = { "capslock" } }, + { main = { x = 1.75, y = 4.5 }, char = "A" }, + { main = { x = 2.75, y = 4.5 }, char = "S" }, + { main = { x = 3.75, y = 4.5 }, char = "D" }, + { main = { x = 4.75, y = 4.5 }, char = "F" }, + { main = { x = 5.75, y = 4.5 }, char = "G" }, + { main = { x = 6.75, y = 4.5 }, char = "H" }, + { main = { x = 7.75, y = 4.5 }, char = "J" }, + { main = { x = 8.75, y = 4.5 }, char = "K" }, + { main = { x = 9.75, y = 4.5 }, char = "L" }, + { main = { x = 10.75, y = 4.5 }, char = ";", word = "semicolon", shifted = ":", scan = { ";" } }, + { main = { x = 11.75, y = 4.5 }, char = "'", word = "apostrophe", shifted = '"', scan = { "'" } }, + { main = { x = 12.75, y = 4.5, w = 2.25 }, name = "Enter", scan = { "return" }, code = { "return", "enter" } }, + + { + main = { x = 0, y = 5.5, w = 2.25 }, + numpad = { x = 0, y = 2.5, w = 2 }, + name = "Shift", + mod = "shift", + scan = { "shift" }, + code = { "shift" }, + }, + { main = { x = 2.25, y = 5.5 }, char = "Z" }, + { main = { x = 3.25, y = 5.5 }, char = "X" }, + { main = { x = 4.25, y = 5.5 }, char = "C" }, + { main = { x = 5.25, y = 5.5 }, char = "V" }, + { main = { x = 6.25, y = 5.5 }, char = "B" }, + { main = { x = 7.25, y = 5.5 }, char = "N" }, + { main = { x = 8.25, y = 5.5 }, char = "M" }, + { main = { x = 9.25, y = 5.5 }, char = ",", word = "comma", shifted = "<", scan = {} }, + { main = { x = 10.25, y = 5.5 }, char = ".", word = "period", shifted = ">", scan = { "." } }, + { main = { x = 11.25, y = 5.5 }, char = "/", word = "slash", shifted = "?", scan = { "/" } }, + { main = { x = 12.25, y = 5.5, w = 2.75 }, name = "Shift", mod = "shift", code = { "rshift" } }, + + { + main = { x = 0, y = 6.5, w = 1.25 }, + numpad = { x = 0, y = 3.5, w = 2 }, + name = "Ctrl", + mod = "ctrl", + scan = { "ctrl" }, + code = { "ctrl" }, + }, + { + main = { x = 1.25, y = 6.5, w = 1.25 }, + numpad = { x = 0, y = 5.5, w = 2 }, + name = "Meta", + mod = "meta", + scan = { "meta" }, + code = { "meta" }, + }, + { + main = { x = 2.5, y = 6.5, w = 1.25 }, + numpad = { x = 0, y = 4.5, w = 2 }, + name = "Alt", + mod = "alt", + scan = { "alt" }, + code = { "alt" }, + }, + { main = { x = 3.75, y = 6.5, w = 6.25 }, name = "Space", scan = { "space" }, code = { "space" } }, + { main = { x = 10, y = 6.5, w = 1.25 }, name = "Alt", mod = "alt", code = { "ralt" } }, + { main = { x = 13.75, y = 6.5, w = 1.25 }, name = "Ctrl", mod = "ctrl", code = { "rctrl" } }, + + -- The navigation keys, the arrows and the number pad, laid out as they sit to the right of + -- the main block, with the modifiers in a column to their left. + { + numpad = { x = 2.5, y = 1 }, + name = "Print", + scan = { "printscreen", "print" }, + code = { "printscreen", "print" }, + }, + -- Printed the way a keycap prints them, the full names being wider than a key. + { numpad = { x = 3.5, y = 1 }, name = "ScrLk", code = { "scrollock" } }, + { numpad = { x = 2.5, y = 2.5 }, name = "Insert", scan = { "insert" }, code = { "insert" } }, + { numpad = { x = 3.5, y = 2.5 }, name = "Home", scan = { "home" }, code = { "home" } }, + { numpad = { x = 4.5, y = 2.5 }, name = "PgUp", scan = { "pageup" }, code = { "pageup" } }, + { numpad = { x = 2.5, y = 3.5 }, name = "Delete", scan = { "delete" }, code = { "delete" } }, + { numpad = { x = 3.5, y = 3.5 }, name = "End", scan = { "end" }, code = { "end" } }, + { numpad = { x = 4.5, y = 3.5 }, name = "PgDn", scan = { "pagedown" }, code = { "pagedown" } }, + { numpad = { x = 3.5, y = 5.5 }, name = up, scan = { "up" }, code = { "up" } }, + { numpad = { x = 2.5, y = 6.5 }, name = left, scan = { "left" }, code = { "left" } }, + { numpad = { x = 3.5, y = 6.5 }, name = down, scan = { "down" }, code = { "down" } }, + { numpad = { x = 4.5, y = 6.5 }, name = right, scan = { "right" }, code = { "right" } }, + + { numpad = { x = 6, y = 2.5 }, name = "NumLk", code = { "numlock" } }, + { numpad = { x = 7, y = 2.5 }, name = "/", scan = { "numpad/" }, code = { "numpad/" } }, + { numpad = { x = 8, y = 2.5 }, name = "*", scan = { "numpad*" }, code = { "numpad*" } }, + { numpad = { x = 9, y = 2.5 }, name = "-", scan = { "numpad-" }, code = { "numpad-" } }, + { numpad = { x = 6, y = 3.5 }, name = "7", scan = { "numpad7" }, code = { "numpad7" } }, + { numpad = { x = 7, y = 3.5 }, name = "8", scan = { "numpad8" }, code = { "numpad8" } }, + { numpad = { x = 8, y = 3.5 }, name = "9", scan = { "numpad9" }, code = { "numpad9" } }, + { numpad = { x = 9, y = 3.5, h = 2 }, name = "+", scan = { "numpad+" }, code = { "numpad+" } }, + { numpad = { x = 6, y = 4.5 }, name = "4", scan = { "numpad4" }, code = { "numpad4" } }, + { numpad = { x = 7, y = 4.5 }, name = "5", scan = { "numpad5" }, code = { "numpad5" } }, + { numpad = { x = 8, y = 4.5 }, name = "6", scan = { "numpad6" }, code = { "numpad6" } }, + { numpad = { x = 6, y = 5.5 }, name = "1", scan = { "numpad1" }, code = { "numpad1" } }, + { numpad = { x = 7, y = 5.5 }, name = "2", scan = { "numpad2" }, code = { "numpad2" } }, + { numpad = { x = 8, y = 5.5 }, name = "3", scan = { "numpad3" }, code = { "numpad3" } }, + { numpad = { x = 9, y = 5.5, h = 2 }, name = "Enter", scan = { "numpad_enter" }, code = { "numpad_enter" } }, + { numpad = { x = 6, y = 6.5, w = 2 }, name = "0", scan = { "numpad0" }, code = { "numpad0" } }, + { numpad = { x = 8, y = 6.5 }, name = ".", scan = { "numpad." }, code = { "numpad." } }, +} + +-- Modifier names in the order the engine writes them, which is the order a layer's caption +-- and its key read them in. +local modifierNames = {} +for i, name in ipairs(keyConfig.modifierOrder) do + modifierNames[i] = name:lower() +end + +-- A layer's key: the modifiers it holds, in that order, joined with "+". No modifiers is "". +local function layerKeyOf(mods) + local parts = {} + for _, name in ipairs(modifierNames) do + if mods[name] then + parts[#parts + 1] = name + end + end + + return table.concat(parts, "+") +end + +---------------------------------------------------------------- +-- Colours and sizes +---------------------------------------------------------------- + +local colorText = "\255\235\235\235" +local colorDim = "\255\160\160\160" +local colorKey = "\255\235\185\070" + +local look = { + -- Caps: a bound key, one with nothing on this layer, a modifier at rest, a modifier + -- whose layer is showing, and a key the search found or the list is filtered to. + bound = { 0.22, 0.22, 0.22, 1 }, + unbound = { 0.16, 0.16, 0.16, 1 }, + modifier = { 0.28, 0.28, 0.28, 1 }, + modifierActive = { 0.8, 0.8, 0.78, 1 }, + hit = { 0.5, 0.4, 0.16, 1 }, + -- A key the search did not find sinks into the panel so the found ones stand out. + missOpacity = 0.4, + -- Text on a dark cap, and on the light cap of an active modifier. + name = "\255\200\200\200", + nameOnLight = "\255\40\40\40", + shifted = "\255\125\125\125", + shiftedOnLight = "\255\110\110\110", + labelOnLight = "\255\30\30\30", + -- The Shift half of a paired order, which does what the key does without Shift. + paired = "\255\150\150\150", + more = colorKey, + iconAlpha = 0.85, + pairedIconAlpha = 0.45, + caption = colorText, + captionMods = colorKey, + hint = colorDim, + -- The view toggle: a button like the header's, pressed while the number pad is showing. + buttonFill = { 0.18, 0.18, 0.18, 1 }, + buttonFillActive = { 0.33, 0.33, 0.33, 1 }, + buttonFillHover = { 0.4, 0.4, 0.4, 1 }, + buttonText = colorText, + buttonHoverOpacity = 0.25, + white = { 1, 1, 1 }, + -- The outline every string here is drawn with, set on every batch: the font is shared with + -- every other widget, some of which set an outline of their own and leave it, and text + -- baked into a display list keeps whatever outline was set last. The editor's value. + outline = { 0, 0, 0, 0.4 }, +} + +-- FlowUI's Button gradients from a bottom stop to a top one; each fill becomes a darker +-- bottom and itself on top, the shape the editor's other buttons take. Derived once per fill. +look.gradients = setmetatable({}, { + __index = function(self, fill) + local pair = { + { fill[1] * 0.55, fill[2] * 0.55, fill[3] * 0.55, fill[4] or 1 }, + { fill[1], fill[2], fill[3], fill[4] or 1 }, + } + self[fill] = pair + + return pair + end, +}) + +-- Label colours by catalog category, so a key's action reads as the kind of thing it is at a +-- glance: groups in blue, camera in gold, build in yellow. Anything unlisted prints plain. +local categoryColors = { + ["categories.selection"] = "\255\150\205\255", + ["categories.orders"] = colorText, + ["categories.queues"] = "\255\255\190\120", + ["categories.unitStates"] = "\255\170\230\150", + ["categories.controlGroups"] = "\255\120\190\255", + ["categories.buildHotkeys"] = "\255\255\225\120", + ["categories.gridMenu"] = "\255\255\225\120", + ["categories.blueprints"] = "\255\200\170\255", + ["categories.camera"] = "\255\255\215\130", + ["categories.mapViews"] = "\255\150\230\220", + ["categories.interfaceDisplay"] = "\255\220\220\220", + ["categories.drawing"] = "\255\255\170\200", + ["categories.sound"] = "\255\190\200\230", + ["categories.gameControl"] = "\255\255\150\150", +} + +---------------------------------------------------------------- +-- Construction +---------------------------------------------------------------- + +function M.new() + local self = setmetatable({}, M) ---@type KeybindKeyboard + self.keys = {} + for i, def in ipairs(keyDefs) do + ---@type table + local key = {} + for k, v in pairs(def) do + key[k] = v + end + key.id = i + key.rects = {} + key.layers = {} + key.any = {} + key.show = {} + self.keys[i] = key + end + self.shown = {} + self.view = "main" + -- Modifiers toggled on the drawn keyboard, and those held on the real one. + self.toggled = {} + self.held = {} + self.layoutName = nil + self.query = Search.query(nil) + self.queryTokens = {} + self.queryGen = 0 + self.filter = nil + self.filterGen = 0 + self.gen = 0 + self.layoutGen = 0 + self.unplaced = 0 + self.L = {} + self.area = { x1 = 0, y1 = 0, x2 = 0, y2 = 0 } + self.frames = {} + self.scale = 1 + + return self +end + +-- Picks up the font and the FlowUI entry points, which do not exist at include time. Called +-- again on a resize: the font handler hands out new objects then. +function M:init(font) + self.font = font + self.UiKey = WG.FlowUI.Draw.Key + self.UiButton = WG.FlowUI.Draw.Button + self.Highlight = WG.FlowUI.Draw.SelectHighlight + self.layoutGen = self.layoutGen + 1 +end + +-- Re-reads the page's own strings. Modifier and key names are read from the layout on the +-- next placement, since they change with the keyboard layout rather than the language. +function M:refreshStrings() + local L = self.L + L.layerBase = BAR.I18N("ui.keybinds.keyboard.layerBase") + L.layer = BAR.I18N("ui.keybinds.keyboard.layer") + L.hint = BAR.I18N("ui.keybinds.keyboard.hint") + L.notShown = BAR.I18N("ui.keybinds.keyboard.notShown") + L.unbound = BAR.I18N("ui.keybinds.keyboard.unbound") + L.anyModifier = BAR.I18N("ui.keybinds.keyboard.anyModifier") + L.paired = BAR.I18N("ui.keybinds.keyboard.paired") + L.clickKey = BAR.I18N("ui.keybinds.keyboard.clickKey") + L.clickModifier = BAR.I18N("ui.keybinds.keyboard.clickModifier") + L.clickModifierOff = BAR.I18N("ui.keybinds.keyboard.clickModifierOff") + L.numpad = BAR.I18N("ui.keybinds.keyboard.numpad") + L.numpadTooltip = BAR.I18N("ui.keybinds.keyboard.numpadTooltip") + L.numpadText = look.buttonText .. L.numpad + self.hintLines = nil +end + +---------------------------------------------------------------- +-- Geometry +---------------------------------------------------------------- + +-- Lays both views out inside the rect: as large as the main view's fifteen units fit across +-- and seven and a half units fit down, each view centred in whatever is left over. The +-- caption row and the view toggle keep to the main view's frame, so they stay put when the +-- view changes. Every edge and size is a whole pixel. +function M:setArea(x1, y1, x2, y2, scale, titleFs) + local a = self.area + a.x1, a.y1, a.x2, a.y2 = x1, y1, x2, y2 + self.scale = scale or 1 + + local unit = floor(min((x2 - x1) / viewCols.main, (y2 - y1) / ROWS)) + self.unit = unit + self.titleFs = max(titleFs or 0, floor(unit * 0.22)) + -- Half the gap between two keys goes on each side of every key, so a wide key and two + -- narrow ones fill the same span. + local half = max(1, floor(unit * 0.045)) + local oy = floor(y2 - (y2 - y1 - unit * ROWS) * 0.5) + for _, view in ipairs(viewOrder) do + self.frames[view] = { ox = floor(x1 + (x2 - x1 - unit * viewCols[view]) * 0.5), oy = oy } + end + self.cs = max(2, floor(unit * 0.09)) + self.pad = max(2, floor(unit * 0.07)) + -- Tighter than the sides: the face's height is what three lines of a label under the key's + -- name have to share. + self.padY = max(2, floor(unit * 0.045)) + -- The key's name reads first, its action smaller under it, and the count of further + -- actions smaller still: each a share of the key in whole pixels, scaled by KEY_TEXT_SCALE. + self.nameFs = max(9, floor(floor(unit * 0.14) * KEY_TEXT_SCALE + 0.5)) + self.labelFs = max(8, floor(floor(unit * 0.125) * KEY_TEXT_SCALE + 0.5)) + self.moreFs = max(8, floor(floor(unit * 0.11) * KEY_TEXT_SCALE + 0.5)) + self.iconSize = floor(unit * 0.24) + -- The hint is a sentence read at a glance, so it prints larger than a key's label; two or + -- three lines of it fit the caption row. + self.hintFs = max(9, floor(unit * 0.17)) + self.buttonFs = max(8, floor(unit * 0.15)) + + for _, key in ipairs(self.keys) do + for _, view in ipairs(viewOrder) do + local at = key[view] + if at then + local ox = self.frames[view].ox + key.rects[view] = { + ox + floor(at.x * unit) + half, + oy - floor((at.y + (at.h or 1)) * unit) + half, + ox + floor((at.x + (at.w or 1)) * unit) - half, + oy - floor(at.y * unit) - half, + } + end + end + end + + -- The toggle, at the right of the caption row, clear of the Pause key at the row's end. + local main = self.frames.main + local bw, bh = floor(unit * 2.2), floor(unit * 0.5) + local bx2 = main.ox + floor(unit * 13.75) + local by1 = floor(oy - unit * 0.5 - bh * 0.5) + self.button = { bx2 - bw, by1, bx2, by1 + bh } + + self:applyView() + self.layoutGen = self.layoutGen + 1 + self.hintLines = nil +end + +-- Which keys the current view draws, and where. A key not in the view has no rect, so the +-- hit test and the drawing skip it. +function M:applyView() + self.shown = {} + for i, key in ipairs(self.keys) do + local r = key.rects[self.view] + if r then + key.x1, key.y1, key.x2, key.y2 = r[1], r[2], r[3], r[4] + self.shown[#self.shown + 1] = i + else + key.x1, key.y1, key.x2, key.y2 = nil, nil, nil, nil + end + end +end + +function M:setView(view) + if not viewCols[view] or view == self.view then + return + end + self.view = view + self:applyView() + self.layoutGen = self.layoutGen + 1 +end + +---------------------------------------------------------------- +-- Names and placement +---------------------------------------------------------------- + +-- What the cap prints and the engine names the key by, for the player's keyboard layout. +-- Each key gets the canonical tokens (as keybind_model spells them: "sc:q", "kc:a") that +-- land on it, and the index from token to key that placement looks bindings up in. +---@return table index +function M:applyLayout(layoutName) + self.layoutName = layoutName + local positional = keyConfig.scanToCode[layoutName] or keyConfig.scanToCode.qwerty + local index = {} + self.tokenIndex = index + + local function claim(token, i) + -- First come first served: a layout that puts one character on two keys is broken, + -- and the drawn keyboard can only show it once. + if index[token] == nil then + index[token] = i + end + end + + for i, key in ipairs(self.keys) do + local tokens = {} + if key.char then + local upper = key.char:upper() + local produced = positional[upper] or upper + -- The engine's scancode name for the position, and the keycode of whatever the + -- layout puts there. Punctuation carries the engine's word for it as well. + tokens[#tokens + 1] = "sc:" .. key.char:lower() + if key.word then + tokens[#tokens + 1] = "sc:" .. key.word + end + tokens[#tokens + 1] = "kc:" .. produced:lower() + -- The engine's other spellings of a keycode only hold while the key still makes + -- the character they spell. + if key.code and produced == upper then + for _, name in ipairs(key.code) do + tokens[#tokens + 1] = "kc:" .. name + end + end + key.label = keyConfig.sanitizeKey("sc_" .. (key.word or key.char), layoutName) + key.shiftedLabel = (produced == upper) and key.shifted or nil + -- What the list's chips print for the key. + key.searchName = key.label + else + for _, name in ipairs(key.scan or {}) do + tokens[#tokens + 1] = "sc:" .. name + end + for _, name in ipairs(key.code or {}) do + tokens[#tokens + 1] = "kc:" .. name + end + key.label = key.name + key.shiftedLabel = nil + local spelled = (key.scan and key.scan[1] and ("sc_" .. key.scan[1])) or (key.code and key.code[1]) or "" + key.searchName = keyConfig.sanitizeKey(spelled, layoutName) + end + key.tokens = tokens + for _, token in ipairs(tokens) do + claim(token, i) + end + key.lower = key.label:lower() + end + + return index +end + +-- Places every binding on its key. `infoFor(action)` answers with the action's label, +-- description, icon, category and catalog rank; `hidden` names the actions the catalog +-- keeps off every surface; `shiftPair` the actions bound twice, bare and with Shift. +function M:place(binds, hidden, layoutName, shiftPair, infoFor) + if not self.tokenIndex or layoutName ~= self.layoutName then + self:applyLayout(layoutName) + end + local index = self.tokenIndex or {} + self.infoFor = infoFor + self.shiftPair = shiftPair or {} + self.gen = self.gen + 1 + self.unplaced = 0 + self.hintLines = nil + + for _, key in ipairs(self.keys) do + key.layers = {} + key.any = {} + key.show = {} + end + + local seen = {} + for _, b in ipairs(binds or {}) do + if not (hidden and hidden[b.action]) then + local elems = keybindModel.splitChain(b.keyset) + local mods, keyToken = keybindModel.splitElement(keybindModel.canonicalKeyset(elems[1] or b.keyset)) + -- Nil for a key the keyboard does not draw: keys[0] is nothing. + local idx = (keyToken and index[keyToken]) or 0 + local key = self.keys[idx] --[[@as table?]] + if key then + local list, layer + if mods.any then + list = key.any + layer = "any" + else + layer = layerKeyOf(mods) + list = key.layers[layer] + if not list then + list = {} + key.layers[layer] = list + end + end + -- The same action on the same key and layer twice is one entry: a keymap can + -- say it twice, and the face has one slot. + local dup = idx .. "|" .. layer .. "|" .. b.action + if not seen[dup] then + seen[dup] = true + list[#list + 1] = { + action = b.action, + raw = b.keyset, + chain = #elems > 1, + any = mods.any or false, + } + end + else + self.unplaced = self.unplaced + 1 + end + end + end +end + +-- The action's card, asked of the host and kept on the entry. +function M:infoOf(entry) + if not entry.info then + entry.info = (self.infoFor and self.infoFor(entry.action)) or { label = entry.action, rank = math.huge } + end + + return entry.info +end + +-- What a key shows on a layer: the bindings naming exactly those modifiers, then the Any+ +-- ones, each block in catalog order. Kept per layer until the bindings change. +function M:entries(key, layer) + local show = key.show[layer] + if show and show.gen == self.gen then + return show.entries + end + + local entries = {} + local function take(list) + local sorted = {} + for i, e in ipairs(list) do + sorted[i] = e + end + table.sort(sorted, function(a, b) + local ra, rb = self:infoOf(a).rank or math.huge, self:infoOf(b).rank or math.huge + if ra ~= rb then + return ra < rb + end + return a.action < b.action + end) + for _, e in ipairs(sorted) do + entries[#entries + 1] = e + end + end + take(key.layers[layer] or {}) + take(key.any) + + -- A paired order's Shift half does what the bare key does; on a layer holding Shift it is + -- marked, so the layer reads as what Shift adds rather than everything Shift keeps. + local bare + if layer:find("shift", 1, true) then + local without = layer:gsub("%+?shift", "") + bare = key.layers[without] or {} + end + for _, e in ipairs(entries) do + e.paired = false + if bare and self.shiftPair[e.action] then + for _, b in ipairs(bare) do + if b.action == e.action then + e.paired = true + break + end + end + end + end + + key.show[layer] = { gen = self.gen, entries = entries } + + return entries +end + +---------------------------------------------------------------- +-- Layers, search, filter and hit testing +---------------------------------------------------------------- + +-- The modifiers in effect: toggled on the drawn keyboard or held on the real one. +function M:activeMods() + local mods = {} + for _, name in ipairs(modifierNames) do + mods[name] = self.toggled[name] or self.held[name] or false + end + + return mods +end + +function M:layer() + return layerKeyOf(self:activeMods()) +end + +function M:toggle(mod) + self.toggled[mod] = not self.toggled[mod] or nil +end + +function M:setHeld(alt, ctrl, meta, shift) + local h = self.held + h.alt, h.ctrl, h.meta, h.shift = alt or nil, ctrl or nil, meta or nil, shift or nil +end + +-- The search box's text. A key is found by its own name, or by an action it shows on the +-- layer; the rest sink. Key names are whole words, as the list's key search takes them, so +-- "f1" does not light F11. +function M:setQuery(str) + local query = Search.query(str) + if query.text == self.query.text then + return + end + self.query = query + self.queryTokens = {} + for token in query.text:gmatch("[^%s%+]+") do + self.queryTokens[#self.queryTokens + 1] = token + end + self.queryGen = self.queryGen + 1 +end + +-- The key the list is filtered to, lit here and nowhere else: `id` names the key and `layer` +-- the modifiers it was clicked under. Nil clears it. +function M:setFilter(filter) + self.filter = filter + self.filterGen = self.filterGen + 1 +end + +function M:matches(key, entries) + local query = self.query + if query.empty then + return nil + end + for _, token in ipairs(self.queryTokens) do + if token == key.lower or (key.mod and token == key.mod) then + return true + end + end + for _, e in ipairs(entries) do + local info = self:infoOf(e) + if Search.matches(query, (info.label or ""):lower()) or Search.matches(query, e.action:lower()) then + return true + end + end + + return false +end + +-- The key under the point, by index; -1 for the view toggle; nil for neither. +function M:hitTest(x, y) + local b = self.button + if b and isInRect(x, y, b[1], b[2], b[3], b[4]) then + return -1 + end + for _, i in ipairs(self.shown) do + local key = self.keys[i] --[[@as table]] + if isInRect(x, y, key.x1, key.y1, key.x2, key.y2) then + return i + end + end + + return nil +end + +-- Everything the baked picture is painted from, beyond what the host already tracks. +function M:signature(hoverIdx) + return (hoverIdx or 0) + .. "|" + .. self:layer() + .. "|" + .. self.queryGen + .. "|" + .. self.gen + .. "|" + .. self.layoutGen + .. "|" + .. self.view + .. "|" + .. self.filterGen +end + +-- A click: the toggle swaps the view; a modifier toggles its layer; a bound key is handed +-- back with the layer it was clicked under, for the list to filter to. Nothing else answers. +function M:mousePress(x, y, button) + if button ~= 1 then + return nil + end + local idx = self:hitTest(x, y) + if idx == -1 then + self:setView(self.view == "main" and "numpad" or "main") + + return "view", self.view + end + local key = idx and self.keys[idx] or nil + if not key then + return nil + end + if key.mod then + self:toggle(key.mod) + + return "modifier", key.mod + end + local layer = self:layer() + if #self:entries(key, layer) == 0 then + return nil + end + + return "key", key, layer +end + +-- The key with the layer's modifiers in front, the way a chip prints it. +function M:keysetName(key, layer) + local parts = {} + local mods = layer and {} or self:activeMods() + if layer then + for name in layer:gmatch("[^+]+") do + mods[name] = true + end + end + for _, name in ipairs(modifierNames) do + if mods[name] then + parts[#parts + 1] = name:sub(1, 1):upper() .. name:sub(2) + end + end + parts[#parts + 1] = key.searchName or key.label + + return table.concat(parts, " + ") +end + +---------------------------------------------------------------- +-- Tooltips +---------------------------------------------------------------- + +-- What the tooltip is about, so the host rebuilds its text only when that changes. +function M:tooltip(idx) + if idx == -1 then + return "kb|button|" .. self.view, self.L.numpad + end + local key = self.keys[idx] + if not key then + return nil + end + local layer = self:layer() + + return "kb|" .. idx .. "|" .. layer .. "|" .. self.gen, self:keysetName(key) +end + +-- The tooltip's lines: every action on the key for this layer, in the order the face ranks +-- them, each with what it does; then what a click here does. +function M:tooltipLines(idx) + local L = self.L + if idx == -1 then + return { colorDim .. L.numpadTooltip } + end + local key = self.keys[idx] + if not key then + return {} + end + local lines = {} + if key.mod then + local tipKey = self.toggled[key.mod] and "ui.keybinds.keyboard.clickModifierOff" + or "ui.keybinds.keyboard.clickModifier" + lines[#lines + 1] = colorDim .. BAR.I18N(tipKey, { mod = key.label }) + end + local entries = self:entries(key, self:layer()) + for _, e in ipairs(entries) do + local info = self:infoOf(e) + local line = (e.paired and look.paired or colorText) .. (info.label or e.action) + if e.chain then + line = line .. " " .. colorKey .. keybindModel.displayKeyset(e.raw, self.layoutName) + end + if e.any then + line = line .. " " .. colorDim .. L.anyModifier + elseif e.paired then + line = line .. " " .. colorDim .. L.paired + end + lines[#lines + 1] = line + if info.description then + lines[#lines + 1] = colorDim .. info.description + end + end + if #entries == 0 and not key.mod then + lines[#lines + 1] = colorDim .. L.unbound + end + if #entries > 0 and not key.mod then + lines[#lines + 1] = colorDim .. L.clickKey + end + + return lines +end + +---------------------------------------------------------------- +-- Drawing +---------------------------------------------------------------- + +-- The size a key's name prints at: the page's, unless the name is wider than the face at +-- that size, then as much smaller as makes it fit. Measured once per name, room and layout. +function M:nameSize(key, room) + if key.nameFsGen == self.layoutGen and key.nameFsLabel == key.label and key.nameFsRoom == room then + return key.nameFsFit + end + local size = self.nameFs + local width = self.font:GetTextWidth(key.label) * size + if width > room and width > 0 then + size = max(floor(size * 0.6), floor(size * room / width)) + end + key.nameFsFit, key.nameFsGen, key.nameFsLabel, key.nameFsRoom = size, self.layoutGen, key.label, room + + return size +end + +-- The label a key wears on a layer, wrapped and fitted to its face, kept until the bindings +-- or the geometry change. +function M:faceLines(key, layer, entries, faceW, maxLines) + local show = key.show[layer] + if show.lines and show.linesGen == self.layoutGen and show.linesMax == maxLines then + return show.lines, show.first + end + local first = entries[1] + local lines = {} + if first then + local info = self:infoOf(first) + local label = info.label or first.action + local fs = self.labelFs + local wrapped = text.wrap(self.font, label, faceW, fs) + if #wrapped > maxLines then + -- The last line that fits takes the rest of the label, shortened to the face. + local rest = {} + for i = maxLines, #wrapped do + rest[#rest + 1] = wrapped[i] + end + wrapped[maxLines] = table.concat(rest, " ") + for i = #wrapped, maxLines + 1, -1 do + wrapped[i] = nil + end + end + for i, line in ipairs(wrapped) do + lines[i] = text.fit(self.font, line, faceW, fs) + end + end + show.lines, show.first, show.linesGen, show.linesMax = lines, first, self.layoutGen, maxLines + + return lines, first +end + +-- The caption's hint, wrapped to the room left of the caption once, per size and language. +function M:hintFor(width) + if self.hintLines and self.hintWidth == width then + return self.hintLines + end + local lines = text.wrap(self.font, self.L.hint or "", width, self.hintFs) + if self.unplaced > 0 then + local note = BAR.I18N("ui.keybinds.keyboard.notShown", { n = self.unplaced }) + for _, line in ipairs(text.wrap(self.font, note, width, self.hintFs)) do + lines[#lines + 1] = line + end + end + for i = 4, #lines do + lines[i] = nil + end + for i, line in ipairs(lines) do + lines[i] = text.fit(self.font, line, width, self.hintFs) + end + self.hintLines, self.hintWidth = lines, width + + return lines +end + +-- Paints the page: the caption row with the view toggle, then every key of the view with +-- its picture and words. Called inside the host's display list, so all of it bakes and +-- replays until the signature moves. +function M:draw(hoverIdx) + local font = self.font + local UiKey = self.UiKey + if not font or not UiKey or not self.unit then + return + end + local mods = self:activeMods() + local layer = layerKeyOf(mods) + local unit, pad, cs = self.unit, self.pad, self.cs + local padY = self.padY + local searching = not self.query.empty + local filter = self.filter + local nameLineH = floor(self.nameFs * 1.12) + local lineH = floor(self.labelFs * 1.1) + local prints = {} + local function print(str, x, y, size, opts) + prints[#prints + 1] = { str, x, y, size, opts } + end + + -- Caps and pictures first, words after, in one font batch. + glBlending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + for _, i in ipairs(self.shown) do + local key = self.keys[i] --[[@as table]] + local entries = self:entries(key, layer) + local active = key.mod and mods[key.mod] + local filtered = filter and filter.id == key.id and filter.layer == layer + local hit = (searching and self:matches(key, entries)) or filtered + local fill = (active and look.modifierActive) + or (hit and look.hit) + or (key.mod and look.modifier) + or (entries[1] and look.bound) + or look.unbound + local opacity = (searching and not hit and not active) and look.missOpacity or 1 + local fx1, fy1, fx2, fy2 = UiKey(key.x1, key.y1, key.x2, key.y2, cs, fill, active, hoverIdx == i, opacity) + local light = active + local faceW = fx2 - fx1 - pad * 2 + -- Text on a dark cap carries the panel's dark outline; dark text on a light cap does + -- not, an outline there being a dark ring round dark letters. + local oLeft, oCentre, oRight = "o", "co", "ro" + if light then + oLeft, oCentre, oRight = "", "c", "r" + end + + -- The key's own name, top left, at the page's name size or smaller for the odd name too + -- long for its key; the symbol Shift makes of it beside, dimmer. + local nameFs = self:nameSize(key, faceW) + local nameTop = fy2 - padY + local nameY = text.baseline(font, nameTop - nameLineH, nameTop, nameFs) + print((light and look.nameOnLight or look.name) .. key.label, fx1 + pad, nameY, nameFs, oLeft) + if key.shiftedLabel then + local nameW = floor(font:GetTextWidth(key.label) * nameFs) + print( + (light and look.shiftedOnLight or look.shifted) .. key.shiftedLabel, + fx1 + pad + nameW + floor(pad * 0.8), + nameY, + nameFs, + oLeft + ) + end + + -- The room under the name: the first action's words, as many lines as fit, centred. The + -- last line may reach into the bottom padding; a label seldom has a descender there. + local bandTop = nameTop - nameLineH + local bandBottom = fy1 + floor(padY * 0.5) + local maxLines = min(3, max(1, floor((bandTop - bandBottom) / lineH))) + local lines, first = self:faceLines(key, layer, entries, faceW, maxLines) + -- The top right corner: the action's picture, and how many more actions the tooltip + -- lists, which sits left of the picture when there is one. + local cornerX = fx2 - pad + if first then + local info = self:infoOf(first) + local color = light and look.labelOnLight + or (first.paired and look.paired) + or categoryColors[info.category or ""] + or colorText + local n = #lines + local blockTop = floor((bandTop + bandBottom + n * lineH) * 0.5) + local cx = floor((fx1 + fx2) * 0.5) + for li = 1, n do + local top = blockTop - (li - 1) * lineH + local ly = text.baseline(font, top - lineH, top, self.labelFs) + print(color .. lines[li], cx, ly, self.labelFs, oCentre) + end + + if info.icon and self.iconSize > 0 then + local s = self.iconSize + local iy2 = fy2 - padY + glColor(1, 1, 1, (first.paired and look.pairedIconAlpha or look.iconAlpha) * opacity) + glTexture(info.icon) + glTexRect(cornerX - s, iy2 - s, cornerX, iy2) + glTexture(false) + glColor(1, 1, 1, 1) + cornerX = cornerX - s - floor(pad * 0.6) + end + end + if #entries > 1 then + local count = (light and look.nameOnLight or look.more) .. "+" .. (#entries - 1) + print(count, cornerX, nameY, self.moreFs, oRight) + end + end + + -- The caption row: which layer this is, centred over the keyboard; how to work the page, + -- in the room to the left of it; and the view toggle at its right. + local frame = self.frames.main + local rowTop, rowBottom = frame.oy, frame.oy - unit + local caption + local held = {} + for _, name in ipairs(modifierNames) do + if mods[name] then + held[#held + 1] = name:sub(1, 1):upper() .. name:sub(2) + end + end + if #held > 0 then + caption = look.captionMods .. BAR.I18N("ui.keybinds.keyboard.layer", { mods = table.concat(held, " + ") }) + else + caption = look.caption .. (self.L.layerBase or "") + end + local captionX = frame.ox + floor(unit * 7.5) + print(caption, captionX, text.baseline(font, rowBottom, rowTop, self.titleFs), self.titleFs, "co") + + local hintW = floor(unit * 5.5) - pad + local hintLines = self:hintFor(hintW) + local hintLineH = floor(self.hintFs * 1.25) + local blockTop = floor((rowTop + rowBottom + #hintLines * hintLineH) * 0.5) + for li, line in ipairs(hintLines) do + local top = blockTop - (li - 1) * hintLineH + local y = text.baseline(font, top - hintLineH, top, self.hintFs) + print(look.hint .. line, frame.ox + pad, y, self.hintFs, "o") + end + + local b = self.button + if b and self.UiButton then + local overButton = hoverIdx == -1 + local fill = (self.view == "numpad" and (overButton and look.buttonFillHover or look.buttonFillActive)) + or look.buttonFill + local pair = look.gradients[fill] + self.UiButton(b[1], b[2], b[3], b[4], 1, 1, 1, 1, 1, 1, 1, 1, nil, pair[1], pair[2]) + if overButton and self.view ~= "numpad" and self.Highlight then + self.Highlight(b[1], b[2], b[3], b[4], floor(cs * 0.5), look.buttonHoverOpacity, look.white) + end + print( + self.L.numpadText or "", + floor((b[1] + b[3]) * 0.5), + text.baseline(font, b[2], b[4], self.buttonFs), + self.buttonFs, + "co" + ) + end + + font:Begin() + font:SetOutlineColor(look.outline) + for _, p in ipairs(prints) do + font:Print(p[1], p[2], p[3], p[4], p[5]) + end + font:End() +end + +return M diff --git a/luaui/Include/keybind_keysyms.lua b/luaui/Include/keybind_keysyms.lua new file mode 100644 index 00000000000..8c97a531484 --- /dev/null +++ b/luaui/Include/keybind_keysyms.lua @@ -0,0 +1,14 @@ +-- Serves the engine's key constants to the keybind editor's includes. +-- +-- barwidgets.lua loads KEYSYMS into the widget-handler environment, which an Include from +-- inside a widget does not inherit, so the global may or may not be visible here. + +local KEYSYMS = KEYSYMS + +if not KEYSYMS then + local env = {} + VFS.Include("luaui/Headers/keysym.h.lua", env) + KEYSYMS = env.KEYSYMS +end + +return KEYSYMS diff --git a/luaui/Include/keybind_model.lua b/luaui/Include/keybind_model.lua new file mode 100644 index 00000000000..854ee1dc753 --- /dev/null +++ b/luaui/Include/keybind_model.lua @@ -0,0 +1,229 @@ +-- Read model for the in-game keybind editor. +-- Source of truth is Spring.GetKeyBindings(); we normalize each binding and group +-- by action. + +local keyConfig = VFS.Include("luaui/configs/keyboard_layouts.lua") + +-- Synonymous key names that should read the same however they were bound +-- (e.g. the file keysym "enter" vs the scancode-based "return" from capture). +local keyNameAlias = { enter = "return" } + +-- Keychain separator (U+2192) shown between taps, since the engine's "," collides with a bound comma key. +local chainSep = " \226\134\146 " + +-- One engine keyset element as the player's keyboard layout would label it. +-- The Any+ qualifier is not shown: it is fixed per action rather than chosen, so there is +-- nothing on that key for a player to change. sanitizeKey is what drops it. +local function displayElement(raw, layout) + local mods, key = raw:match("^(.-)([^+]*)$") + if key and keyNameAlias[key:lower()] then + raw = mods .. keyNameAlias[key:lower()] + end + + return (keyConfig.sanitizeKey(raw, layout):gsub("%+", " + ")) +end + +-- Split a chain on separator commas. A comma is the bound key rather than a separator +-- when nothing has been read yet (","), straight after a modifier ("Alt+,") or after "sc_". +local function splitChain(raw) + local elems = {} + local cur = "" + for i = 1, #raw do + local c = raw:sub(i, i) + if c == "," and cur ~= "" and cur:sub(-1) ~= "+" and cur:sub(-3) ~= "sc_" then + if cur ~= "" then + elems[#elems + 1] = cur + end + cur = "" + else + cur = cur .. c + end + end + if cur ~= "" then + elems[#elems + 1] = cur + end + + return elems +end + +-- Whether two keysets are the same binding, which is a different question from whether +-- they print the same. Any+ and a bare modifier set resolve differently when the engine +-- picks an action, and scancodes and keycodes are separate maps that land on different +-- physical keys off qwerty, so both distinctions survive here. Only spellings of one key +-- fold. Display is no use for this: it drops exactly the qualifiers that decide priority. +local canonicalMods = { "any", "alt", "ctrl", "meta", "shift" } +local modToken = { + ["any+"] = "any", + ["*+"] = "any", + ["alt+"] = "alt", + ["ctrl+"] = "ctrl", + ["meta+"] = "meta", + ["shift+"] = "shift", +} + +local function canonicalElement(raw) + local rest, held = raw, {} + local stripped = true + while stripped do + stripped = false + for token, name in pairs(modToken) do + if rest:sub(1, #token):lower() == token then + held[name] = true + rest = rest:sub(#token + 1) + stripped = true + end + end + end + + local key = rest:lower() + local scan = key:sub(1, 3) == "sc_" + if scan then + key = key:sub(4) + end + key = keyNameAlias[key] or key + + local out = {} + for _, name in ipairs(canonicalMods) do + if held[name] then + out[#out + 1] = name + end + end + out[#out + 1] = (scan and "sc:" or "kc:") .. key + + return table.concat(out, "+") +end + +-- A whole keyset, joining a chain with the arrow separator. +local function displayKeyset(raw, layout) + if not raw:find(",", 1, true) then + return displayElement(raw, layout) + end + + local parts = splitChain(raw) + for i = 1, #parts do + parts[i] = displayElement(parts[i], layout) + end + + return table.concat(parts, chainSep) +end + +-- A keyset as the editor's chip for it reads. A paired action holds one key as two binds, bare +-- and Shift+, and that Shift is the action's rather than the player's, so the Shift half reads +-- as the bare key and both halves share one chip. +local function displayWithoutShift(raw, layout) + local parts = splitChain(raw) + parts[1] = (parts[1]:gsub("[Ss][Hh][Ii][Ff][Tt]%+", "")) + + return displayKeyset(table.concat(parts, ","), layout) +end + +-- Whether a displayed keyset holds every one of the keys given, in any order: whole keys as the +-- chip prints them, modifiers included, lowercased. Split once per display string, of which a +-- keymap has a few hundred at most. +local keySets = {} +local function holdsKeys(display, keys) + if #keys == 0 then + return false + end + local set = keySets[display] + if not set then + set = {} + for key in display:lower():gmatch("[^%s%+]+") do + set[key] = true + end + keySets[display] = set + end + for i = 1, #keys do + if not set[keys[i]] then + return false + end + end + + return true +end + +local function canonicalKeyset(raw) + local parts = splitChain(raw) + for i = 1, #parts do + parts[i] = canonicalElement(parts[i]) + end + + return table.concat(parts, ",") +end + +-- The first tap of a canonical keyset, taken apart: the modifiers it names as a set ("any" +-- among them when the engine's qualifier is on) and the key token ("sc:q", "kc:a"). What the +-- keyboard page places a binding by, and what a filter on one key matches chips against. +local function splitElement(canon) + local first = canon:match("^[^,]+") or canon + local mods, key = {}, nil + -- The key runs from its "sc:"/"kc:" tag to the end: a key can be named "+" itself + -- ("kc:numpad+"), so the tag decides where the modifiers stop, not the separator. + local at = first:find("[sk]c:") + local modPart = first + if at then + key = first:sub(at) + modPart = first:sub(1, at - 1) + end + for token in modPart:gmatch("[^+]+") do + mods[token] = true + end + + return mods, key +end + +-- A bound action is identified by the full command string passed to /bind: +-- command plus its space-separated args (.extra) - exactly what bind/unbind +-- expect. This includes "chain", whose .extra is the sequence; dropping it would +-- collapse every chain into one id and lose the sequence on rebind. +local function actionId(b) + if b.extra and b.extra ~= "" then + return b.command .. " " .. b.extra + end + + return b.command +end + +-- Snapshot of every bound action, with both raw and display forms of its keysets. +local function build() + local layout = Spring.GetConfigString("KeyboardLayout", "qwerty") + local bindings = Spring.GetKeyBindings() or {} + + local byAction = {} + local order = {} + local binds = {} + + for _, b in ipairs(bindings) do + local id = actionId(b) + local raw = b.boundWith + binds[#binds + 1] = { keyset = raw, action = id } + + local entry = byAction[id] + if not entry then + entry = { action = id, command = b.command, keysets = {} } + byAction[id] = entry + order[#order + 1] = id + end + entry.keysets[#entry.keysets + 1] = { raw = raw, display = displayKeyset(raw, layout) } + end + + table.sort(order) + + local actions = {} + for i = 1, #order do + actions[i] = byAction[order[i]] + end + + return { actions = actions, layout = layout, binds = binds } +end + +return { + build = build, + displayKeyset = displayKeyset, + displayWithoutShift = displayWithoutShift, + holdsKeys = holdsKeys, + canonicalKeyset = canonicalKeyset, + splitElement = splitElement, + splitChain = splitChain, + chainSep = chainSep, +} diff --git a/luaui/Include/keybind_profiles.lua b/luaui/Include/keybind_profiles.lua new file mode 100644 index 00000000000..4bb9471fc68 --- /dev/null +++ b/luaui/Include/keybind_profiles.lua @@ -0,0 +1,982 @@ +-- Store for user keybind profiles, persisted to LuaUI/Config/keybind_profiles.json. +-- +-- Profiles are whole snapshots, never deltas: keyreload clears the keymap before it loads, +-- so a profile always defines every binding it wants. The emitter writes them in the one +-- shape the engine round-trips. +-- +-- Migration is the exception: a player's own file has to be read as written, so the reader +-- below understands the subset of the bind-file grammar that changes what ends up bound - +-- bind, the three unbinds, keyload, keysym and fakemeta. Everything after migration goes +-- through Spring.GetKeyBindings instead. + +local Json = Json or VFS.Include("common/luaUtilities/json.lua") +local keybindConfig = VFS.Include("luaui/Include/keybind_config.lua") + +local PROFILES_PATH = "LuaUI/Config/keybind_profiles.json" +local DEFAULTS_PATH = "common/configs/keybind_defaults.json" +local RETIRED_INCLUDES_PATH = "common/configs/keybind_retired_includes.json" +local ACTIVE_FILE = "uikeys.txt" +local BACKUP_FILE = "uikeys.txt.bak" +local STORE_VERSION = 2 + +-- The shipped profiles a player can select but not edit; editing forks a copy. They +-- carry binds rather than a file path so every surface reads one shape, and applying +-- one takes the same path as applying a player's own profile. +local builtins = {} +local emitPriority = {} +do + local decoded = keybindConfig.load(DEFAULTS_PATH) + if decoded and type(decoded.profiles) == "table" then + builtins = decoded.profiles + if type(decoded.priority) == "table" then + emitPriority = decoded.priority + end + else + Spring.Echo("[keybind_profiles] Error: " .. DEFAULTS_PATH .. " has no profiles; none shipped") + end +end + +-- Only for upgrades: the preset a player was on is recorded as a bind-file path. Maps +-- each of those paths to the profile that now covers it. +local presetFiles = { + ["luaui/configs/hotkeys/grid_keys.txt"] = "Grid", + ["luaui/configs/hotkeys/grid_keys_60pct.txt"] = "Grid (60% Keyboard)", + ["luaui/configs/hotkeys/legacy_keys.txt"] = "Legacy", + ["luaui/configs/hotkeys/legacy_keys_60pct.txt"] = "Legacy (60% Keyboard)", +} + +---@type table +local store + +-- Set while reading a store written before profiles named a meta key, so the launch that +-- upgrades one can still recognise the files that version wrote. +local storePredatesMeta = false + +-- Shape a fresh store file takes. +local function emptyStore() + return { version = STORE_VERSION, active = nil, profiles = {} } +end + +-- Position of one of the player's own profiles, nil when the name is not theirs. +local function indexOf(name) + for i, p in ipairs(store.profiles) do + if p.name == name then + return i + end + end + + return nil +end + +local M = { builtins = builtins, activeFile = ACTIVE_FILE } + +-- The shipped profile of that name, nil when the player owns it instead. +function M.isBuiltin(name) + for _, b in ipairs(builtins) do + if b.name == name then + return b + end + end + + return nil +end + +-- Where an action sits in the shipped priority list, last for anything unlisted. +local function priorityRank(action) + for i = 1, #emitPriority do + local prefix = emitPriority[i] + if action:sub(1, #prefix) == prefix then + return i + end + end + + return #emitPriority + 1 +end + +-- Two actions on one key are tried in the order they were bound, so file order is what +-- settles which one wins. Sorting by declared priority keeps that decision with the +-- action instead of with whoever edited last. Equal ranks hold their existing order, so +-- only the listed actions move. +local function byPriority(binds) + local ordered = {} + for i = 1, #binds do + ordered[i] = { bind = binds[i], rank = priorityRank(binds[i].action), pos = i } + end + + table.sort(ordered, function(a, b) + if a.rank ~= b.rank then + return a.rank < b.rank + end + + return a.pos < b.pos + end) + + local out = {} + for i = 1, #ordered do + out[i] = ordered[i].bind + end + + return out +end + +-- Stamped into every file we write so migration can tell our own output from a file the +-- player wrote, and recover which profile was live when the store holding it is gone. +-- The engine drops everything from "//" to end of line, so it costs nothing on load. +local GENERATED_PREFIX = "// keybind editor profile: " +local GENERATED_PATTERN = "^" .. (GENERATED_PREFIX:gsub("(%W)", "%%%1")) .. "([^\r\n]*)" + +local function generatedName(text) + if not text then + return nil + end + + local name = text:match(GENERATED_PATTERN) + + return (name ~= nil and name ~= "") and name or nil +end + +-- Loading a keymap leaves the meta key alone, so a bind file naming none runs under whatever +-- the engine set at startup. Every shipped keymap relied on that before profiles carried one. +local ENGINE_FAKE_META = "space" + +-- A meta key the engine will actually take, nil for anything else. It keeps the key it already +-- had when it cannot parse one, so emitting a name it does not know leaves the live keymap +-- disagreeing with the profile that named it. "none", which clears the key, is the one non-key +-- it accepts, and it takes that ahead of any parsing. Scancodes it refuses outright. +local function validFakeMeta(value) + if type(value) ~= "string" or value == "" or value:find("%s") then + return nil + end + + if value == "none" or (Spring.GetKeyCode(value) or 0) > 0 then + return value + end + + return nil +end + +-- What a profile's meta key comes to. Naming nothing asks for the engine's, the same as a bind +-- file that names none does; "none" is how a profile asks for no meta key at all. +local function resolveFakeMeta(value) + return validFakeMeta(value) or ENGINE_FAKE_META +end + +-- Shipped profiles never go through the store, so this is the only place their meta key is +-- checked before the editor reads it back and hands it to a fork. +for _, b in ipairs(builtins) do + b.fakeMeta = resolveFakeMeta(b.fakeMeta) +end + +-- A whole keymap: keyreload clears the bindings before it loads, but not the meta key. +local function toBindFile(profile) + local out = { GENERATED_PREFIX .. tostring(profile.name) } + out[#out + 1] = "fakemeta " .. resolveFakeMeta(profile.fakeMeta) + -- The store is writable by the player and by other surfaces, so a malformed entry is + -- reachable here. Dropping one costs a keybind; letting it through takes the whole + -- hotkey loader down with it. + local binds, dropped = {}, 0 + for _, b in ipairs(profile.binds or {}) do + if + type(b) == "table" + and type(b.keyset) == "string" + and type(b.action) == "string" + and b.keyset ~= "" + and b.action ~= "" + then + binds[#binds + 1] = b + else + dropped = dropped + 1 + end + end + if dropped > 0 then + Spring.Echo( + "[keybind_profiles] skipped " .. dropped .. " malformed binding(s) in profile " .. tostring(profile.name) + ) + end + + for _, b in ipairs(byPriority(binds)) do + out[#out + 1] = "bind " .. b.keyset .. " " .. b.action + end + + return table.concat(out, "\n") .. "\n" +end + +-- Only for upgrades: what a bind file we stopped shipping used to bind, for a keyload that +-- still names it. Read on the first one that needs it rather than at include time, since +-- nothing but a migration gets here. +---@type table +local retiredIncludes +local function retiredBinds(path) + local preset = presetFiles[path] + local profile = preset and M.isBuiltin(preset) + if profile then + return profile.binds + end + + if not retiredIncludes then + retiredIncludes = keybindConfig.load(RETIRED_INCLUDES_PATH) or {} + end + + return retiredIncludes[path] +end + +-- The engine has no Lua getter for the fakemeta key, so migration is the only +-- chance to carry a non-default one over from the file the player already had. +-- Reads the bind lines back out of a keybind file. Needed for the player's own +-- uikeys.txt at migration time: the live keymap is whichever preset they had selected, +-- so it cannot stand in for what their own file holds. +local function readBindFile(text, depth) + if not text then + return nil + end + + depth = depth or 1 + local breaks = "[^" .. string.char(13, 10) .. "]+" + local binds = {} + + -- A file may name a key the engine has none for (capslock is commented out engine-side), + -- and every keyset after that point uses the name. Resolved here rather than carried, so + -- what we write out is only ever bind lines the engine already parses. The engine refuses + -- to redefine a name, so one definition per name is the whole of it. + local keySyms = {} + local function resolveKeySyms(keyset) + if not next(keySyms) then + return keyset + end + + local out = {} + for element in (keyset .. ","):gmatch("([^,]*),") do + local mods, key = element:match("^(.-)([^+]+)$") + local named = key and keySyms[key:lower()] + out[#out + 1] = named and (mods .. named) or element + end + + return table.concat(out, ",") + end + + -- Applied to what has been collected so far rather than issued as commands, so an unbind + -- means "drop what this file has bound up to here". Matched on the command word, never + -- its args: "unbindaction factory_preset" takes every "factory_preset load N" with it. + local function drop(match) + for i = #binds, 1, -1 do + if match(binds[i]) then + table.remove(binds, i) + end + end + end + + for line in text:gmatch(breaks) do + -- Everything from "//" is a comment to the engine, so it is gone before anything reads + -- the line as a directive. + line = line:gsub("//.*", ""):gsub("%s+$", "") + local keyset, action = line:match("^%s*bind%s+(%S+)%s+(.-)%s*$") + if keyset and action ~= "" then + binds[#binds + 1] = { keyset = resolveKeySyms(keyset), action = action } + elseif line:match("^%s*unbindall%s*$") then + binds = {} + elseif line:match("^%s*unbindaction%s+%S") then + local command = line:match("^%s*unbindaction%s+(%S+)") + drop(function(b) + return b.action:match("^%S+") == command + end) + elseif line:match("^%s*unbindkeyset%s+%S") then + local target = line:match("^%s*unbindkeyset%s+(%S+)"):lower() + drop(function(b) + return b.keyset:lower() == target + end) + elseif line:match("^%s*unbind%s+%S") then + local target, command = line:match("^%s*unbind%s+(%S+)%s+(%S+)") + if target then + target = target:lower() + drop(function(b) + return b.keyset:lower() == target and b.action:match("^%S+") == command + end) + end + elseif line:match("^%s*keysym%s+%S+%s+%S") then + local name, code = line:match("^%s*keysym%s+(%S+)%s+(%S+)") + if not keySyms[name:lower()] then + keySyms[name:lower()] = code + end + else + -- A player's file can pull in others the same way the shipped presets did, and + -- those bindings are just as much theirs. Depth-capped rather than cycle-tracked. + local included = line:match("^%s*keyload%s+(%S+)") + if included and depth < 8 then + local text = VFS.LoadFile(included) + if text then + for _, b in ipairs(readBindFile(text, depth + 1) or {}) do + binds[#binds + 1] = b + end + else + -- These stopped being files, so a keyload naming one has nothing to read: + -- what they bound lives in the data that replaced them. + local retired = retiredBinds(included) + if retired then + for _, b in ipairs(retired) do + binds[#binds + 1] = { keyset = b.keyset, action = b.action } + end + else + Spring.Echo( + "[keybind_profiles] Error: keyload could not read " + .. included + .. "; any bindings it held are missing from the migrated profile" + ) + end + end + end + end + end + + return binds +end + +local function readFakeMeta(text) + if not text then + return nil + end + -- Horizontal whitespace only: %s would match the line break and swallow the + -- next line as the value when fakemeta is present but unset. + -- Leading newline so the directive is still found on the first line, which is where + -- toBindFile puts it. + local value = ("\n" .. text):match("\n[ \t]*fakemeta[ \t]*([^\n]*)") + if not value then + return nil + end + + value = value:gsub("//.*", ""):gsub("%s+$", "") + + return value ~= "" and value or nil +end + +local function fakeMetaOf(text) + return resolveFakeMeta(readFakeMeta(text)) +end + +-- What a bind file binds, as one comparable string, and the meta key it leaves set. Both +-- sides of a comparison go through the reader, so comments, line endings and any later change +-- to how we emit cannot read as an edit the player made. +local function keymapOf(text) + local binds = readBindFile(text) + if not binds then + return nil + end + + local parts = {} + for i = 1, #binds do + parts[i] = binds[i].keyset .. " " .. binds[i].action + end + + return table.concat(parts, "\n"), fakeMetaOf(text) +end + +-- The profile already holding this keymap, nil when none does. The one migration just made of +-- the player's own file counts, which is what keeps the launch they arrive on from forking a +-- second copy of what it has only now imported. +local function matchesKnownProfile(text) + local theirBinds, theirMeta = keymapOf(text) + if not theirBinds then + return nil + end + + -- Before profiles named a meta key every file we wrote said "fakemeta none", so on the + -- launch that upgrades a store one differing only there is still ours rather than an edit. + -- A player who named some other key still forks. + local function holds(profile) + local ourBinds, ourMeta = keymapOf(toBindFile(profile)) + if ourBinds ~= theirBinds then + return false + end + + return ourMeta == theirMeta or (storePredatesMeta and theirMeta == "none") + end + + -- Nearly always our own output for the profile it names, and this runs on every game + -- load, so try that one before reading out every profile there is. Keeps the usual path + -- off the full scan however many the player has accumulated. + local claimed = generatedName(text) + local i = claimed and indexOf(claimed) + local stamped = (i and store.profiles[i]) or (claimed and M.isBuiltin(claimed)) + if stamped and holds(stamped) then + return stamped.name + end + + for _, p in ipairs(store.profiles) do + if holds(p) then + return p.name + end + end + for _, b in ipairs(builtins) do + if holds(b) then + return b.name + end + end + + return nil +end + +-- A name no existing profile holds, for copies. +function M.uniqueName(base) + M.load() + if not indexOf(base) and not M.isBuiltin(base) then + return base + end + + local n = 2 + while indexOf(base .. " " .. n) or M.isBuiltin(base .. " " .. n) do + n = n + 1 + end + + return base .. " " .. n +end + +-- The next free " (n)". A name already carrying one counts up from it, anything else +-- starts at 2. Kept distinct from uniqueName's suffix so a copy the player never asked for +-- reads as one rather than as another profile they made. +local function nextCopyName(name) + local stem, n = name:match("^(.-) %((%d+)%)$") + n = tonumber(n) or 1 + stem = stem or name + + repeat + n = n + 1 + until not indexOf(stem .. " (" .. n .. ")") and not M.isBuiltin(stem .. " (" .. n .. ")") + + return stem .. " (" .. n .. ")" +end + +-- Writes the store back to disk. +function M.save() + local file = io.open(PROFILES_PATH, "w") + if not file then + Spring.Echo("[keybind_profiles] could not open " .. PROFILES_PATH .. " for writing") + return false + end + + local encoded = Json.encode(store) + if not encoded then + file:close() + Spring.Echo("[keybind_profiles] could not encode " .. PROFILES_PATH) + return false + end + + file:write(encoded) + file:close() + + return true +end + +-- Players upgrading from the old preset picker keep what they had, so dropping the +-- preset list does not silently reset anyone. +-- The player's file as it was before any of this touched it. Written once and never again, +-- including on a later migration, so the copy is always the original rather than our own +-- output. Nothing reads it back: it exists for a human with a broken keymap. +local function backupActiveFile() + local existing = io.open(BACKUP_FILE, "r") + if existing then + existing:close() + + return + end + + local text = VFS.LoadFile(ACTIVE_FILE) + if not text then + return + end + + local file = io.open(BACKUP_FILE, "w") + if not file then + Spring.Echo( + "[keybind_profiles] Error: could not write " + .. BACKUP_FILE + .. "; continuing without a copy of the original keymap" + ) + + return + end + + file:write(text) + file:close() + Spring.Echo("[keybind_profiles] kept the original " .. ACTIVE_FILE .. " as " .. BACKUP_FILE) +end + +local function migrate() + backupActiveFile() + store = emptyStore() + + -- Every preset still ships, so a player on one only needs it selected; there is nothing + -- of theirs to carry across. + local configured = Spring.GetConfigString("KeybindingFile", "") + local preset = presetFiles[configured] + + -- Whichever file actually held their bindings: the one they pointed the engine at when + -- that is not a preset we still ship, otherwise the uikeys.txt a preset leaves unloaded. + -- The player's own file is a profile in its own right, whatever else they had going on. + local ownPath = (not preset and configured ~= "") and configured or ACTIVE_FILE + local ownText = VFS.LoadFile(ownPath) + local written = generatedName(ownText) + + if written and M.isBuiltin(written) then + -- Our own copy of a shipped profile. Select it rather than importing a duplicate. + store.active = written + else + local own = readBindFile(ownText) + if own and #own > 0 then + local name = written or "Custom" + store.profiles[1] = { name = name, binds = own, fakeMeta = fakeMetaOf(ownText) } + store.active = preset or name + else + store.active = preset + end + end + + M.save() + + -- A keyload naming a retired preset resolves to that profile's bindings here and to + -- nothing engine-side, so hand it the store rather than the file the store came from. + local active = M.getActive() + local file = active and M.materialize(active) + if file then + Spring.SetConfigString("KeybindingFile", file) + end +end + +-- Reads the store once, migrating an older layout on the way in. +function M.load() + if store then + return store + end + + local content = VFS.LoadFile(PROFILES_PATH) + if not content then + migrate() + return store + end + + -- Json.decode raises on malformed input, so a corrupt file must not take LuaUI down. + local ok, decoded = pcall(Json.decode, content) + if not ok or type(decoded) ~= "table" or type(decoded.profiles) ~= "table" then + Spring.Echo("[keybind_profiles] could not decode " .. PROFILES_PATH .. "; starting empty") + store = emptyStore() + return store + end + + store = decoded + storePredatesMeta = (tonumber(store.version) or 1) < 2 + store.version = STORE_VERSION + -- A hand-edited file can repeat a name; keep the first so lookups stay unambiguous. + local seen, kept, inferred = {}, {}, false + for _, p in ipairs(store.profiles) do + if type(p) == "table" and type(p.name) == "string" and not seen[p.name] then + seen[p.name] = true + p.binds = type(p.binds) == "table" and p.binds or {} + -- Said here rather than on the way out, where the emitter runs once per profile per + -- comparison and would repeat it all session. + if p.fakeMeta and not validFakeMeta(p.fakeMeta) then + Spring.Echo( + "[keybind_profiles] profile " + .. p.name + .. " names meta key " + .. tostring(p.fakeMeta) + .. ", which the engine has none of; falling back to " + .. ENGINE_FAKE_META + ) + end + p.fakeMeta = resolveFakeMeta(p.fakeMeta) + -- Which shipped profile it was forked from. Only a name that still ships means + -- anything: a retired one would have the editor comparing against nothing, so a + -- profile without a usable one is given the closest shipped profile instead, and + -- that is written back so every surface reads the same origin from then on. + if not M.baseIsUsable(p.basedOn, store.profiles) then + p.basedOn = M.inferBase(p) + inferred = inferred or p.basedOn ~= nil + end + kept[#kept + 1] = p + end + end + store.profiles = kept + if inferred or storePredatesMeta then + M.save() + end + + return store +end + +-- Names of the player's own profiles, in store order. +function M.list() + M.load() + local names = {} + for _, p in ipairs(store.profiles) do + names[#names + 1] = p.name + end + + return names +end + +-- One of the player's own profiles by name. +function M.get(name) + M.load() + local i = indexOf(name) + + return i and store.profiles[i] or nil +end + +-- The selected profile, shipped or the player's own. +function M.getActive() + M.load() + + return store.active +end + +-- The selection, or the first shipped profile when it is missing or stale. +function M.activeName() + local active = M.getActive() + if active and (M.get(active) or M.isBuiltin(active)) then + return active + end + + return builtins[1] and builtins[1].name or nil +end + +-- Records the selection; the store owns this, not the engine config. +function M.setActive(name) + M.load() + store.active = name + + return M.save() +end + +-- A keymap the player edited themselves, kept as a profile instead of overwritten the next +-- time one is applied. Whichever file the engine is pointed at, since a hand-set +-- KeybindingFile is the same player doing the same thing somewhere else. +function M.adoptEditedKeymap() + M.load() + + local configured = Spring.GetConfigString("KeybindingFile", ACTIVE_FILE) + local text = VFS.LoadFile(configured ~= "" and configured or ACTIVE_FILE) + if not text then + return nil + end + + local matched = matchesKnownProfile(text) + if matched then + -- A keymap still matching its profile is never rewritten, so the "fakemeta none" the + -- previous version wrote into every file would outlive the upgrade that gave the + -- profiles a meta key. Left until here so a file the player did edit is adopted first. + if storePredatesMeta then + M.materialize(matched) + end + + return nil + end + + local binds = readBindFile(text) + if not binds or #binds == 0 then + return nil + end + + local previous = store.active + local name = nextCopyName(M.activeName() or "Custom") + store.profiles[#store.profiles + 1] = { name = name, binds = binds, fakeMeta = fakeMetaOf(text) } + store.active = name + if not M.save() then + table.remove(store.profiles) + store.active = previous + Spring.Echo( + "[keybind_profiles] Error: could not write " + .. PROFILES_PATH + .. "; the edited " + .. ACTIVE_FILE + .. " was left alone rather than kept as a profile" + ) + + return nil + end + + return name +end + +-- The shipped profile a player's profile is closest to: the one it differs from on the +-- fewest actions, comparing each action's keysets as written. For a profile with no recorded +-- origin - imported, or made before origins were recorded - this stands in for one: a fork +-- of Grid differs from Grid on a handful of actions and from Legacy on a hundred, so the +-- closest is the right answer, and even a layout written from scratch is best measured +-- against whatever it most resembles. +function M.inferBase(profile) + local ownSets = {} + for _, b in ipairs(profile.binds or {}) do + local set = ownSets[b.action] + if not set then + set = {} + ownSets[b.action] = set + end + set[b.keyset:lower()] = true + end + + local best, bestDiff + for _, builtin in ipairs(builtins) do + local theirSets = {} + for _, b in ipairs(builtin.binds or {}) do + local set = theirSets[b.action] + if not set then + set = {} + theirSets[b.action] = set + end + set[b.keyset:lower()] = true + end + + local diff = 0 + for action, set in pairs(ownSets) do + local theirs = theirSets[action] + if not theirs then + diff = diff + 1 + else + for keyset in pairs(set) do + if not theirs[keyset] then + diff = diff + 1 + break + end + end + if diff == 0 or theirs then + for keyset in pairs(theirs) do + if not set[keyset] then + diff = diff + 1 + break + end + end + end + end + end + for action in pairs(theirSets) do + if not ownSets[action] then + diff = diff + 1 + end + end + + if not bestDiff or diff < bestDiff then + best, bestDiff = builtin, diff + end + end + + return best and best.name or nil +end + +-- The one value of `basedOn` that is not a profile's name: the player chose to compare the +-- profile with nothing, which loading must not turn back into a guess. +local NO_BASE = "none" + +-- Whether a profile's `basedOn` still says something: no comparison, a shipped profile, or +-- one of the player's own in the list given (the store's, so a later entry counts too). +function M.baseIsUsable(basedOn, profiles) + if type(basedOn) ~= "string" then + return false + end + if basedOn == NO_BASE or M.isBuiltin(basedOn) then + return true + end + for _, p in ipairs(profiles or {}) do + if type(p) == "table" and p.name == basedOn then + return true + end + end + + return false +end + +-- The profile a profile is compared with: itself for a shipped one, the recorded fork or +-- the player's later choice for their own - a shipped profile or another of theirs. What an +-- editor compares against to say which keys the player changed. Nil when the player chose +-- none, or the profile it named is gone. +function M.baseOf(name) + local builtin = M.isBuiltin(name) + if builtin then + return builtin + end + + local own = M.get(name) + if not own or type(own.basedOn) ~= "string" or own.basedOn == NO_BASE or own.basedOn == name then + return nil + end + + return M.isBuiltin(own.basedOn) or M.get(own.basedOn) or nil +end + +-- Records what one of the player's profiles is compared with: a shipped profile, another of +-- their own, or nothing at all (nil). False when either name is unknown. +function M.setBase(name, baseName) + M.load() + local i = indexOf(name) + if not i then + return false + end + if baseName ~= nil and (baseName == name or not (M.isBuiltin(baseName) or indexOf(baseName))) then + return false + end + store.profiles[i].basedOn = baseName or NO_BASE + + return M.save() +end + +-- Adds a profile of the player's own, without selecting it: whether it becomes the live one +-- depends on the keymap reaching disk, which only the caller finds out. Selecting it up front +-- would leave the picker naming a profile the engine never loaded when that write fails. +-- `basedOn` names the profile it was forked from; without one, the closest shipped profile +-- stands in. +function M.create(name, binds, fakeMeta, basedOn) + M.load() + name = M.uniqueName(name) + local profile = { name = name, binds = binds, fakeMeta = resolveFakeMeta(fakeMeta) } + profile.basedOn = (basedOn and (M.isBuiltin(basedOn) or indexOf(basedOn))) and basedOn or M.inferBase(profile) + store.profiles[#store.profiles + 1] = profile + if not M.save() then + Spring.Echo( + "[keybind_profiles] Error: could not write " + .. PROFILES_PATH + .. "; profile " + .. name + .. " will be gone next launch" + ) + end + + return name +end + +-- Renames one of the player's own, following the selection if it moves. +function M.rename(oldName, newName) + M.load() + local i = indexOf(oldName) + if not i or newName == oldName then + return oldName + end + + newName = M.uniqueName(newName) + store.profiles[i].name = newName + if store.active == oldName then + store.active = newName + end + -- Whatever was compared with it follows the name. + for _, p in ipairs(store.profiles) do + if p.basedOn == oldName then + p.basedOn = newName + end + end + if not M.save() then + Spring.Echo( + "[keybind_profiles] Error: could not write " + .. PROFILES_PATH + .. "; the rename to " + .. newName + .. " will be gone next launch" + ) + end + + return newName +end + +-- Removes one of the player's own. +function M.delete(name) + M.load() + local i = indexOf(name) + if not i then + return false + end + + table.remove(store.profiles, i) + if store.active == name then + store.active = store.profiles[1] and store.profiles[1].name or nil + end + -- A profile compared with the one gone falls back to the closest shipped one, as a + -- profile with no recorded origin does. + for _, p in ipairs(store.profiles) do + if p.basedOn == name then + p.basedOn = M.inferBase(p) + end + end + + return M.save() +end + +-- A profile as text a player can paste anywhere: the same bind-file form the engine loads, +-- headed by the profile's name, so what is shared is what would be applied. +function M.exportText(profile) + return toBindFile(profile) +end + +-- Every line of bind-file text with what the reader makes of it, for showing a player what +-- an import will take before it does: "bind" for a binding, "directive" for anything else +-- the reader acts on, "comment" for a comment or a blank line, "error" for a line it cannot +-- read and will drop. Follows readBindFile line for line, and counts the binds and the +-- errors with it. +function M.classifyBindFile(text) + local lines, binds, errors = {}, 0, 0 + if type(text) ~= "string" then + return lines, binds, errors + end + + for raw in (text:gsub("\r\n", "\n"):gsub("\r", "\n") .. "\n"):gmatch("([^\n]*)\n") do + local line = raw:gsub("//.*", ""):gsub("%s+$", "") + local kind + if line:match("^%s*$") then + kind = "comment" + elseif line:match("^%s*bind%s+%S+%s+%S") then + kind = "bind" + binds = binds + 1 + elseif + line:match("^%s*unbindall%s*$") + or line:match("^%s*unbindaction%s+%S") + or line:match("^%s*unbindkeyset%s+%S") + or line:match("^%s*unbind%s+%S+%s+%S") + or line:match("^%s*keysym%s+%S+%s+%S") + or line:match("^%s*keyload%s+%S") + or line:match("^%s*fakemeta") + then + kind = "directive" + else + kind = "error" + errors = errors + 1 + end + lines[#lines + 1] = { text = raw, kind = kind } + end + + -- The split above leaves one empty line after a trailing newline, which is no line. + if #lines > 0 and lines[#lines].text == "" then + lines[#lines] = nil + end + + return lines, binds, errors +end + +-- The reverse: bind-file text, however it was produced, as binds plus the fakemeta key and +-- the profile name our own output is stamped with. nil binds when the text holds none. +function M.parseBindFile(text) + if type(text) ~= "string" or text == "" then + return nil + end + + local binds = readBindFile(text) + if not binds or #binds == 0 then + return nil + end + + return binds, fakeMetaOf(text), generatedName(text) +end + +-- Write a profile out where the engine can keyreload it, and return that path. +function M.materialize(name) + local profile = M.get(name) or M.isBuiltin(name) + if not profile then + return nil + end + + local file = io.open(ACTIVE_FILE, "w") + if not file then + Spring.Echo("[keybind_profiles] could not open " .. ACTIVE_FILE .. " for writing") + return nil + end + + file:write(toBindFile(profile)) + file:close() + + return ACTIVE_FILE +end + +return M diff --git a/luaui/Include/keybind_text.lua b/luaui/Include/keybind_text.lua new file mode 100644 index 00000000000..2a1a7537a09 --- /dev/null +++ b/luaui/Include/keybind_text.lua @@ -0,0 +1,116 @@ +-- Text measuring shared by the keybind editor's controls and the game info panel's rows, +-- so a label clipped in one control clips the same way in the next. The font is passed in +-- because each control draws with its own. + +local utf8 = VFS.Include("common/luaUtilities/utf8.lua") + +local M = {} + +local mathFloor = math.floor + +-- Body height per font, in em. Asked of the font once: it does not change with the size +-- the text is drawn at, and `baseline` is called for every label on every frame. +local bodyHeight = setmetatable({}, { __mode = "k" }) + +-- Where a line's baseline goes for the text to sit centred in a box, whatever it says. +-- +-- Printing with "v" centres the glyphs the string happens to have: "Search..." has no +-- descenders and so centres on its capitals, while "Legacy (2)" centres on a box that +-- reaches below the baseline, which lifts everything you actually read. Two controls +-- side by side then disagree with each other. +-- +-- Centred on the font's x-height instead, so every label sits alike. A UI label is +-- mostly lowercase, and that band is where the eye puts the middle of a line: centring +-- the capitals leaves the text reading low, because little of it reaches that high. +-- Ascenders and descenders then sit above and below, the way type intends. +-- +-- Print with "o" rather than "ov": with no vertical option the y is the baseline. +function M.baseline(font, y1, y2, size) + local body = bodyHeight[font] + if not body then + -- A lowercase x sits on the baseline and reaches neither above nor below the band, + -- so the height of its ink is the x-height. + body = font:GetTextHeight("x") + bodyHeight[font] = body + end + + -- Rounded, not floored: a whole pixel keeps the glyphs off a fraction, but always + -- taking the lower one leaves every label sitting up to a pixel low. + return mathFloor((y1 + y2) * 0.5 - size * body * 0.5 + 0.5) +end + +-- Shortens text until it draws inside maxWidth, marking the cut with "..". +function M.fit(font, text, maxWidth, size) + -- Callers derive the width by subtracting, so it can come through negative. Returning + -- the text whole there draws it straight out of whatever it was meant to fit inside. + if maxWidth <= 0 then + return "" + end + + local width = font:GetTextWidth(text) * size + if width <= maxWidth then + return text + end + + -- Trimmed by character, not byte: translated labels and the chain arrow are multi-byte. + -- Cut straight to the length the average glyph predicts and correct from there by one + -- character either way, rather than measuring every length down from the whole string. + local len = utf8.len(text) + local markW = font:GetTextWidth("..") * size + local keep = math.max(1, math.min(len - 1, math.floor(len * (maxWidth - markW) / width))) + local cut = utf8.sub(text, 1, keep) + while keep > 1 and font:GetTextWidth(cut .. "..") * size > maxWidth do + keep = keep - 1 + cut = utf8.sub(text, 1, keep) + end + while keep < len - 1 do + local longer = utf8.sub(text, 1, keep + 1) + if font:GetTextWidth(longer .. "..") * size > maxWidth then + break + end + keep, cut = keep + 1, longer + end + + return cut .. ".." +end + +-- Splits text into lines that each draw inside maxWidth. A word too long to fit is left +-- over-long for the caller to shorten, there being nowhere sensible to break it. +function M.wrap(font, text, maxWidth, size) + local lines = {} + local line + for word in text:gmatch("%S+") do + local candidate = line and (line .. " " .. word) or word + if line and font:GetTextWidth(candidate) * size > maxWidth then + lines[#lines + 1] = line + line = word + else + line = candidate + end + end + if line then + lines[#lines + 1] = line + end + + return lines +end + +-- Starts each line of wrapped text in the colour the line before it ended in. A tooltip prints its +-- text a line at a time, each line from the tooltip's own colour, so a colour set partway along one +-- line would otherwise stop where the line does - and wrapping ends lines wherever it has to. +function M.carryColors(str) + local out, current = {}, nil + for line in (str .. "\n"):gmatch("([^\n]*)\n") do + if current and line ~= "" and line:byte(1) ~= 255 then + line = current .. line + end + for code in line:gmatch("\255...") do + current = code + end + out[#out + 1] = line + end + + return table.concat(out, "\n") +end + +return M diff --git a/luaui/Include/lua_source.lua b/luaui/Include/lua_source.lua new file mode 100644 index 00000000000..247aa061678 --- /dev/null +++ b/luaui/Include/lua_source.lua @@ -0,0 +1,361 @@ +-- Pretty-prints a Lua snippet for display. +-- +-- Tweakdefs reach the game base64-encoded and minified: one enormous line, statements run +-- together with semicolons and every space squeezed out. Listing that raw shows the first +-- eighty characters of it and nothing else, so it is tokenized here and laid out again - +-- one statement per line, indented by block depth, with the spacing Lua is normally +-- written with. +-- +-- A formatter, not a parser: it never has to decide whether the source is valid, only +-- where a reader would have put the line breaks. Anything it cannot make sense of is +-- passed through as tokens rather than dropped. + +local M = {} + +local KEYWORDS = {} +for word in + string.gmatch( + "and break do else elseif end false for function if in local nil not or repeat return then true until while", + "%S+" + ) +do + KEYWORDS[word] = true +end + +-- Opens a block: what follows it is indented, on its own line. +local OPENERS = { ["do"] = true, ["then"] = true, ["repeat"] = true } +-- Closes one: it goes back out a level and starts a line of its own. +local CLOSERS = { ["end"] = true, ["until"] = true, ["else"] = true, ["elseif"] = true } + +-- Both sides get a space. `=` is in here too: it is not an operator in Lua, but it reads +-- like one and every style writes it spaced. +local BINARY = {} +for op in string.gmatch("+ - * / % ^ .. == ~= < > <= >= =", "%S+") do + BINARY[op] = true +end + +-- After one of these a `-` is a sign rather than a subtraction, so it sticks to what +-- follows it. +local PREFIX = { ["("] = true, ["["] = true, ["{"] = true, [","] = true, [";"] = true } + +local CLOSING = { [")"] = true, ["]"] = true, ["}"] = true } + +-- Punctuation rather than arithmetic. It is the most common token in any source, so it is +-- reported apart from the operators: tinting all of it the same is what turns highlighting +-- into noise. +local PUNCT = {} +for _, mark in ipairs({ ",", ";", ".", ":", "(", ")", "[", "]", "{", "}" }) do + PUNCT[mark] = true +end + +-- Keywords that can only begin a statement. Minified source runs statements together with +-- nothing between them, and one of these arriving after a finished expression is the only +-- sign of where the next one starts. +local STARTERS = {} +for word in string.gmatch("local if for while repeat return function break", "%S+") do + STARTERS[word] = true +end + +---------------------------------------------------------------- +-- Tokens +---------------------------------------------------------------- + +-- The end of a long bracket opened at `from`, or the end of the source when it is never +-- closed. Shared by long strings and long comments, which differ only in what precedes. +local function longBracketEnd(src, from, eq) + local close = "]" .. eq .. "]" + local at = string.find(src, close, from, true) + + return at and (at + #close - 1) or #src +end + +local function readString(src, i) + local quote = string.sub(src, i, i) + local j = i + 1 + local n = #src + while j <= n do + local c = string.sub(src, j, j) + if c == "\\" then + j = j + 2 + elseif c == quote then + return j + elseif c == "\n" then + -- Unterminated: stop at the line rather than swallowing the rest of the file. + return j - 1 + else + j = j + 1 + end + end + + return n +end + +local function readNumber(src, i) + return string.match(src, "^0[xX]%x+", i) + or string.match(src, "^%d+%.?%d*[eE][-+]?%d+", i) + or string.match(src, "^%.%d+[eE][-+]?%d+", i) + or string.match(src, "^%d+%.?%d*", i) + or string.match(src, "^%.%d+", i) + or string.sub(src, i, i) +end + +-- The source as a flat list of { k = kind, s = text }; whitespace is dropped, since the +-- layout below decides all of it again. +local function tokenize(src) + local toks = {} + local i, n = 1, #src + while i <= n do + local c = string.sub(src, i, i) + if string.find(c, "%s") then + i = i + 1 + elseif string.sub(src, i, i + 1) == "--" then + local eq = string.match(src, "^%-%-%[(=*)%[", i) + local stop + if eq then + stop = longBracketEnd(src, i + 4 + #eq, eq) + else + stop = (string.find(src, "\n", i, true) or (n + 1)) - 1 + end + toks[#toks + 1] = { k = "comment", s = string.sub(src, i, stop) } + i = stop + 1 + elseif string.match(src, "^%[=*%[", i) then + local eq = string.match(src, "^%[(=*)%[", i) + local stop = longBracketEnd(src, i + 2 + #eq, eq) + toks[#toks + 1] = { k = "string", s = string.sub(src, i, stop) } + i = stop + 1 + elseif c == '"' or c == "'" then + local stop = readString(src, i) + toks[#toks + 1] = { k = "string", s = string.sub(src, i, stop) } + i = stop + 1 + elseif string.find(c, "%d") or (c == "." and string.find(string.sub(src, i + 1, i + 1), "%d")) then + local s = readNumber(src, i) + toks[#toks + 1] = { k = "number", s = s } + i = i + #s + elseif string.find(c, "[%a_]") then + local s = string.match(src, "^[%a_][%w_]*", i) + toks[#toks + 1] = { k = KEYWORDS[s] and "keyword" or "name", s = s } + i = i + #s + else + local s = string.match(src, "^%.%.%.", i) + or string.match(src, "^[=~<>]=", i) + or string.match(src, "^%.%.", i) + or c + toks[#toks + 1] = { k = "op", s = s } + i = i + #s + end + end + + return toks +end + +---------------------------------------------------------------- +-- Spacing +---------------------------------------------------------------- + +local function isWord(t) + return t.k == "name" or t.k == "number" or t.k == "keyword" or t.k == "string" +end + +-- Whether a token can be the last one of a statement. What follows such a token is either +-- more of the same expression or, if it is a STARTER, the next statement. +local function endsStatement(t) + if not t then + return false + end + + return t.k == "name" or t.k == "number" or t.k == "string" or CLOSING[t.s] or t.s == "..." +end + +-- Whether a space belongs between two tokens. Only readability is at stake except for one +-- case that is not optional: two words run together become a different word. +local function spaced(a, b, prevOfA) + if not a then + return false + end + + local as, bs = a.s, b.s + + -- Nothing is ever pushed away from what it closes or qualifies. + if bs == "," or bs == ";" or bs == ")" or bs == "]" or bs == "." or bs == ":" then + return false + end + if as == "." or as == ":" or as == "(" or as == "[" or as == "#" then + return false + end + -- A separator's whole job is to hold things apart. + if as == "," then + return true + end + -- A call or an index hangs off the thing it applies to, and a function hugs its own + -- parameter list; everywhere else the bracket is a grouping and wants air. + if bs == "(" or bs == "[" then + return not (as == "function" or a.k == "name" or CLOSING[as]) + end + -- An empty table stays empty; anything else gets room inside its braces. + if bs == "}" then + return as ~= "{" + end + if as == "{" then + return true + end + -- Sign, not arithmetic. + if + (as == "-" or as == "+") + and (prevOfA == nil or PREFIX[prevOfA.s] or BINARY[prevOfA.s] or prevOfA.k == "keyword") + then + return false + end + if BINARY[as] or BINARY[bs] then + return true + end + if a.k == "keyword" or b.k == "keyword" then + return true + end + if isWord(a) and isWord(b) then + return true + end + if CLOSING[as] and (isWord(b) or bs == "{" or bs == "#") then + return true + end + + return false +end + +---------------------------------------------------------------- +-- Layout +---------------------------------------------------------------- + +-- A comment is handed over as its own words, so a long one wraps like prose instead of +-- being cut off at the column. +local function commentParts(str) + local parts = {} + for word in string.gmatch(str, "%S+") do + parts[#parts + 1] = { s = (#parts > 0 and " " or "") .. word, k = "comment" } + end + + return parts +end + +-- A name with a call directly after it reads as the thing being done rather than a value, +-- and colouring it apart is most of what makes a wall of source scannable. Lua's two +-- sugared call forms - `f"str"` and `f{...}` - count as well. +local function markCalls(toks) + for i = 1, #toks - 1 do + local t, next = toks[i], toks[i + 1] + if t.k == "name" and (next.s == "(" or next.s == "{" or next.k == "string") then + t.call = true + end + end +end + +---Lays a Lua snippet out as display lines. +--- +---A part is one token with whatever space belongs in front of it, and the kind it was +---lexed as, so the caller can both break lines between parts and colour them. Kinds are +---`comment`, `string`, `number`, `keyword`, `call`, `name`, `op` and `punct`. +---@param src string +---@return table lines Each `{ depth = , parts = { { s = , k = } } }` +function M.format(src) + local toks = tokenize(src) + markCalls(toks) + local lines = {} + local parts = {} + local depth, lineDepth = 0, 0 + local prev, prevPrev + -- ( and [ nesting, and the nesting each open function's parameter list closes at, so + -- the body starts on a line of its own without a parser to say where it begins. + local paren = 0 + local fnParen = {} + local fnPending = false + + local function flush() + if #parts > 0 then + lines[#lines + 1] = { depth = lineDepth, parts = parts } + parts = {} + end + lineDepth = depth + prev, prevPrev = nil, nil + end + + local function push(t) + local kind = t.k + if t.call then + kind = "call" + elseif kind == "op" and PUNCT[t.s] then + kind = "punct" + end + parts[#parts + 1] = { s = (spaced(prev, t, prevPrev) and " " or "") .. t.s, k = kind } + prevPrev, prev = prev, t + end + + for i = 1, #toks do + local t = toks[i] + local s = t.s + + -- Nothing separates two statements written side by side, so a keyword that can only + -- open one, arriving where the line so far already reads as finished, is the break. + -- Only outside brackets: the same words appear inside expressions. + if STARTERS[s] and paren == 0 and endsStatement(prev) then + flush() + end + + if t.k == "comment" then + flush() + -- A long comment carries its own line breaks; each becomes a line here. + for line in string.gmatch(s .. "\n", "([^\n]*)\n") do + if string.find(line, "%S") then + lines[#lines + 1] = { depth = depth, parts = commentParts(line) } + end + end + lineDepth = depth + elseif s == ";" then + -- The separator itself is what the line break now says, so it is dropped. + flush() + elseif CLOSERS[s] then + depth = math.max(0, depth - 1) + flush() + lineDepth = depth + push(t) + if s == "end" then + flush() + elseif s == "else" then + depth = depth + 1 + flush() + end + -- `elseif` is left open: its own `then` puts the level back. + -- `until` keeps its condition beside it. + elseif OPENERS[s] then + push(t) + depth = depth + 1 + flush() + else + if s == "function" then + fnPending = true + end + + if s == "(" or s == "[" or s == "{" then + paren = paren + 1 + if s == "(" and fnPending then + fnParen[#fnParen + 1] = paren + fnPending = false + end + push(t) + elseif CLOSING[s] then + push(t) + if s == ")" and fnParen[#fnParen] == paren then + fnParen[#fnParen] = nil + depth = depth + 1 + flush() + end + paren = math.max(0, paren - 1) + else + push(t) + end + end + end + flush() + + return lines +end + +return M diff --git a/luaui/Include/markdown.lua b/luaui/Include/markdown.lua new file mode 100644 index 00000000000..ca42486b693 --- /dev/null +++ b/luaui/Include/markdown.lua @@ -0,0 +1,1575 @@ +-- Markdown for widget text panels. +-- +-- Three stages, each usable on its own: +-- Markdown.parse(text) -> doc blocks and headings, inline markup resolved +-- Markdown.layout(doc, ctx) -> rows word-wrapped rows of styled segments +-- Markdown.draw(rows, i, j, x, y, ctx) paints rows i..j with their top edge at y +-- +-- Block level: ATX and setext headings, paragraphs (soft breaks joined, hard breaks on +-- a trailing backslash, two trailing spaces or
), nested bullet and numbered lists +-- with continuation paragraphs, blockquotes, fenced code, horizontal rules, pipe tables, +-- link reference definitions and HTML comments. Inline: **bold**, *italic*, `code`, +-- ~~strike~~, [text](url), and bare http(s) links, images as their alt text, and +-- backslash escapes. Indented code blocks are deliberately not recognised; an indented +-- line outside a list is ordinary paragraph text, which is what changelog authors mean. +-- +-- Bold uses a heavier face and code a monospaced one, so the wrapping measures every +-- word with the face it will be drawn in. Italic is the regular face sheared with a +-- matrix, which the engine's batched font path cannot do, so italic segments print +-- unbatched; ctx.italicShear = 0 turns that into plain text. + +local Markdown = {} + +local mathFloor = math.floor +local mathMax = math.max +local mathMin = math.min +local strSub = string.sub +local strFind = string.find +local strMatch = string.match +local strGsub = string.gsub +local strLower = string.lower +local strRep = string.rep +local tableConcat = table.concat + +---------------------------------------------------------------------------------------- +-- Inline markup +---------------------------------------------------------------------------------------- + +-- A run is { text = s, b = bold, i = italic, c = code, s = strike, link = url } or +-- { br = true } for a hard break. Runs that share a style are merged as they are added. +local function sameStyle(a, b) + return a.b == b.b and a.i == b.i and a.c == b.c and a.s == b.s and a.link == b.link +end + +local function withStyle(st, key, value) + local t = { b = st.b, i = st.i, c = st.c, s = st.s, link = st.link } + t[key] = value + return t +end + +local function isSpaceAt(s, i) + local c = strSub(s, i, i) + return c == "" or c == " " or c == "\n" or c == "\t" +end + +local function isPunctAt(s, i) + local c = strSub(s, i, i) + return c ~= "" and strFind(c, "%p") ~= nil +end + +local function isWordAt(s, i) + local c = strSub(s, i, i) + return c ~= "" and strFind(c, "[%w]") ~= nil +end + +-- CommonMark's flanking test for a delimiter run at i..i+len-1: whether it may open +-- or close emphasis, judged by the characters on either side of it. +local function delimiterFlags(s, i, len, c) + local prevSpace = isSpaceAt(s, i - 1) + local nextSpace = isSpaceAt(s, i + len) + local prevPunct = isPunctAt(s, i - 1) + local nextPunct = isPunctAt(s, i + len) + local left = not nextSpace and (not nextPunct or prevSpace or prevPunct) + local right = not prevSpace and (not prevPunct or nextSpace or nextPunct) + if c == "_" then + return left and (not right or prevPunct), right and (not left or nextPunct) + end + return left, right +end + +-- The matching close bracket for the one at `i`, honouring nesting and escapes. +local function matchBracket(s, i, open, close) + local depth = 0 + local p = i + local n = #s + while p <= n do + local ch = strSub(s, p, p) + if ch == "\\" then + p = p + 2 + else + if ch == open then + depth = depth + 1 + elseif ch == close then + depth = depth - 1 + if depth == 0 then + return p + end + end + p = p + 1 + end + end + return nil +end + +local function findTickClose(s, ticks, from) + local p = from + while true do + local q = strFind(s, ticks, p, true) + if not q then + return nil + end + local after = q + #ticks + if strSub(s, q - 1, q - 1) ~= "`" and strSub(s, after, after) ~= "`" then + return q + end + p = after + end +end + +local parseInline + +-- The text of a run list with the markup dropped, for labels and searches. +function Markdown.plainText(runs) + local out = {} + for k = 1, #runs do + local r = runs[k] + out[#out + 1] = r.br and " " or r.text + end + return tableConcat(out) +end + +-- Walks `s` in style `st`, appending tokens: text { text, st }, breaks { br } and +-- emphasis delimiter runs { delim, n, open, close }, which are matched up afterwards. +local function scan(s, st, tokens, refs) + local n = #s + local i = 1 + local buf = {} + + local function text(t, style) + if t ~= "" then + tokens[#tokens + 1] = { text = t, st = style } + end + end + + local function flush() + if #buf > 0 then + text(tableConcat(buf), st) + buf = {} + end + end + + local function inlineRuns(label, style) + local runs = parseInline(label, refs, style) + for k = 1, #runs do + local r = runs[k] + if r.br then + tokens[#tokens + 1] = { br = true } + else + tokens[#tokens + 1] = { text = r.text, st = r } + end + end + end + + while i <= n do + local c = strSub(s, i, i) + local handled = false + + if c == "\\" then + local nx = strSub(s, i + 1, i + 1) + if nx ~= "" and strFind(nx, "%p") then + buf[#buf + 1] = nx + i = i + 2 + else + buf[#buf + 1] = c + i = i + 1 + end + handled = true + elseif c == "\n" then + flush() + tokens[#tokens + 1] = { br = true } + i = i + 1 + handled = true + elseif c == "`" then + local ticks = strMatch(s, "^`+", i) + local close = findTickClose(s, ticks, i + #ticks) + if close then + local code = strSub(s, i + #ticks, close - 1) + code = strGsub(code, "\n", " ") + if #code >= 2 and strSub(code, 1, 1) == " " and strSub(code, -1) == " " and strFind(code, "%S") then + code = strSub(code, 2, -2) + end + flush() + text(code, withStyle(st, "c", true)) + i = close + #ticks + else + buf[#buf + 1] = ticks + i = i + #ticks + end + handled = true + elseif c == "*" or c == "_" or c == "~" then + local run = strMatch(s, "^" .. (c == "*" and "%*+" or (c == "_" and "_+" or "~+")), i) + local len = #run + -- Only a double tilde strikes, so the lone "~" of "~25%" stays literal. + if c ~= "~" or len == 2 then + local open, close = delimiterFlags(s, i, len, c) + flush() + tokens[#tokens + 1] = { delim = c, n = len, orig = len, open = open, close = close, st = st, opens = {}, closes = {} } + else + buf[#buf + 1] = run + end + i = i + len + handled = true + elseif c == "[" or (c == "!" and strSub(s, i + 1, i + 1) == "[") then + local isImage = c == "!" + local start = isImage and i + 1 or i + local close = matchBracket(s, start, "[", "]") + if close then + local label = strSub(s, start + 1, close - 1) + local after = close + 1 + local url + local resume + local nextChar = strSub(s, after, after) + if nextChar == "(" then + local pclose = matchBracket(s, after, "(", ")") + if pclose then + local inner = strSub(s, after + 1, pclose - 1) + url = strMatch(inner, "^%s*<([^>]*)>") or strMatch(inner, "^%s*(%S+)") or "" + resume = pclose + 1 + end + elseif nextChar == "[" then + local rclose = strFind(s, "]", after + 1, true) + if rclose then + local id = strSub(s, after + 1, rclose - 1) + if id == "" then + id = label + end + url = refs[strLower(id)] + if url then + resume = rclose + 1 + end + end + else + url = refs[strLower(label)] + if url then + resume = after + end + end + if resume then + flush() + if isImage then + -- No pictures here: the alt text stands in for the image. + text(Markdown.plainText(parseInline(label, refs)), withStyle(st, "i", true)) + else + inlineRuns(label, withStyle(st, "link", url)) + end + i = resume + handled = true + end + end + if not handled then + buf[#buf + 1] = c + i = i + 1 + handled = true + end + elseif c == "<" then + local url = strMatch(s, "^<(%a[%w+.-]*://[^%s<>]*)>", i) + if url then + flush() + text(url, withStyle(st, "link", url)) + i = i + #url + 2 + else + local br = strMatch(s, "^<[bB][rR]%s*/?>", i) + if br then + flush() + tokens[#tokens + 1] = { br = true } + i = i + #br + else + buf[#buf + 1] = c + i = i + 1 + end + end + handled = true + elseif c == "h" and (strFind(s, "^https?://", i)) and not isWordAt(s, i - 1) then + local url = strMatch(s, "^https?://[^%s<]+", i) + -- Trailing punctuation belongs to the sentence, not the address. + local trimmed = strMatch(url, "^(.-)[%.,:;!%?%)]*$") + if trimmed ~= "" then + url = trimmed + end + flush() + text(url, withStyle(st, "link", url)) + i = i + #url + handled = true + end + + if not handled then + buf[#buf + 1] = c + i = i + 1 + end + end + flush() +end + +-- CommonMark's "process emphasis": every closing delimiter run looks back for the +-- nearest opener of its kind, they hand each other as many delimiters as both can +-- spare (two for bold, one for italic), and any delimiters between them are spent. +local function processEmphasis(tokens) + local i = 1 + while i <= #tokens do + local c = tokens[i] + if c.delim and c.close and c.n > 0 then + local j = i - 1 + local o + while j >= 1 do + local t = tokens[j] + if t.delim and t.delim == c.delim and t.open and t.n > 0 and not t.spent then + -- One run that could both open and close may not pair with another + -- when their lengths add up to a multiple of three, unless both do. + local odd = (t.close or c.open) and (t.orig + c.orig) % 3 == 0 and not (t.orig % 3 == 0 and c.orig % 3 == 0) + if c.delim == "~" then + odd = t.n ~= c.n + end + if not odd then + o = t + break + end + end + j = j - 1 + end + if o then + local use + if c.delim == "~" then + use = 2 + else + use = (o.n >= 2 and c.n >= 2) and 2 or 1 + end + local kind = c.delim == "~" and "s" or (use == 2 and "b" or "i") + o.opens[#o.opens + 1] = kind + c.closes[#c.closes + 1] = kind + o.n = o.n - use + c.n = c.n - use + for k = j + 1, i - 1 do + if tokens[k].delim then + tokens[k].spent = true + end + end + if c.n == 0 then + i = i + 1 + end + else + i = i + 1 + end + else + i = i + 1 + end + end +end + +-- Turns matched tokens into runs: leftover delimiters print literally, and the +-- emphasis they opened or closed nests as bold/italic/strike depth. +local function flattenTokens(tokens, runs) + local depth = { b = 0, i = 0, s = 0 } + + local function emit(t, st) + if t == "" then + return + end + local style = { + b = st.b or depth.b > 0, + i = st.i or depth.i > 0, + s = st.s or depth.s > 0, + c = st.c, + link = st.link, + } + local last = runs[#runs] + if last and not last.br and sameStyle(last, style) then + last.text = last.text .. t + else + style.text = t + runs[#runs + 1] = style + end + end + + for k = 1, #tokens do + local t = tokens[k] + if t.br then + runs[#runs + 1] = { br = true } + elseif t.text then + emit(t.text, t.st) + else + -- A closer's matches close innermost first; an opener's open outermost + -- first, which is the reverse of the order they were found in. + for m = 1, #t.closes do + local kind = t.closes[m] + depth[kind] = depth[kind] - 1 + end + if t.n > 0 then + emit(strRep(t.delim, t.n), t.st) + end + for m = #t.opens, 1, -1 do + local kind = t.opens[m] + depth[kind] = depth[kind] + 1 + end + end + end +end + +parseInline = function(text, refs, style) + local tokens = {} + scan(text, style or {}, tokens, refs or {}) + processEmphasis(tokens) + local runs = {} + flattenTokens(tokens, runs) + return runs +end + +Markdown.parseInline = parseInline + +---------------------------------------------------------------------------------------- +-- Block structure +---------------------------------------------------------------------------------------- + +local function expandTabs(line) + return (strGsub(line, "\t", " ")) +end + +local function indentOf(line) + local _, e = strFind(line, "^ *") + return e +end + +local function isBlank(line) + return strFind(line, "^%s*$") ~= nil +end + +-- Level and text of an ATX heading, or nil. +local function atxHeading(s) + local hashes, rest = strMatch(s, "^(#+)[ \t]+(.-)[ \t]*$") + if not hashes then + hashes = strMatch(s, "^(#+)[ \t]*$") + rest = "" + end + if not hashes or #hashes > 6 then + return nil + end + rest = strGsub(rest, "[ \t]+#+$", "") + if strFind(rest, "^#+$") then + rest = "" + end + return #hashes, rest +end + +local function isRule(s) + local t = strGsub(s, "[ \t]", "") + return #t >= 3 and (strFind(t, "^%-+$") or strFind(t, "^%*+$") or strFind(t, "^_+$")) ~= nil +end + +local function fenceOpen(s) + local sp, fence, info = strMatch(s, "^( *)(```+)(.*)$") + if not fence then + sp, fence, info = strMatch(s, "^( *)(~~~+)(.*)$") + end + if not fence then + return nil + end + if strSub(fence, 1, 1) == "`" and strFind(info, "`") then + return nil + end + return #sp, fence, strMatch(info, "^%s*(%S*)") +end + +local function fenceClose(s, fence) + local t = strMatch(s, "^ *(%S+)%s*$") + return t ~= nil and #t >= #fence and strFind(t, "^" .. strSub(fence, 1, 1) .. "+$") ~= nil +end + +-- Indent, content column, ordered flag, number and text of a list item line, or nil. +local function listMarker(s) + local sp, mark = strMatch(s, "^( *)([-*+])[ \t]*$") + if mark then + return #sp, #sp + 2, false, nil, "" + end + local gap, text + sp, mark, gap, text = strMatch(s, "^( *)([-*+])( +)(.*)$") + if mark then + local g = #gap + if g > 4 then + g = 1 + end + return #sp, #sp + 1 + g, false, nil, text + end + local num + sp, num, mark = strMatch(s, "^( *)(%d+)([.)])[ \t]*$") + if num and #num <= 9 then + return #sp, #sp + #num + 2, true, tonumber(num), "" + end + sp, num, mark, gap, text = strMatch(s, "^( *)(%d+)([.)])( +)(.*)$") + if num and #num <= 9 then + local g = #gap + if g > 4 then + g = 1 + end + return #sp, #sp + #num + 1 + g, true, tonumber(num), text + end + return nil +end + +local function splitCells(s) + s = strGsub(s, "^%s*|", "") + s = strGsub(s, "|%s*$", "") + local cells = {} + local cur = {} + local i = 1 + local n = #s + while i <= n do + local c = strSub(s, i, i) + if c == "\\" and strSub(s, i + 1, i + 1) == "|" then + cur[#cur + 1] = "|" + i = i + 2 + elseif c == "|" then + cells[#cells + 1] = tableConcat(cur) + cur = {} + i = i + 1 + else + cur[#cur + 1] = c + i = i + 1 + end + end + cells[#cells + 1] = tableConcat(cur) + for k = 1, #cells do + cells[k] = strMatch(cells[k], "^%s*(.-)%s*$") + end + return cells +end + +-- Column alignments of a table delimiter row such as | --- | :-: | --: |, or nil. +local function delimiterRow(s) + if not strFind(s, "|", 1, true) or not strFind(s, "%-") then + return nil + end + local cells = splitCells(s) + local align = {} + for k, c in ipairs(cells) do + if not strFind(c, "^:?%-+:?$") then + return nil + end + local l = strSub(c, 1, 1) == ":" + local r = strSub(c, -1) == ":" + align[k] = (l and r and "center") or (r and "right") or "left" + end + return align +end + +local function isQuoteLine(s) + return strFind(s, "^ ? ? ?>") ~= nil +end + +local function stripQuote(s) + return (strGsub(s, "^ ? ? ?> ?", "")) +end + +local function startsBlock(s, nextLine) + if atxHeading(s) or isRule(s) or fenceOpen(s) or isQuoteLine(s) or listMarker(s) then + return true + end + if strFind(s, "|", 1, true) and nextLine and delimiterRow(nextLine) then + return true + end + return false +end + +-- Paragraph lines become one string: a soft break is a space, a hard break (trailing +-- backslash or two spaces) a newline for the inline scanner. +local function joinLines(lines) + local out = {} + local n = #lines + for k = 1, n do + local l = lines[k] + local body = strGsub(l, "%s+$", "") + if k < n then + local hard = strFind(l, " $") ~= nil + if not hard and strFind(body, "\\$") then + hard = true + body = strSub(body, 1, -2) + end + out[#out + 1] = body + out[#out + 1] = hard and "\n" or " " + else + out[#out + 1] = body + end + end + return tableConcat(out) +end + +local parseBlocks + +-- Parses `lines` into `blocks`. `quote` is the blockquote depth of everything found. +parseBlocks = function(lines, blocks, quote, refs) + local n = #lines + local i = 1 + -- Open list items, innermost last: { col = content column, ordered, count }. + local stack = {} + -- Lists that a new item at each depth would continue: { ordered, count }. + local listAt = {} + -- The paragraph or item still collecting text lines. + local open = nil + local blankSeen = false + + local function closeOpen() + open = nil + end + + local function add(block) + block.quote = quote + blocks[#blocks + 1] = block + return block + end + + local function closeListsBelow(level) + for k = #listAt, level + 1, -1 do + listAt[k] = nil + end + end + + while i <= n do + local line = expandTabs(lines[i]) + if isBlank(line) then + closeOpen() + blankSeen = true + i = i + 1 + else + local ind = indentOf(line) + local nextLine = lines[i + 1] and expandTabs(lines[i + 1]) or nil + local t0 = strSub(line, ind + 1) + local lazy = open ~= nil and not blankSeen and not startsBlock(t0, nextLine) and not strFind(t0, "^=+%s*$") + if lazy then + open.lines[#open.lines + 1] = strSub(line, ind + 1) + i = i + 1 + else + while #stack > 0 and ind < stack[#stack].col do + stack[#stack] = nil + end + local level = #stack + local base = level > 0 and stack[level].col or 0 + local s = strSub(line, base + 1) + local t = strSub(s, indentOf(s) + 1) + + local fenceIndent, fence, lang = fenceOpen(s) + local hLevel, hText = atxHeading(t) + local _, mCol, ordered, number, mText = listMarker(s) + + if fence then + closeOpen() + closeListsBelow(level) + local code = {} + i = i + 1 + while i <= n do + local l = expandTabs(lines[i]) + if fenceClose(strSub(l, base + 1), fence) then + i = i + 1 + break + end + -- Lines keep their own indentation past the fence's. + local strip = mathMin(base + fenceIndent, indentOf(l)) + code[#code + 1] = strSub(l, strip + 1) + i = i + 1 + end + add({ kind = "code", lines = code, lang = lang, indent = level }) + blankSeen = false + elseif hLevel then + closeOpen() + closeListsBelow(level) + add({ kind = "heading", level = hLevel, lines = { hText }, indent = level }) + blankSeen = false + i = i + 1 + elseif open and open.kind == "para" and not blankSeen and (strFind(t, "^=+%s*$") or strFind(t, "^%-+%s*$")) then + -- A setext underline turns the paragraph above into a heading. + open.kind = "heading" + open.level = strFind(t, "^=") and 1 or 2 + closeOpen() + i = i + 1 + elseif isRule(t) then + closeOpen() + closeListsBelow(level) + add({ kind = "rule", indent = level }) + blankSeen = false + i = i + 1 + elseif isQuoteLine(s) then + closeOpen() + closeListsBelow(level) + local inner = {} + while i <= n do + local l = expandTabs(lines[i]) + local rel = strSub(l, base + 1) + if isQuoteLine(rel) then + inner[#inner + 1] = stripQuote(rel) + elseif not isBlank(l) and #inner > 0 and not startsBlock(strSub(l, indentOf(l) + 1), nil) and not isBlank(inner[#inner]) then + -- Lazy continuation of the quoted paragraph. + inner[#inner + 1] = strSub(l, indentOf(l) + 1) + else + break + end + i = i + 1 + end + local sub = {} + parseBlocks(inner, sub, quote + 1, refs) + for k = 1, #sub do + sub[k].indent = (sub[k].indent or 0) + level + blocks[#blocks + 1] = sub[k] + end + blankSeen = false + elseif strFind(t, "|", 1, true) and nextLine and delimiterRow(strSub(nextLine, base + 1)) then + closeOpen() + closeListsBelow(level) + local align = delimiterRow(strSub(nextLine, base + 1)) + local header = splitCells(t) + local rows = {} + i = i + 2 + while i <= n do + local l = expandTabs(lines[i]) + if isBlank(l) or not strFind(l, "|", 1, true) then + break + end + rows[#rows + 1] = splitCells(strSub(l, indentOf(l) + 1)) + i = i + 1 + end + add({ kind = "table", header = header, align = align, rows = rows, indent = level }) + blankSeen = false + elseif mCol then + closeOpen() + local newLevel = level + 1 + closeListsBelow(newLevel) + local list = listAt[newLevel] + local loose = false + if list and list.ordered == ordered then + list.count = list.count + 1 + loose = blankSeen + else + list = { ordered = ordered, count = number or 1 } + listAt[newLevel] = list + end + stack[newLevel] = { col = base + mCol } + local item = add({ + kind = "item", + level = newLevel, + ordered = ordered, + number = list.count, + lines = { mText }, + loose = loose, + indent = newLevel, + }) + open = item + blankSeen = false + i = i + 1 + else + local id, url = strMatch(t, "^%[([^%]]+)%]:%s*(%S+)") + if id and not open then + refs[strLower(id)] = url + i = i + 1 + else + closeListsBelow(level) + local para = add({ kind = "para", lines = { t }, indent = level, loose = blankSeen and level > 0 }) + open = para + blankSeen = false + i = i + 1 + end + end + end + end + end +end + +-- Parses a whole document. Blocks carry `inline` (runs) where they hold text, `indent` +-- (enclosing list depth) and `quote` (blockquote depth). `doc.headings` lists every +-- heading with its block index; `doc.chapterLevel` is the shallowest level used, the +-- one a table of contents should be built from. +function Markdown.parse(text) + text = text or "" + if strSub(text, 1, 3) == "\239\187\191" then + text = strSub(text, 4) + end + text = strGsub(text, "", "") + local lines = {} + for line in string.gmatch(text .. "\n", "(.-)\r?\n") do + lines[#lines + 1] = line + end + + local refs = {} + local blocks = {} + parseBlocks(lines, blocks, 0, refs) + + local headings = {} + local chapterLevel = 7 + for k = 1, #blocks do + local b = blocks[k] + if b.lines and b.kind ~= "code" then + b.text = joinLines(b.lines) + b.inline = parseInline(b.text, refs) + b.lines = nil + end + if b.kind == "heading" then + b.text = Markdown.plainText(b.inline) + headings[#headings + 1] = { level = b.level, text = b.text, block = k } + if b.level < chapterLevel then + chapterLevel = b.level + end + elseif b.kind == "table" then + b.headerInline = {} + for c = 1, #b.header do + b.headerInline[c] = parseInline(b.header[c], refs) + end + b.rowsInline = {} + for r = 1, #b.rows do + local row = {} + for c = 1, #b.header do + row[c] = parseInline(b.rows[r][c] or "", refs) + end + b.rowsInline[r] = row + end + end + end + if chapterLevel == 7 then + chapterLevel = 1 + end + + return { blocks = blocks, headings = headings, chapterLevel = chapterLevel } +end + +---------------------------------------------------------------------------------------- +-- Layout +---------------------------------------------------------------------------------------- + +-- Sizes and colours at a given UI scale. Callers override what they need to and must +-- supply ctx.fonts = { regular, bold, mono } and ctx.width. +function Markdown.defaultContext(scale) + scale = scale or 1 + local function px(v) + return mathFloor(v * scale) + end + return { + scale = scale, + width = 600, + fonts = nil, + fs = { + body = px(15), + code = px(14), + h = { px(18), px(16), px(15), px(15), px(14), px(14) }, + }, + -- Row height as a multiple of the font size; 15 px text sits on a 19 px grid. + lineMul = 1.27, + listIndent = px(24), + markerGap = px(14), + numberGap = px(6), + quoteIndent = px(14), + quoteBar = px(3), + cellPad = px(10), + tableRowPad = px(3), + gapPara = px(6), + gapHeading = px(8), + gapCode = px(4), + codePad = px(5), + gapRule = px(6), + corner = px(3), + italicShear = 0.18, + bullets = { "\226\128\162", "\226\151\166", "\226\150\170" }, -- • ◦ ▪ + colors = { + text = { 0.8, 0.77, 0.74, 1 }, + heading = { 1, 1, 1, 1 }, + bold = { 0.95, 0.94, 0.92, 1 }, + code = { 0.85, 0.9, 0.95, 1 }, + codeBg = { 1, 1, 1, 0.07 }, + codeBlockBg = { 0, 0, 0, 0.22 }, + link = { 0.55, 0.75, 1, 1 }, + strike = { 0.58, 0.56, 0.54, 1 }, + marker = { 0.6, 0.58, 0.55, 1 }, + quoteBar = { 1, 1, 1, 0.22 }, + quoteText = { 0.68, 0.66, 0.64, 1 }, + rule = { 1, 1, 1, 0.15 }, + tableBg = { 1, 1, 1, 0.04 }, + tableHeadBg = { 1, 1, 1, 0.07 }, + tableStripe = { 1, 1, 1, 0.03 }, + tableLine = { 1, 1, 1, 0.22 }, + }, + } +end + +-- Word widths per face at size 1, so a page of repeated words measures once. +local widthCache = setmetatable({}, { __mode = "k" }) + +local function textWidth(font, s) + local cache = widthCache[font] + if not cache then + cache = {} + widthCache[font] = cache + end + local w = cache[s] + if not w then + w = font:GetTextWidth(s) + cache[s] = w + end + return w +end + +local function fontFor(run, ctx, forceBold) + local fonts = ctx.fonts + if run.c and fonts.mono then + return fonts.mono, "mono" + elseif (run.b or forceBold) and fonts.bold then + return fonts.bold, "bold" + end + return fonts.regular, "regular" +end + +local function colorFor(run, ctx, base) + local colors = ctx.colors + if run.link then + return colors.link + elseif run.c then + return colors.code + elseif run.s then + return colors.strike + elseif run.b and base == colors.text then + return colors.bold + end + return base +end + +-- Splits text into UTF-8 characters, for words wider than the whole column. +local function utf8Chars(s) + local out = {} + for ch in string.gmatch(s, "[%z\1-\127\194-\244][\128-\191]*") do + out[#out + 1] = ch + end + return out +end + +-- Wraps `runs` into rows of segments no wider than `width`. Segment x is relative to +-- the text column. Returns the rows as arrays of segments with `w` (used width). +local function wrapRuns(runs, width, fs, ctx, baseColor, forceBold) + local rows = {} + local row = { w = 0 } + rows[1] = row + local pendingSpace = false + + local function newRow() + row = { w = 0 } + rows[#rows + 1] = row + pendingSpace = false + end + + local function place(text, w, run, font, face, color, joinSpace, spaceW) + local last = row[#row] + if joinSpace and last and last.run == run then + last.text = last.text .. " " .. text + last.w = last.w + spaceW + w + row.w = row.w + spaceW + w + else + -- Whole pixels, so glyphs do not land between them after a style change. + local x = mathFloor(row.w + (joinSpace and spaceW or 0)) + row[#row + 1] = { + x = x, + w = w, + text = text, + font = font, + face = face, + fs = fs, + color = color, + run = run, + code = run.c, + strike = run.s, + link = run.link, + italic = run.i and ctx.italicShear ~= 0, + } + row.w = x + w + end + end + + for k = 1, #runs do + local run = runs[k] + if run.br then + newRow() + else + local font, face = fontFor(run, ctx, forceBold) + local color = colorFor(run, ctx, baseColor) + local spaceW = textWidth(font, " ") * fs + local text = run.text + local p = 1 + local n = #text + while p <= n do + local a, b = strFind(text, "^%s+", p) + if a then + if row.w > 0 then + pendingSpace = true + end + p = b + 1 + else + a, b = strFind(text, "^%S+", p) + local tok = strSub(text, a, b) + p = b + 1 + local w = textWidth(font, tok) * fs + if row.w == 0 then + if w > width then + -- Too wide for a whole row: break it by character. + local chars = utf8Chars(tok) + local piece = "" + local pieceW = 0 + for c = 1, #chars do + local cw = textWidth(font, chars[c]) * fs + if pieceW + cw > width and piece ~= "" then + place(piece, pieceW, run, font, face, color, false, 0) + newRow() + piece = "" + pieceW = 0 + end + piece = piece .. chars[c] + pieceW = pieceW + cw + end + place(piece, pieceW, run, font, face, color, false, 0) + else + place(tok, w, run, font, face, color, false, 0) + end + elseif pendingSpace then + if row.w + spaceW + w <= width then + place(tok, w, run, font, face, color, true, spaceW) + else + newRow() + if w > width then + local chars = utf8Chars(tok) + local piece = "" + local pieceW = 0 + for c = 1, #chars do + local cw = textWidth(font, chars[c]) * fs + if pieceW + cw > width and piece ~= "" then + place(piece, pieceW, run, font, face, color, false, 0) + newRow() + piece = "" + pieceW = 0 + end + piece = piece .. chars[c] + pieceW = pieceW + cw + end + place(piece, pieceW, run, font, face, color, false, 0) + else + place(tok, w, run, font, face, color, false, 0) + end + end + else + -- Glued to the previous token (a style change mid-word). + if row.w + w <= width or row.w == 0 then + place(tok, w, run, font, face, color, false, 0) + else + newRow() + place(tok, w, run, font, face, color, false, 0) + end + end + pendingSpace = false + end + end + end + end + return rows +end + +-- The measured width of runs on one line, for table columns. +local function runsWidth(runs, fs, ctx) + local w = 0 + for k = 1, #runs do + local run = runs[k] + if not run.br then + local font = fontFor(run, ctx) + w = w + textWidth(font, run.text) * fs + end + end + return w +end + +-- Lays a document out against ctx.width. Each row: h (advance), pad (space above the +-- text box), box (text box height), base (baseline below the row top), x (left indent), +-- segs, plus block, kind, quote, marker and decoration flags. Blocks get `firstRow`. +function Markdown.layout(doc, ctx) + local rows = {} + local blocks = doc.blocks + local colors = ctx.colors + local lineMul = ctx.lineMul + + local function boxFor(fs) + return mathFloor(fs * lineMul + 0.5) + end + + local function addRow(block, kind, fs, xIndent, segs, marker) + local box = boxFor(fs) + local row = { + h = box, + pad = 0, + box = box, + base = box, + x = xIndent, + w = segs and segs.w or 0, + segs = segs or {}, + block = block, + kind = kind, + quote = block.quote or 0, + marker = marker, + } + rows[#rows + 1] = row + return row + end + + local function padTop(row, gap) + row.pad = row.pad + gap + row.base = row.base + gap + row.h = row.h + gap + end + + local function gapBottom(row, gap) + row.h = row.h + gap + end + + for bi = 1, #blocks do + local b = blocks[bi] + local prev = blocks[bi - 1] + local nxt = blocks[bi + 1] + local xIndent = (b.indent or 0) * ctx.listIndent + (b.quote or 0) * ctx.quoteIndent + local width = mathMax(ctx.width - xIndent, ctx.listIndent) + local first = #rows + 1 + local baseColor = (b.quote or 0) > 0 and colors.quoteText or colors.text + + if b.kind == "heading" then + local fs = ctx.fs.h[mathMin(b.level, 6)] + local wrapped = wrapRuns(b.inline, width, fs, ctx, colors.heading, true) + for r = 1, #wrapped do + addRow(b, "heading", fs, xIndent, wrapped[r]) + end + if prev then + padTop(rows[first], ctx.gapHeading) + end + gapBottom(rows[#rows], mathFloor(ctx.gapPara * 0.34)) + elseif b.kind == "para" then + local fs = ctx.fs.body + local wrapped = wrapRuns(b.inline, width, fs, ctx, baseColor) + for r = 1, #wrapped do + addRow(b, "text", fs, xIndent, wrapped[r]) + end + if b.loose then + padTop(rows[first], ctx.gapPara) + end + if not (nxt and nxt.kind == "item" and nxt.level == (b.indent or 0)) then + gapBottom(rows[#rows], ctx.gapPara) + end + elseif b.kind == "item" then + local fs = ctx.fs.body + local wrapped = wrapRuns(b.inline, width, fs, ctx, baseColor) + local marker + if b.ordered then + local label = tostring(b.number) .. "." + marker = { + text = label, + font = ctx.fonts.regular, + face = "regular", + fs = fs, + color = colors.marker, + x = -ctx.numberGap - mathFloor(textWidth(ctx.fonts.regular, label) * fs + 0.5), + } + else + local glyph = ctx.bullets[((b.level - 1) % #ctx.bullets) + 1] + marker = { + text = glyph, + font = ctx.fonts.mono or ctx.fonts.regular, + face = ctx.fonts.mono and "mono" or "regular", + fs = fs, + color = colors.marker, + x = -ctx.markerGap, + } + end + for r = 1, #wrapped do + addRow(b, "text", fs, xIndent, wrapped[r], r == 1 and marker or nil) + end + if b.loose then + padTop(rows[first], ctx.gapPara) + end + -- No gap before the next item of the same list, a nested one, or the parent + -- list resuming; a different list or anything else gets one. + local continues = nxt + and ((nxt.kind == "item" and (nxt.level ~= b.level or nxt.ordered == b.ordered)) or (nxt.kind == "para" and (nxt.indent or 0) > 0)) + if not continues then + gapBottom(rows[#rows], ctx.gapPara) + end + elseif b.kind == "code" then + local fs = ctx.fs.code + local font = ctx.fonts.mono or ctx.fonts.regular + local run = { c = true, text = "" } + local lines = b.lines + if #lines == 0 then + lines = { "" } + end + for l = 1, #lines do + local text = strGsub(lines[l], "%s+$", "") + local segs = { w = 0 } + if text ~= "" then + -- Code keeps its spacing, so it wraps by character, not by word. + local chars = utf8Chars(text) + local piece = {} + local pieceW = 0 + local innerW = width - ctx.codePad * 2 + for c = 1, #chars do + local cw = textWidth(font, chars[c]) * fs + if pieceW + cw > innerW and #piece > 0 then + segs[#segs + 1] = { x = ctx.codePad, w = pieceW, text = tableConcat(piece), font = font, face = "mono", fs = fs, color = colors.code, run = run } + segs.w = pieceW + addRow(b, "code", fs, xIndent, segs).codeBlock = bi + segs = { w = 0 } + piece = {} + pieceW = 0 + end + piece[#piece + 1] = chars[c] + pieceW = pieceW + cw + end + segs[#segs + 1] = { x = ctx.codePad, w = pieceW, text = tableConcat(piece), font = font, face = "mono", fs = fs, color = colors.code, run = run } + segs.w = pieceW + end + addRow(b, "code", fs, xIndent, segs).codeBlock = bi + end + padTop(rows[first], ctx.gapCode + ctx.codePad) + gapBottom(rows[#rows], ctx.gapCode + ctx.codePad) + elseif b.kind == "rule" then + local row = addRow(b, "rule", 0, xIndent, nil) + row.pad = ctx.gapRule + row.box = 1 + row.base = ctx.gapRule + 1 + row.h = ctx.gapRule * 2 + 1 + row.rule = true + elseif b.kind == "table" then + local fs = ctx.fs.body + local ncol = #b.header + local colW = {} + local headerRuns = {} + for c = 1, ncol do + local runs = {} + for k = 1, #b.headerInline[c] do + local r = b.headerInline[c][k] + runs[k] = r.br and r or { text = r.text, b = true, i = r.i, c = r.c, s = r.s, link = r.link } + end + headerRuns[c] = runs + colW[c] = runsWidth(runs, fs, ctx) + end + for r = 1, #b.rowsInline do + for c = 1, ncol do + colW[c] = mathMax(colW[c], runsWidth(b.rowsInline[r][c], fs, ctx)) + end + end + for c = 1, ncol do + colW[c] = mathFloor(colW[c] + 0.999) + end + local total = 0 + for c = 1, ncol do + total = total + colW[c] + ctx.cellPad * 2 + end + -- A table wider than the column shrinks to fit rather than running under the + -- scrollbar. + if total > width and total > 0 then + local f = width / total + fs = mathMax(mathFloor(fs * f), mathFloor(ctx.fs.body * 0.6)) + for c = 1, ncol do + colW[c] = mathFloor(colW[c] * fs / ctx.fs.body) + end + total = 0 + for c = 1, ncol do + total = total + colW[c] + ctx.cellPad * 2 + end + end + local colX = {} + local x = 0 + for c = 1, ncol do + colX[c] = x + x = x + colW[c] + ctx.cellPad * 2 + end + + local function cellSegs(cellRuns, c, segs) + local runs = cellRuns[c] or {} + local cw = runsWidth(runs, fs, ctx) + local align = b.align[c] or "left" + local sx = colX[c] + ctx.cellPad + if align == "center" then + sx = sx + mathFloor((colW[c] - cw) * 0.5) + elseif align == "right" then + sx = sx + mathFloor(colW[c] - cw) + end + for k = 1, #runs do + local run = runs[k] + if not run.br then + local font, face = fontFor(run, ctx) + local w = textWidth(font, run.text) * fs + segs[#segs + 1] = { + x = sx, + w = w, + text = run.text, + font = font, + face = face, + fs = fs, + color = colorFor(run, ctx, baseColor), + run = run, + code = run.c, + strike = run.s, + link = run.link, + italic = run.i and ctx.italicShear ~= 0, + } + sx = mathFloor(sx + w + 0.5) + end + end + end + + local segs = { w = total } + for c = 1, ncol do + cellSegs(headerRuns, c, segs) + end + local head = addRow(b, "table", fs, xIndent, segs) + head.tableHead = true + head.tableW = total + head.tableBlock = bi + head.tableRow = 0 + padTop(head, ctx.tableRowPad) + gapBottom(head, ctx.tableRowPad) + for r = 1, #b.rowsInline do + segs = { w = total } + for c = 1, ncol do + cellSegs(b.rowsInline[r], c, segs) + end + local row = addRow(b, "table", fs, xIndent, segs) + row.tableW = total + row.tableBlock = bi + row.tableRow = r + padTop(row, ctx.tableRowPad) + gapBottom(row, ctx.tableRowPad) + end + gapBottom(rows[#rows], ctx.gapPara) + end + + b.firstRow = first + b.lastRow = #rows + end + + return rows +end + +-- Total height of rows i..j when i is the first row shown (its top padding is not +-- drawn, so the text starts flush with the top edge). +function Markdown.rowsHeight(rows, i, j) + local h = 0 + for k = i, j do + h = h + rows[k].h + end + if rows[i] then + h = h - rows[i].pad + end + return h +end + +---------------------------------------------------------------------------------------- +-- Drawing +---------------------------------------------------------------------------------------- + +local glColor = gl.Color +local glRect = gl.Rect +local glPushMatrix = gl.PushMatrix +local glPopMatrix = gl.PopMatrix +local glTranslate = gl.Translate +local glMultMatrix = gl.MultMatrix + +-- Paints rows first..last of `rows` with the text column's left edge at `x` and the +-- first row's text box starting at `top`. ctx.rectRound(x1, y1, x2, y2, cs, tl, tr, br, +-- bl, color) draws the rounded fills (FlowUI's RectRound); without it plain rects are +-- used. +function Markdown.draw(rows, first, last, x, top, ctx) + local colors = ctx.colors + local rectRound = ctx.rectRound + local width = ctx.width + + local function fill(x1, y1, x2, y2, color, cs, tl, tr, br, bl) + if rectRound and cs and cs > 0 then + rectRound(x1, y1, x2, y2, cs, tl or 1, tr or 1, br or 1, bl or 1, color) + else + glColor(color) + glRect(x1, y1, x2, y2) + end + end + + -- Pass 1: row tops and baselines. The first row's padding is eaten so the text + -- starts at `top` whatever block it belongs to. + local tops = {} + local bases = {} + local y = top + (rows[first] and rows[first].pad or 0) + for i = first, last do + local row = rows[i] + tops[i] = y + bases[i] = y - row.base + y = y - row.h + end + + -- Pass 2: block backgrounds. Code boxes span every contiguous run of their rows on + -- this page; quote bars and rules are per row and join up seamlessly. + local groupStart, groupBlock + local function flushCode(i) + if groupStart then + local r1 = rows[groupStart] + local yTop = tops[groupStart] - (groupStart == r1.block.firstRow and ctx.gapCode or 0) + if yTop > top then + yTop = top + end + local rN = rows[i] + local yBot = tops[i] - rN.h + (i == rN.block.lastRow and ctx.gapCode or 0) + fill(x + r1.x, yBot, x + width, yTop, colors.codeBlockBg, ctx.corner) + groupStart = nil + groupBlock = nil + end + end + for i = first, last do + local row = rows[i] + if row.codeBlock then + if groupBlock ~= row.codeBlock then + if groupStart then + flushCode(i - 1) + end + groupStart = i + groupBlock = row.codeBlock + end + elseif groupStart then + flushCode(i - 1) + end + end + if groupStart then + flushCode(last) + end + + -- Tables: a faint card under every contiguous run of a table's rows on this page, a + -- band behind the header with a line beneath it, and every other body row shaded. + -- Bands run from the row's top to just under its text, so the trailing gap after + -- the table stays clear. + local function bandTop(i) + local t = tops[i] + if t > top then + t = top + end + return t + end + local function bandBottom(i) + local r = rows[i] + return tops[i] - r.pad - r.box - ctx.tableRowPad + end + local tStart, tBlock + local function flushTable(i) + if tStart then + local r1 = rows[tStart] + local x1 = x + r1.x + local x2 = x1 + r1.tableW + fill(x1, bandBottom(i), x2, bandTop(tStart), colors.tableBg, ctx.corner) + for k = tStart, i do + local r = rows[k] + local topCorner = k == tStart and 1 or 0 + local bottomCorner = k == i and 1 or 0 + if r.tableRow == 0 then + fill(x1, bandBottom(k), x2, bandTop(k), colors.tableHeadBg, ctx.corner, topCorner, topCorner, bottomCorner, bottomCorner) + fill(x1, bandBottom(k), x2, bandBottom(k) + 1, colors.tableLine) + elseif r.tableRow % 2 == 0 then + fill(x1, bandBottom(k), x2, bandTop(k), colors.tableStripe, ctx.corner, topCorner, topCorner, bottomCorner, bottomCorner) + end + end + tStart = nil + tBlock = nil + end + end + for i = first, last do + local row = rows[i] + if row.tableBlock then + if tBlock ~= row.tableBlock then + if tStart then + flushTable(i - 1) + end + tStart = i + tBlock = row.tableBlock + end + elseif tStart then + flushTable(i - 1) + end + end + if tStart then + flushTable(last) + end + + for i = first, last do + local row = rows[i] + local rowTop = tops[i] + local rowBottom = rowTop - row.h + if row.quote > 0 then + for d = 1, row.quote do + local bx = x + row.x - (row.quote - d + 1) * ctx.quoteIndent + fill(bx, rowBottom, bx + ctx.quoteBar, rowTop, colors.quoteBar) + end + end + if row.rule then + local ly = rowTop - ctx.gapRule + fill(x + row.x, ly - 1, x + width, ly, colors.rule) + end + end + + -- Pass 3: inline decorations under the glyphs. + for i = first, last do + local row = rows[i] + local segs = row.segs + local base = bases[i] + for k = 1, #segs do + local seg = segs[k] + local sx = x + row.x + seg.x + local ex = mathFloor(sx + seg.w + 0.5) + if seg.code and row.kind ~= "code" then + local pad = mathFloor(seg.fs * 0.15) + fill(sx - pad, base - mathFloor(seg.fs * 0.22), ex + pad, base + mathFloor(seg.fs * 0.82), colors.codeBg, mathFloor(ctx.corner * 0.66)) + end + if seg.link then + fill(sx, base - 2, ex, base - 1, seg.color) + end + if seg.strike then + local ly = base + mathFloor(seg.fs * 0.3) + fill(sx, ly, ex, ly + 1, seg.color) + end + end + end + + -- Pass 4: glyphs, one batch per face. Italic segments are sheared, so they print + -- on their own outside the batches. + local italics = {} + local faces = { "regular", "bold", "mono" } + for f = 1, #faces do + local face = faces[f] + local font = ctx.fonts[face] + if font then + font:Begin() + for i = first, last do + local row = rows[i] + local base = bases[i] + local marker = row.marker + if marker and marker.face == face then + font:SetTextColor(marker.color) + font:Print(marker.text, x + row.x + marker.x, base, marker.fs, "n") + end + local segs = row.segs + for k = 1, #segs do + local seg = segs[k] + if seg.face == face then + if seg.italic then + italics[#italics + 1] = { seg = seg, x = x + row.x + seg.x, y = base } + else + font:SetTextColor(seg.color) + font:Print(seg.text, x + row.x + seg.x, base, seg.fs, "n") + end + end + end + end + font:End() + end + end + + local shear = ctx.italicShear + for k = 1, #italics do + local it = italics[k] + local seg = it.seg + glPushMatrix() + glTranslate(it.x, it.y, 0) + glMultMatrix(1, 0, 0, 0, shear, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) + seg.font:SetTextColor(seg.color) + seg.font:Print(seg.text, 0, 0, seg.fs, "n") + glPopMatrix() + end + glColor(1, 1, 1, 1) +end + +return Markdown diff --git a/luaui/Include/mission_options.lua b/luaui/Include/mission_options.lua new file mode 100644 index 00000000000..f7452d45a39 --- /dev/null +++ b/luaui/Include/mission_options.lua @@ -0,0 +1,43 @@ +-- Mission options: the `missionoptions` modoption, decoded and reduced to the questions +-- widgets actually ask of it. +-- +-- Format is base64url(zlib(json)); see modoptions.lua. Spring.GetModOptions() lowercases +-- the outer modoption key, but the keys inside the payload keep their original casing. +-- +-- Decoding costs a zlib inflate plus a json parse, and these do it on every call. The +-- modoption cannot change during a game, so call once at widget file scope or in +-- Initialize and keep the boolean; never per frame. + +local ModoptionPayload = VFS.Include("common/luaUtilities/modoption_payload.lua") + +local missionOptions = {} + +local function getOptions() + return ModoptionPayload.Decode(Spring.GetModOptions().missionoptions) +end + +--- Whether the mission places the starting units itself, leaving no commander to pick a +--- start position for or to queue pregame builds against. +---@return boolean +function missionOptions.IsStartUnitSpawnDisabled() + local options = getOptions() + if not options then + return false + end + + if options.disableInitialCommanderSpawn then + return true + end + + return not table.isNilOrEmpty(options.unitloadout) +end + +--- Whether the mission fixes the player's faction instead of letting them choose one. +---@return boolean +function missionOptions.IsFactionPickerDisabled() + local options = getOptions() + + return options ~= nil and options.disableFactionPicker == true +end + +return missionOptions diff --git a/luaui/Include/range_coverage_mask_gl4.lua b/luaui/Include/range_coverage_mask_gl4.lua new file mode 100644 index 00000000000..16af2fa7f9e --- /dev/null +++ b/luaui/Include/range_coverage_mask_gl4.lua @@ -0,0 +1,107 @@ +-------------------------------------------------------------------------------- +-- Shared range coverage mask targets +-- +-- Used by gui_attackrange_gl4.lua and gui_defenserange_gl4.lua. Both merge overlapping +-- range discs into one outline. Filling every disc into the stencil buffer of the main +-- framebuffer costs the summed disc areas at full multisampled resolution, tens of times +-- the screen for a few hundred rings. Instead the discs are drawn into this private, +-- single-sample FBO and the depth test does the merging: the first disc drawn wins per +-- pixel and the hierarchical depth test rejects the overlapping ones before they are +-- rasterized. The 8-bit red channel holds one bit per ring class (additive blend), so a +-- widget can clip its outlines per class by reading the texture. +-- +-- The targets are shared through WG so the widgets pay for one set of screen-sized +-- textures. A widget has to finish reading the mask within the draw callin that filled +-- it, the next user overwrites it. +-------------------------------------------------------------------------------- + +local GL_R8 = GL.R8 or 0x8229 +local GL_DEPTH_COMPONENT16 = GL.DEPTH_COMPONENT16 or 0x81A5 +local GL_COLOR_ATTACHMENT0 = GL.COLOR_ATTACHMENT0 or 0x8CE0 + +local RangeCoverageMask = {} + +local function sharedState() + WG.rangeCoverageMaskGL4 = WG.rangeCoverageMaskGL4 or { users = 0, sizeX = 0, sizeY = 0 } + return WG.rangeCoverageMaskGL4 +end + +local function deleteTargets(state) + if state.fbo then + gl.DeleteFBO(state.fbo) + state.fbo = nil + end + if state.texture then + gl.DeleteTexture(state.texture) + state.texture = nil + end + if state.depthTexture then + gl.DeleteTexture(state.depthTexture) + state.depthTexture = nil + end + state.sizeX, state.sizeY = 0, 0 +end + +local function createTargets(state, vsx, vsy) + deleteTargets(state) + local texOpts = { + min_filter = GL.NEAREST, + mag_filter = GL.NEAREST, + wrap_s = GL.CLAMP_TO_EDGE, + wrap_t = GL.CLAMP_TO_EDGE, + format = GL_R8, + } + state.texture = gl.CreateTexture(vsx, vsy, texOpts) + texOpts.format = GL_DEPTH_COMPONENT16 + state.depthTexture = gl.CreateTexture(vsx, vsy, texOpts) + if state.texture and state.depthTexture then + state.fbo = gl.CreateFBO({ + color0 = state.texture, + depth = state.depthTexture, + drawbuffers = { GL_COLOR_ATTACHMENT0 }, + }) + end + if not (state.fbo and gl.IsValidFBO(state.fbo)) then + deleteTargets(state) + state.failedSizeX, state.failedSizeY = vsx, vsy + Spring.Echo("Range coverage mask: could not create the FBO targets, range widgets use their stencil path") + return false + end + state.failedSizeX, state.failedSizeY = nil, nil + state.sizeX, state.sizeY = vsx, vsy + return true +end + +--- Registers a user of the shared targets. Call once from widget:Initialize and pair it +--- with Release in widget:Shutdown; the targets are freed with the last user. +function RangeCoverageMask.Acquire() + local state = sharedState() + state.users = state.users + 1 +end + +function RangeCoverageMask.Release() + local state = sharedState() + state.users = math.max(0, state.users - 1) + if state.users == 0 then + deleteTargets(state) + end +end + +--- Returns the FBO and its R8 colour texture, sized to the current world view, or nil when +--- they cannot be created. Call from a draw callin; the targets are (re)created on demand. +function RangeCoverageMask.Get() + local state = sharedState() + local vsx, vsy = Spring.GetViewGeometry() + if state.fbo and state.sizeX == vsx and state.sizeY == vsy then + return state.fbo, state.texture + end + if state.failedSizeX == vsx and state.failedSizeY == vsy then + return nil -- already failed at this size, do not retry every frame + end + if not createTargets(state, vsx, vsy) then + return nil + end + return state.fbo, state.texture +end + +return RangeCoverageMask diff --git a/luaui/Include/search.lua b/luaui/Include/search.lua new file mode 100644 index 00000000000..ef7f70fe925 --- /dev/null +++ b/luaui/Include/search.lua @@ -0,0 +1,298 @@ +-- Shared search and filtering for the panels with a search box: the widget selector, the +-- settings, the game info and the keybind editor. +-- +-- There are two kinds of search here, and the difference is deliberate: +-- +-- * Ranked. `query` then `score`, and the caller sorts by what comes back. A list of +-- things you are hunting for by name - widgets, settings - where the best answer +-- should rise to the top. +-- * Plain. `query` then `matches`, and the caller keeps its own order. A list you are +-- reading rather than hunting through - the game's settings as they were authored, +-- the keybinds grouped the way they are taught - where reshuffling the rows under the +-- cursor as each letter is typed loses the reader their place. +-- +-- Both are here so a panel can pick the one it wants, not so the four panels can be made +-- to search alike. +-- +-- Haystacks are handed in already lowercased. Every panel here builds its rows once and +-- searches them on each keystroke, so lowercasing belongs with the row, not with the +-- search; `normalize` is for the panels whose text only exists in a coloured form. + +local M = {} + +local stringByte = string.byte +local stringFind = string.find +local stringLower = string.lower +local stringGsub = string.gsub +local mathMax = math.max +local mathMin = math.min + +local EMPTY = {} + +---------------------------------------------------------------- +-- The query +---------------------------------------------------------------- + +-- What was typed, worked out once per keystroke rather than once per item. The words and +-- the spaceless form are only built when something is actually being searched for. +-- +-- Fields: `text` lowercased, `words` the whitespace-separated parts, `joined` those parts +-- run together (what the fuzzy pass matches against), `empty` when nothing was typed. +---@param text string? +---@return table query +function M.query(text) + if not text or text == "" then + return { text = "", words = EMPTY, joined = "", empty = true } + end + + local lower = stringLower(text) + local words = {} + for word in lower:gmatch("%S+") do + words[#words + 1] = word + end + + if #words == 0 then + return { text = "", words = EMPTY, joined = "", empty = true } + end + + return { text = lower, words = words, joined = (stringGsub(lower, "%s+", "")), empty = false } +end + +-- Strips the inline colour codes from a label so it can be searched or lowercased. Text +-- that is stored coloured only: prefer keeping an uncoloured copy on the row where you +-- can, since this runs over every item on every keystroke otherwise. +---@param text string? +---@return string +function M.normalize(text) + if not text or text == "" then + return "" + end + -- \255 takes three bytes of colour after it; \008 is the reset. + text = stringGsub(text, "\255...", "") + text = stringGsub(text, "\008", "") + text = stringGsub(text, "%s%s+", " ") + + return stringLower(text:match("^%s*(.-)%s*$") or text) +end + +---------------------------------------------------------------- +-- Plain, order-preserving +---------------------------------------------------------------- + +-- Does this row survive the filter? An empty query keeps everything, which is what makes +-- this the whole test at a call site rather than half of one. +---@param query table From `M.query` +---@param haystack string? Already lowercased +---@return boolean +function M.matches(query, haystack) + if query.empty then + return true + end + + return haystack ~= nil and stringFind(haystack, query.text, 1, true) ~= nil +end + +-- Did this heading itself match? A category, group or block whose own title matches keeps +-- everything under it, so searching for a section's name shows the section rather than +-- emptying it. Unlike `matches` an empty query is not a match: nothing is being searched +-- for, so nothing is being claimed. +---@param query table From `M.query` +---@param haystack string? Already lowercased +---@return boolean +function M.claims(query, haystack) + if query.empty then + return false + end + + return haystack ~= nil and stringFind(haystack, query.text, 1, true) ~= nil +end + +---------------------------------------------------------------- +-- Ranked +---------------------------------------------------------------- + +-- How well `query` reads as a subsequence of `target`: every query character has to appear +-- in order, and the score says how tightly. Runs together, at word starts and near the +-- front all count for more; gaps count against. `0` means it does not match at all. +-- +-- Both `query` and `target` are lowercased, and `query` is the spaceless form. +---@param query string +---@param target string +---@return number +function M.fuzzy(query, target) + local qi = 1 + local qlen = #query + local tlen = #target + if qlen == 0 then + return 0 + end + if qlen > tlen then + return 0 + end + + ---@type number + local score = 0 + local consecutive = 0 + local prevMatched = false + ---@type number? + local firstMatchPos = nil + local lastMatchPos = 0 + + for ti = 1, tlen do + if qi > qlen then + break + end + local tc = stringByte(target, ti) + local qc = stringByte(query, qi) + if tc == qc then + if not firstMatchPos then + firstMatchPos = ti + end + qi = qi + 1 + -- Gap penalty: penalize distance from previous match + if lastMatchPos > 0 then + local gap = ti - lastMatchPos - 1 + if gap > 0 then + score = score - gap * 0.5 + end + end + lastMatchPos = ti + -- Consecutive character bonus + if prevMatched then + consecutive = consecutive + 1 + score = score + 3 + consecutive + else + consecutive = 0 + score = score + 1 + end + -- Word boundary bonus: char after space, underscore, or start of string + if ti == 1 then + score = score + 5 + else + local prev = stringByte(target, ti - 1) + if prev == 32 or prev == 95 or prev == 45 then -- space, underscore, dash + score = score + 4 + end + end + prevMatched = true + else + prevMatched = false + consecutive = 0 + end + end + + if qi <= qlen then + return 0 -- not all query chars matched + end + + -- Bonus for matching near the start + if firstMatchPos then + score = score + mathMax(0, 6 - firstMatchPos) + end + + -- Normalize: prefer shorter targets (tighter matches) + score = score + mathMax(0, 3 - (tlen - qlen) * 0.1) + + return score +end + +-- How well one item answers the query, as three tiers that never overlap, so a whole-word +-- hit always outranks a scattered one however pretty the latter scores: +-- +-- 300+ the query appears whole in a `primary` field +-- 100-299 every word appears somewhere; 200 when they are all in the first field +-- 1-99 the query reads as a subsequence of a `primary` field +-- +-- `primary` is what the item is called - its name, and whatever else names it, such as an +-- id. The first entry is the one the multi-word tier counts as a name hit. `secondary` is +-- everything else worth finding it by, a description or an author: enough to satisfy the +-- multi-word tier, never enough to match on its own. +-- +-- Both are arrays of lowercased strings, read and not kept, so a caller can fill one pair +-- of tables outside its loop and rewrite them per item rather than allocating. +-- +-- Returns `0` when the item does not match at all. +---@param query table From `M.query` +---@param primary string[] +---@param secondary string[]? +---@return number +function M.score(query, primary, secondary) + if query.empty then + return 0 + end + + local text = query.text + + -- Tier 1: the query, whole, in one of the fields the item is named by. Earlier in a + -- shorter field is a better answer than later in a longer one. + for i = 1, #primary do + local field = primary[i] + local at = field ~= "" and stringFind(field, text, 1, true) + if at then + return 300 + mathMax(0, 50 - at) + mathMax(0, 20 - #field) + end + end + + local words = query.words + + -- Tier 2: every word found somewhere. All of them in the name beats some of them + -- landing in a description. + if #words > 1 then + local first = primary[1] or "" + local nameMatches = 0 + ---@type number + local posSum = 0 + local all = true + for i = 1, #words do + local word = words[i] + local inName = first ~= "" and stringFind(first, word, 1, true) + local found = inName + if not found and secondary then + for j = 1, #secondary do + local field = secondary[j] + if field ~= "" and stringFind(field, word, 1, true) then + found = true + break + end + end + end + if not found then + all = false + break + end + if inName then + nameMatches = nameMatches + 1 + posSum = posSum + inName + end + end + if all then + local base = (nameMatches == #words) and 200 or 100 + + return base + mathMax(0, 50 - posSum / #words) + end + end + + -- Tier 3: a subsequence, which is loose enough that it needs a few characters to go on + -- and a floor under how well it has to read before it counts at all. + local joined = query.joined + if #joined >= 3 then + ---@type number + local best = 0 + for i = 1, #primary do + local field = primary[i] + if field ~= "" then + local s = M.fuzzy(joined, field) + if s > best then + best = s + end + end + end + if best >= #joined * 2 then + return mathMin(99, best) + end + end + + return 0 +end + +return M diff --git a/luaui/Include/select_api.lua b/luaui/Include/select_api.lua index 57e45221e5d..6718011bc69 100644 --- a/luaui/Include/select_api.lua +++ b/luaui/Include/select_api.lua @@ -358,7 +358,7 @@ end --- Applies the filter function to the unit represented by the unit ID to determine if the unit --- passes the filter. --- ---- @param uid integer The unit ID +--- @param uid UnitID --- @param filterFunctions table List of filter functions --- @return boolean? passes Whether the unit passes the filter, nil if the unit doesn't exist function SelectApi.unitPassesFilter(uid, filterFunctions) diff --git a/luaui/Include/startpolygon_sdf_gl4.lua b/luaui/Include/startpolygon_sdf_gl4.lua new file mode 100644 index 00000000000..eaa6ef91a36 --- /dev/null +++ b/luaui/Include/startpolygon_sdf_gl4.lua @@ -0,0 +1,122 @@ +-------------------------------------------------------------------------------- +-- Baked start polygon distance field +-- +-- Shared by gfx_norush_timer_gl4.lua and map_startbox.lua. Both draw a fullscreen pass +-- that needs the distance from every pixel to the start polygons. Walking the (spline +-- tessellated) polygon edges per pixel per frame cost more than the rest of the frame, +-- so the field is rendered once into a map-sized texture and the per-frame shaders +-- sample that instead. +-- +-- Texel layout, one texel per TEXEL_ELMOS (see startpolygon_sdf_bake_gl4.frag.glsl): +-- x = signed distance to the closest polygon, negative inside +-- y = key of that polygon (whatever the caller stored in the SSBO: team or allyteam id) +-- z = distance to the edge of the containing polygon, negative outside +-- w = flags: bit 0 own allyteam box, bit 1 scav box, bit 2 raptor box, +-- bits 3+ number of enemy boxes (capped at 2) +-- +-- Bake() issues gl.RenderToTexture, so it may only run from a world draw callin: the +-- engine restores framebuffer 0 afterwards, which would break the minimap texture pass. +-------------------------------------------------------------------------------- + +local TEXEL_ELMOS = 8 +local MAX_TEXTURE_SIZE = 1024 + +local GL_TRIANGLES = GL.TRIANGLES +local GL_SRC_ALPHA = GL.SRC_ALPHA +local GL_ONE_MINUS_SRC_ALPHA = GL.ONE_MINUS_SRC_ALPHA + +local StartPolygonSDF = {} +StartPolygonSDF.__index = StartPolygonSDF + +--- Creates the texture and the bake shader. +--- @param params table { format = GL.RG32F|GL.RGBA16F, shaderName = string, +--- shaderConfig = { NUM_POLYGONS, NUM_POINTS, SCAV_ALLYTEAM_ID?, RAPTOR_ALLYTEAM_ID? } } +--- @return table|nil sdf, string|nil error +function StartPolygonSDF.Create(params) + local sizeX = math.min(MAX_TEXTURE_SIZE, math.ceil(Game.mapSizeX / TEXEL_ELMOS)) + local sizeY = math.min(MAX_TEXTURE_SIZE, math.ceil(Game.mapSizeZ / TEXEL_ELMOS)) + + local texture = gl.CreateTexture(sizeX, sizeY, { + format = params.format, + min_filter = GL.NEAREST, -- the shaders filter by hand, see SampleStartPolygonSDF + mag_filter = GL.NEAREST, + wrap_s = GL.CLAMP_TO_EDGE, + wrap_t = GL.CLAMP_TO_EDGE, + fbo = true, + }) + if not texture then + return nil, "could not allocate the start polygon distance field texture" + end + + local shaderSourceCache = { + vssrcpath = "LuaUI/Shaders/startpolygon_sdf_bake_gl4.vert.glsl", + fssrcpath = "LuaUI/Shaders/startpolygon_sdf_bake_gl4.frag.glsl", + uniformInt = { + myAllyTeamID = -1, + }, + uniformFloat = {}, + shaderName = params.shaderName, + shaderConfig = params.shaderConfig, + } + local shader = gl.LuaShader.CheckShaderUpdates(shaderSourceCache) + if not shader then + gl.DeleteTexture(texture) + return nil, "start polygon distance field bake shader failed to compile" + end + + local self = setmetatable({}, StartPolygonSDF) + self.texture = texture + self.sizeX = sizeX + self.sizeY = sizeY + self.shader = shader + self.shaderSourceCache = shaderSourceCache + self.bakedAllyTeamID = nil + return self +end + +--- Renders the field. World draw callins only (see header). +--- @param polygonBuffer table SSBO of { key, numVertices, x, z } quads +--- @param rectVAO table fullscreen rect VAO from InstanceVBOTable.MakeTexRectVAO() +--- @param myAllyTeamID number|nil allyteam the flags channel treats as "own" (only matters +--- when the buffer keys are allyteam ids) +function StartPolygonSDF:Bake(polygonBuffer, rectVAO, myAllyTeamID) + myAllyTeamID = myAllyTeamID or -1 + if not self.drawRect then + self.drawRect = function() + rectVAO:DrawArrays(GL_TRIANGLES) + end + end + + polygonBuffer:BindBufferRange(4) + + gl.Culling(false) + gl.DepthTest(false) + gl.DepthMask(false) + gl.Blending(false) -- the channels are data, blending would corrupt them + + self.shader:Activate() + self.shader:SetUniformInt("myAllyTeamID", myAllyTeamID) + gl.RenderToTexture(self.texture, self.drawRect) + self.shader:Deactivate() + + gl.Blending(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) -- back to the callin default + self.bakedAllyTeamID = myAllyTeamID +end + +--- True once Bake() has run for this allyteam. +function StartPolygonSDF:IsBakedFor(myAllyTeamID) + return self.bakedAllyTeamID == (myAllyTeamID or -1) +end + +function StartPolygonSDF:Delete() + if self.texture then + gl.DeleteTexture(self.texture) + self.texture = nil + end + if self.shader then + self.shader:Delete() + self.shader = nil + end +end + +return StartPolygonSDF diff --git a/luaui/Include/unitBlocking.lua b/luaui/Include/unitBlocking.lua index 8122d4531b8..ca6cfafeee0 100644 --- a/luaui/Include/unitBlocking.lua +++ b/luaui/Include/unitBlocking.lua @@ -3,7 +3,7 @@ local unitBlocking = {} --- Gets blocked unit definitions from TeamRulesParams ----@param unitDefIDs? number[] Optional array of specific UnitDefIDs to check. If nil, checks all blocked units for the current team. +---@param unitDefIDs? UnitDefID[] If `nil`, checks all blocked units for the current team. ---@return table> blockedUnits Table where keys are UnitDefIDs and values are tables of blocking reasons (reason -> true) ---@usage --- -- Get all blocked units diff --git a/luaui/Include/widget_dependencies.lua b/luaui/Include/widget_dependencies.lua new file mode 100644 index 00000000000..c6f46c968dd --- /dev/null +++ b/luaui/Include/widget_dependencies.lua @@ -0,0 +1,240 @@ +-- What a widget shares with others through WG, read out of its source. +-- +-- Widgets hand each other functionality through tables on WG - gui_fonthandler sets WG.fonts, and +-- dozens of widgets reach for it - and nothing declares any of that. So it is read out of the +-- source: the keys a widget assigns (`provides`), the keys it reaches for (`uses`), and whether it +-- ever checks a key is there before using it (`guards`). A use that is never checked breaks when +-- whatever provides it is switched off; a checked one only loses what it offered. +-- +-- A static reading, so a good guess rather than a fact. A key looked up through a variable is +-- invisible, and "checks it" means the file tests it somewhere, not that every use is covered. The +-- LuaUI files a widget VFS.Includes are read as well, since a fair part of what widgets share is +-- reached through those. +-- +-- Pure Lua with no engine calls: `load(path)` hands back an included file's source, or nil, so the +-- same code runs in the game and in the offline tests. It reads every widget there is, so it finds +-- things with plain searches and reads patterns only where those land. A pattern search over the +-- seven megabytes of widget source, or a copy of it with the comments taken out, is what made the +-- first version take a third of a second. + +local M = {} + +local find, sub, match, byte, lower = string.find, string.sub, string.match, string.byte, string.lower + +-- Directory variables the game sets for widgets, for include paths built from them. +---@type table +local KNOWN_DIRS = { LUAUI_DIRNAME = "LuaUI/" } + +local MAX_INCLUDE_DEPTH = 4 + +-- Each included file is read once and its reading kept, since LuaShader.lua and the like are +-- included by dozens of widgets. `false` while a file is being read, so one that includes itself +-- back gets nothing rather than looping. +local included = {} + +function M.clearCache() + included = {} +end + +-- Whether a byte is one a Lua name can be made of. +local function isNameByte(b) + return b ~= nil and (b == 95 or (b >= 48 and b <= 57) or (b >= 65 and b <= 90) or (b >= 97 and b <= 122)) +end + +-- Whether position `s` comes after a `--` on the same line. Comments name keys all the time +-- without using them. Read backwards to the start of the line, a few dozen bytes, rather than +-- copying every source without its comments first. +local function inComment(src, s) + local i = s - 1 + while i > 1 do + local b = byte(src, i) + if b == 10 or b == 13 then + return false + end + if b == 45 and byte(src, i - 1) == 45 then + return true + end + i = i - 1 + end + + return false +end + +-- Calls fn(start, finish, key) for every WG.name, WG["name"] or WG['name'] outside a comment. A +-- match inside a longer name, like myWG.x, is not WG. +local function eachKey(src, fn) + local init = 1 + while true do + local s = find(src, "WG", init, true) + if not s then + return + end + init = s + 2 + if not isNameByte(byte(src, s - 1)) then + local _, e, key = find(src, "^%.([%a_][%w_]*)", s + 2) + if not e then + _, e, key = find(src, "^%[%s*[\"']([%a_][%w_]*)[\"']%s*%]", s + 2) + end + if e then + init = e + 1 + if not inComment(src, s) then + fn(s, e, key) + end + end + end + end +end + +-- Whether the name between `s` and `e` is being tested rather than used: followed by then, and, +-- or or a comparison; behind a not, or handed to type(); or the last operand inside a condition, +-- as in `not (a or WG.x)`. `if WG.x.y then` is not a test of WG.x - it fails without it - and +-- neither is passing it along to something else. +local function isCheck(src, s, e) + local after = sub(src, e + 1, e + 16) + if + find(after, "^%s*then%f[^%w_]") + or find(after, "^%s*and%f[^%w_]") + or find(after, "^%s*or%f[^%w_]") + or find(after, "^%s*[~=]=") + then + return true + end + local before = sub(src, math.max(1, s - 16), s - 1) + if find(before, "%f[%w_]not%s*%(?%s*$") or find(before, "%f[%w_]type%s*%(%s*$") then + return true + end + + return find(after, "^%s*%)") ~= nil + and (find(before, "%f[%w_]or%s*$") ~= nil or find(before, "%f[%w_]and%s*$") ~= nil) +end + +-- The variable a key is put in on a line of its own, like `local grid = WG.gridmenu`, or nil. +local function assignedTo(src, s, e) + if not find(sub(src, e + 1, e + 8), "^%s*;?[ \t]*[\r\n]") then + return nil + end + + return match(sub(src, math.max(1, s - 48), s - 1), "([%a_][%w_]*)%s*=%s*$") +end + +-- Whether a variable a key was put in is tested somewhere, so `if not grid then return end` checks +-- WG.gridmenu through the variable. +local function variableChecked(src, name) + local init, length = 1, #name + while true do + local s = find(src, name, init, true) + if not s then + return false + end + local e = s + length - 1 + if + not isNameByte(byte(src, s - 1)) + and not isNameByte(byte(src, e + 1)) + and isCheck(src, s, e) + and not inComment(src, s) + then + return true + end + init = e + 1 + end +end + +-- The LuaUI files a source includes, where the path can be read without running anything: a +-- literal, or a literal added to a directory variable the file sets itself or the game provides. +-- Only LuaUI's own: gamedata and config tables are large and cannot reach WG. +local function includePaths(src) + local paths = {} + local init = 1 + while true do + local s, e = find(src, "VFS.Include", init, true) + if not s then + return paths + end + init = e + 1 + if not inComment(src, s) then + local _, _, path = find(src, "^%s*[%(,]%s*[\"']([^\"'\r\n]+)[\"']", e + 1) + if not path then + local _, _, name, rest = find(src, "^%s*[%(,]%s*([%a_][%w_]*)%s*%.%.%s*[\"']([^\"'\r\n]+)[\"']", e + 1) + if name then + local base = KNOWN_DIRS[name] + or match(src, "%f[%w_]" .. name .. '%s*=%s*"([^"\r\n]*)"') + or match(src, "%f[%w_]" .. name .. "%s*=%s*'([^'\r\n]*)'") + path = base and base .. rest + end + end + local key = path and lower(path) + if key and find(key, "^luaui/") and find(key, "%.lua$") then + paths[#paths + 1] = path + end + end + end +end + +local function merge(into, from) + for key in pairs(from) do + into[key] = true + end +end + +local function read(text, load, depth) + local provides, uses, guards = {}, {}, {} + local follow = load and depth < MAX_INCLUDE_DEPTH and find(text, "VFS.Include", 1, true) ~= nil + if not (follow or find(text, "WG", 1, true)) then + return { provides = provides, uses = uses, guards = guards } + end + + local variables = {} + eachKey(text, function(s, e, key) + if find(sub(text, e + 1, e + 16), "^%s*=[^=]") then + provides[key] = true + return + end + uses[key] = true + if isCheck(text, s, e) then + guards[key] = true + else + local name = assignedTo(text, s, e) + if name then + variables[#variables + 1] = { name, key } + end + end + end) + for _, v in ipairs(variables) do + if not guards[v[2]] and variableChecked(text, v[1]) then + guards[v[2]] = true + end + end + + if follow then + for _, path in ipairs(includePaths(text)) do + local key = lower(path) + local result = included[key] + if result == nil then + included[key] = false + local ok, source = pcall(load, path) + result = ok and type(source) == "string" and read(source, load, depth + 1) or false + included[key] = result + end + if result then + merge(provides, result.provides) + merge(uses, result.uses) + merge(guards, result.guards) + end + end + end + + return { provides = provides, uses = uses, guards = guards } +end + +-- Reads a widget's source, and the LuaUI sources it includes, into three sets of WG keys. +function M.scan(src, load) + local result = read(src, load, 0) + -- What a widget provides is not something it depends on, even where it reads it back. + for key in pairs(result.provides) do + result.uses[key] = nil + end + + return result +end + +return M diff --git a/luaui/Include/widget_profiling.lua b/luaui/Include/widget_profiling.lua new file mode 100644 index 00000000000..c84d4b28d02 --- /dev/null +++ b/luaui/Include/widget_profiling.lua @@ -0,0 +1,474 @@ +-- Per-widget CPU and memory measurement, shared by everything that wants to read it. +-- +-- There is no engine call for what one widget costs, so the only way to find out is to wrap +-- every callin of every widget and time what happens inside. That wrapping is global and it +-- does not nest: two independent hookers would each end up timing the other's wrapper, and +-- both sets of numbers would be wrong. So it lives here, once, behind a count of who wants +-- it - the first subscriber puts the hooks in and the last one takes them out again. +-- +-- VFS.Include runs the file afresh for each includer, which would hand every widget its own +-- copy of that count. The instance is therefore parked on WG, and every later include of +-- this file gets the one already running. +-- +-- Ported out of dbg_widget_profiler, which measured all of this itself before the widget +-- selector wanted the same numbers. + +if WG.widgetProfiling then + return WG.widgetProfiling +end + +local spGetTimer = Spring.GetTimer +local spDiffTimers = Spring.DiffTimers +local spGetLuaMemUsage = Spring.GetLuaMemUsage +local spGetFPS = Spring.GetFPS +local spGetConfigFloat = Spring.GetConfigFloat +local mathExp = math.exp +local mathMin = math.min +local stringFind = string.find +local stringSub = string.sub +local type = type +local pairs = pairs + +local highres +if Spring.GetTimerMicros and Spring.GetConfigInt("UseHighResTimer", 0) == 1 then + spGetTimer = Spring.GetTimerMicros + highres = true +end + +local M = {} + +-- How often the raw counters are turned into averages. +-- +-- The wrappers run whatever this is - they are the callins themselves - but the sweep that +-- turns their counters into averages walks every widget and all of its callins, and that +-- only happens on a tick. So this is worth asking for no faster than the reader needs: +-- subscribers say what they want and the fastest of them wins, since a slower reader is +-- satisfied by numbers arriving sooner than it asked. +local DEFAULT_TICK = 0.1 +local tick = DEFAULT_TICK +-- Set by hand through the profiler's action, which is an explicit instruction and beats +-- what the subscribers asked for. +local tickOverride +-- The window the ordering average is taken over, in seconds. Much longer than the smoothing +-- above on purpose: a list that reorders itself every time a widget has a busy frame cannot +-- be read at all. +local retainSortTime = 100 + +-- [owner] = how often that owner wants the numbers, in seconds. +local subscribers = {} +local subscriberCount = 0 +local hooked = false + +-- [name][callin] = { time since last sample, time since forever, space since last, space } +local callinStats = {} +local wrapped = {} +setmetatable(wrapped, { __mode = "k" }) +local inHook = false +local s = 0 +local startTimer +local deltaTime + +-- What each widget costs, as everything reading this sees it. Entries are updated in place +-- rather than rebuilt, since the sweep runs several times a second for the whole widget list. +-- +-- load smoothed share of wall clock, in percent +-- space smoothed allocation rate, in kB/s +-- avg load again over a much longer window, for anything that orders by cost +-- peakTime, peakSpace the callin that accounted for most of each +M.stats = {} +M.total = { load = 0, space = 0 } +M.mem = { lua = 0, global = 0, unsynced = 0, shared = 0 } +M.deltaTime = 0 +-- Counts up once per sample. Anything that builds something out of these numbers can +-- hold it until this moves, rather than rebuilding on every frame for figures that only +-- change ten times a second. +M.gen = 0 + +-- Per-callin detail, which only the widget being drilled into pays for. +local callinAverages = {} +local detailName + +local oldUpdateWidgetCallIn +local oldInsertWidget +local callInsList + +local function calcLoad(old, new, t) + if t and t > 0 then + local exptick = mathExp(-tick / t) + + return old * exptick + new * (1 - exptick) + end + + return new +end + +local function buildCallInsList(wh) + local list, n = {}, 0 + for name, e in pairs(wh) do + local i = stringFind(name, "List", nil, true) + if i and type(e) == "table" then + n = n + 1 + list[n] = stringSub(name, 1, i - 1) + end + end + + return list +end + +-- Keeps the handler's own ordering when a callin list is rebuilt: widgets sit in layer +-- order, and re-inserting one anywhere else would change the order call-ins run in. +local function arrayInsert(t, f, g) + if f then + local layer = g.whInfo.layer + local index = 1 + for i = 1, #t do + local v = t[i] + if v == g then + return + end + if layer >= v.whInfo.layer then + index = i + 1 + end + end + table.insert(t, index, g) + end +end + +local function arrayRemove(t, g) + for k = 1, #t do + if t[k] == g then + table.remove(t, k) + + return + end + end +end + +-- Wraps one callin of one widget. The timer is taken before the real function and read +-- after it, and the allocation counter with it; `inHook` keeps a callin that calls another +-- widget's callin from being counted twice. +local function hook(w, name) + local widgetName = w.whInfo.name + local realFunc = w[name] + w["_old" .. name] = realFunc + + -- Measuring the measurer only makes the measurement worse. + if widgetName == "Widget Profiler" then + return realFunc + end + + local stats = callinStats[widgetName] + if not stats then + stats = {} + callinStats[widgetName] = stats + end + stats[name] = stats[name] or { 0, 0, 0, 0 } + local c = stats[name] + + local t + + local helperFunc = function(...) + local dt = spDiffTimers(spGetTimer(), t, nil, highres) + local _, _, newS, _ = spGetLuaMemUsage() + local ds = newS - s + c[1] = c[1] + dt + c[2] = c[2] + dt + c[3] = c[3] + ds + c[4] = c[4] + ds + inHook = false + + return ... + end + + local hookFunc = function(...) + if inHook then + return realFunc(...) + end + + inHook = true + t = spGetTimer() + local _, _, newS, _ = spGetLuaMemUsage() + s = newS + + return helperFunc(realFunc(...)) + end + + wrapped[hookFunc] = true + + return hookFunc +end + +local function startHook() + local wh = widgetHandler + callInsList = callInsList or buildCallInsList(wh) + + for i = 1, #callInsList do + local callin = callInsList[i] + local list = wh[callin .. "List"] + if list then + for j = 1, #list do + list[j][callin] = hook(list[j], callin) + end + end + end + + -- A widget that gains or loses a callin later, and one that loads later, both have to + -- be wrapped too, or they measure as free. + oldUpdateWidgetCallIn = wh.UpdateWidgetCallInRaw + wh.UpdateWidgetCallInRaw = function(self, name, w) + local ciList = self[name .. "List"] + if ciList then + local func = w[name] + if type(func) == "function" then + if not wrapped[func] then + w[name] = hook(w, name) + end + arrayInsert(ciList, func, w) + else + arrayRemove(ciList, w) + end + self:UpdateCallIn(name) + else + Spring.Echo("UpdateWidgetCallIn: bad name: " .. name) + end + end + + oldInsertWidget = wh.InsertWidgetRaw + wh.InsertWidgetRaw = function(self, w) + if w == nil then + return + end + oldInsertWidget(self, w) + for i = 1, #callInsList do + local callin = callInsList[i] + if type(w[callin]) == "function" then + w[callin] = hook(w, callin) + end + end + end + + startTimer = spGetTimer() + hooked = true +end + +local function stopHook() + local wh = widgetHandler + local list = callInsList or buildCallInsList(wh) + + -- Every widget the handler holds, not only the ones still in a callin list: a widget + -- that dropped a callin after it was wrapped is no longer in that list, and leaving it + -- wrapped would have it measuring into a table nobody reads for the rest of the + -- session. That costs nothing while profiling runs once; this goes on and off as often + -- as a panel is opened. + for i = 1, #wh.widgets do + local w = wh.widgets[i] + for j = 1, #list do + local callin = list[j] + local old = w["_old" .. callin] + if old then + w[callin] = old + w["_old" .. callin] = nil + end + end + end + + if oldUpdateWidgetCallIn then + wh.UpdateWidgetCallInRaw = oldUpdateWidgetCallIn + oldUpdateWidgetCallIn = nil + end + if oldInsertWidget then + wh.InsertWidgetRaw = oldInsertWidget + oldInsertWidget = nil + end + + hooked = false + callinStats = {} + callinAverages = {} + for name in pairs(M.stats) do + M.stats[name] = nil + end + M.total.load, M.total.space = 0, 0 +end + +---------------------------------------------------------------- +-- API +---------------------------------------------------------------- + +-- How often everyone wanting the numbers needs them, which is as often as the most +-- impatient of them asked. +local function retick() + if tickOverride then + tick = tickOverride + + return + end + local want + for _, interval in pairs(subscribers) do + if not want or interval < want then + want = interval + end + end + tick = want or DEFAULT_TICK +end + +-- `owner` is any unique key; the widget table itself does. Subscribing twice from the same +-- owner counts once, so a panel can ask on every toggle without keeping track. `interval` +-- is how often that owner wants the numbers refreshed - a reader that updates a column a +-- player is glancing at does not need them as often as one drawing a live graph. +function M.subscribe(owner, interval) + if subscribers[owner] then + return + end + subscribers[owner] = tonumber(interval) or DEFAULT_TICK + subscriberCount = subscriberCount + 1 + retick() + if subscriberCount == 1 then + startHook() + end +end + +function M.unsubscribe(owner) + if not subscribers[owner] then + return + end + subscribers[owner] = nil + subscriberCount = subscriberCount - 1 + retick() + if subscriberCount == 0 then + stopHook() + end +end + +function M.subscribed() + return subscriberCount +end + +-- Whether this particular owner is one of them, which is how a panel restoring a saved +-- setting can tell whether it has acted on it yet. +function M.subscribes(owner) + return subscribers[owner] ~= nil +end + +function M.isHooked() + return hooked +end + +-- Sets the rate by hand, whatever the subscribers asked for; nil gives it back to them. +function M.setTick(seconds) + tickOverride = tonumber(seconds) + retick() + + return tick +end + +function M.getTick() + return tick +end + +-- Which widget's per-callin breakdown to keep. Only one at a time: the smoothing behind it +-- costs a table per callin, and nothing reads more than one at once. +function M.setDetail(name) + if detailName ~= name then + detailName = name + callinAverages = {} + end +end + +function M.callins(name) + return callinAverages[name] +end + +-- Turns the raw counters into averages, at most once per tick however often it is called - +-- several panels can ask on the same frame and only the first does the work. Answers true +-- when new numbers landed. +function M.sample() + if not hooked or not startTimer then + return false + end + + deltaTime = spDiffTimers(spGetTimer(), startTimer, nil, highres) + if deltaTime < tick then + return false + end + startTimer = spGetTimer() + M.deltaTime = deltaTime + + local averageTime = spGetConfigFloat("profiler_averagetime", 2) + -- The long-window average is in frames, and a frame is however long the tick or the + -- frame rate makes it. + local frames = mathMin(1 / tick, spGetFPS()) * retainSortTime + local framesMinusOne = frames - 1 + + local totalLoad, totalSpace = 0, 0 + + for name, callins in pairs(callinStats) do + local t, space = 0, 0 + local peakT, peakTName = 0, "-" + local peakS, peakSName = 0, "-" + + local detail + if name == detailName then + detail = callinAverages[name] + if not detail then + detail = {} + callinAverages[name] = detail + end + end + + for cname, c in pairs(callins) do + local c1, c2, c3, c4 = c[1], c[2], c[3], c[4] + t = t + c1 + if c2 > peakT then + peakT, peakTName = c2, cname + end + c[1] = 0 + + space = space + c3 + if c4 > peakS then + peakS, peakSName = c4, cname + end + c[3] = 0 + + if detail then + local relT = 100 * c1 / deltaTime + local relS = c3 / deltaTime + local prev = detail[cname] + if prev then + prev[1] = calcLoad(prev[1], relT, averageTime) + prev[2] = calcLoad(prev[2], relS, averageTime) + else + detail[cname] = { relT, relS } + end + end + end + + local entry = M.stats[name] + if not entry then + entry = { load = 100 * t / deltaTime, space = space / deltaTime } + entry.avg = entry.load * 0.7 + M.stats[name] = entry + end + + entry.load = calcLoad(entry.load, 100 * t / deltaTime, averageTime) + entry.space = calcLoad(entry.space, space / deltaTime, averageTime) + entry.avg = ((entry.avg * framesMinusOne) + entry.load) / frames + entry.share = t / deltaTime + entry.peakTime = peakTName + entry.peakSpace = peakSName + + totalLoad = totalLoad + entry.load + totalSpace = totalSpace + entry.space + end + + M.total.load, M.total.space = totalLoad, totalSpace + M.gen = M.gen + 1 + + local lm, _, gm, _, um, _, sm, _ = spGetLuaMemUsage() + M.mem.lua, M.mem.global, M.mem.unsynced, M.mem.shared = lm, gm, um, sm + + return true +end + +WG.widgetProfiling = M + +return M diff --git a/luaui/RmlWidgets/gui_ceg_browser/gui_ceg_browser.lua b/luaui/RmlWidgets/gui_ceg_browser/gui_ceg_browser.lua index 79089037ef3..dd66b903d9a 100644 --- a/luaui/RmlWidgets/gui_ceg_browser/gui_ceg_browser.lua +++ b/luaui/RmlWidgets/gui_ceg_browser/gui_ceg_browser.lua @@ -109,7 +109,6 @@ local hoveredCEG = "" local SCALE_PRESETS = { 0.75, 0.875, 1.0, 1.125, 1.25 } local scaleIndex = Spring.GetConfigInt("ceg_browser_scale_index", 3) scaleIndex = math.max(1, math.min(#SCALE_PRESETS, scaleIndex)) -local uiScale = SCALE_PRESETS[scaleIndex] local scaleNeedsApply = true -- apply on first Update ---------------------------------------------------------------- @@ -1252,7 +1251,6 @@ local init_model = { scaleDown = function(ev) if scaleIndex > 1 then scaleIndex = scaleIndex - 1 - uiScale = SCALE_PRESETS[scaleIndex] scaleNeedsApply = true UpdateScaleModel() end @@ -1261,7 +1259,6 @@ local init_model = { scaleUp = function(ev) if scaleIndex < #SCALE_PRESETS then scaleIndex = scaleIndex + 1 - uiScale = SCALE_PRESETS[scaleIndex] scaleNeedsApply = true UpdateScaleModel() end diff --git a/luaui/RmlWidgets/gui_decal_placer/gui_decal_placer.lua b/luaui/RmlWidgets/gui_decal_placer/gui_decal_placer.lua index 3fa1f1c0b9a..db10ca44cb5 100644 --- a/luaui/RmlWidgets/gui_decal_placer/gui_decal_placer.lua +++ b/luaui/RmlWidgets/gui_decal_placer/gui_decal_placer.lua @@ -20,7 +20,6 @@ local RML_PATH = "luaui/RmlWidgets/gui_decal_placer/gui_decal_placer.rml" local MODEL_NAME = "decal_placer_model" local WG = WG -local GetViewGeometry = Spring.GetViewGeometry local INITIAL_LEFT_VW = 60 local INITIAL_TOP_VH = 10 diff --git a/luaui/RmlWidgets/gui_diffuse_library/gui_diffuse_library.lua b/luaui/RmlWidgets/gui_diffuse_library/gui_diffuse_library.lua index b409dbbdc65..ee195cabe2a 100644 --- a/luaui/RmlWidgets/gui_diffuse_library/gui_diffuse_library.lua +++ b/luaui/RmlWidgets/gui_diffuse_library/gui_diffuse_library.lua @@ -124,10 +124,6 @@ local function thumbCachePath(matKey) return THUMB_CACHE_DIR .. "/" .. matKey .. "_" .. THUMB_SIZE .. ".png" end -local function getDpRatio() - return (WG.TerraformerShared and WG.TerraformerShared.getDpRatio and WG.TerraformerShared.getDpRatio()) or 1.0 -end - local function cleanupThumbs() thumbTextures = {} thumbImgEls = {} diff --git a/luaui/RmlWidgets/gui_feature_placer/gui_feature_placer.lua b/luaui/RmlWidgets/gui_feature_placer/gui_feature_placer.lua index 0fcd9571d63..129ef8a5ad3 100644 --- a/luaui/RmlWidgets/gui_feature_placer/gui_feature_placer.lua +++ b/luaui/RmlWidgets/gui_feature_placer/gui_feature_placer.lua @@ -20,7 +20,6 @@ local RML_PATH = "luaui/RmlWidgets/gui_feature_placer/gui_feature_placer.rml" local MODEL_NAME = "feature_placer_model" local WG = WG -local GetViewGeometry = Spring.GetViewGeometry local INITIAL_LEFT_VW = 60 local INITIAL_TOP_VH = 10 diff --git a/luaui/RmlWidgets/gui_terraform_brush/env_presets.lua b/luaui/RmlWidgets/gui_terraform_brush/env_presets.lua index d7df5060ee4..ae715a18049 100644 --- a/luaui/RmlWidgets/gui_terraform_brush/env_presets.lua +++ b/luaui/RmlWidgets/gui_terraform_brush/env_presets.lua @@ -7,7 +7,8 @@ return { { name = "Clear Daylight", source = "Altair_Crossing_V4.1", - sunDir = { 0.8000, 0.8000, -0.7000 }, + -- sunDir + sunColor hand-set to PtaQ's canonical editor sun (2026-09-03); re-apply after a harvest + sunDir = { 0.4490, 0.5645, -0.6926 }, groundShadowDensity = 0.7500, modelShadowDensity = 0.7500, groundAmbientColor = { 0.5000, 0.5000, 0.5000 }, @@ -19,7 +20,7 @@ return { fogStart = 0.8000, fogEnd = 1.0000, fogColor = { 0.8000, 0.8000, 0.5000, 1.0000 }, - sunColor = { 1.0000, 0.9200, 0.7800 }, + sunColor = { 1.0000, 1.0000, 1.0000 }, skyColor = { 0.4288, 0.5802, 0.6400 }, cloudColor = { 0.9600, 0.9600, 0.9600 }, splatTexMults = { 1.2000, 0.7000, 0.5300, 0.5000 }, diff --git a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.lua b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.lua index b263702a7d7..b152d9348fa 100644 --- a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.lua +++ b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.lua @@ -125,6 +125,13 @@ local function formatFrequency(f) end local WG = WG +-- Engine globals as chunk locals (the tf_* modules do the same): RmlUi event +-- closures can run outside the widget env where bare globals read nil, and +-- the CI analyzer counts every bare engine global as an undefined-global +-- finding. Same table objects, so Spring.X = ... still reaches every widget. +local Spring = Spring +local VFS = VFS +local gl = gl local GetViewGeometry = Spring.GetViewGeometry local GetMouseState = Spring.GetMouseState local TraceScreenRay = Spring.TraceScreenRay @@ -224,6 +231,7 @@ local windowDragAllWindows = {} widgetState = { -- forward-declared above playSound so mute check works rmlContext = nil, document = nil, + ---@type table? dmHandle = nil, rootElement = nil, modeButtons = {}, @@ -326,6 +334,11 @@ widgetState = { -- forward-declared above playSound so mute check works -- Passthrough mode: deactivate all tools but keep panel visible passthroughMode = false, passthroughSaved = nil, -- {tool=string, mode=string|nil} + -- Focus mode (game interface hidden, editor left alive): focusMode and + -- focusSetTimer are assigned by setFocusMode and deliberately NOT initialised + -- here. The analyzer takes a false/nil literal in this constructor as the + -- field's only value and flags every guard on it (the passthroughMode ones + -- above are all in the baseline for that reason). -- Settings window settingsRootEl = nil, settingsOpen = false, @@ -347,10 +360,13 @@ widgetState = { -- forward-declared above playSound so mute check works projectDeleteConfirmExpiry = 0, projectOpenRowEls = {}, -- {{slug = ..., el = ...}, ...} for selection painting projectOpenNeedsRebuild = false, -- set by a delete, consumed in Update + projectOpenFilter = "", -- search box text (lowercased substring match on name/path/size) + projectOpenSort = "recent", -- "recent" (last touched) | "name" | "size" + projectOpenCollapsed = {}, -- folder path -> true while its tree node is folded -- Auto-scroll transport state (per-slider, keyed by slider element id) transports = {}, -- Currently focused RmlUI input element (text/number boxes); cleared on blur. - -- Used to auto-blur when game chat is opened, so chat keys aren't stolen by RmlUI. + -- Used to auto-blur when game chat is opened, so Tab autocomplete isn't stolen by RmlUI. focusedRmlInput = nil, -- Module-shared mutable state noiseManuallyHidden = false, @@ -378,6 +394,8 @@ widgetState = { -- forward-declared above playSound so mute check works seenLightsTypeHint = false, seenCloneLayersHint = false, seenSceneSkyboxHint = false, + perfMode = false, -- Settings > Performance + clayStack = false, -- Settings > Stroke > Clay build-up (legacy per-tick stacking) heightmapExportRangeMode = "auto", heightmapExportCustomMin = 0, heightmapExportCustomMax = 1, @@ -398,6 +416,28 @@ widgetState = { -- forward-declared above playSound so mute check works -- first call, then serves subsequent calls from widgetState.elCache. Caches -- are invalidated in widget:Shutdown when the document closes. `nil` lookups -- are NOT cached (so late-loaded elements can be found on subsequent frames). +-- Give an RmlUi text field the keyboard. Without this the game eats every +-- keystroke and the field never types: SDL text input has to be started while +-- the field has focus, and WG.TerraformBrushInputFocused is what tells the tool +-- widgets to stand their single-letter hotkeys down. Every +-- in the panel must go through here -- the search boxes shipped without it and +-- were simply dead (reported by Moose, 2026-09-04). +widgetState.wireTextInput = function(el) + if not el then + return + end + el:AddEventListener("focus", function(_e) + WG.TerraformBrushInputFocused = true + Spring.SDLStartTextInput() + widgetState.focusedRmlInput = el + end, false) + el:AddEventListener("blur", function(_e) + WG.TerraformBrushInputFocused = false + Spring.SDLStopTextInput() + widgetState.focusedRmlInput = nil + end, false) +end + local function getCachedEl(doc, id) local cache = widgetState.elCache local el = cache[id] @@ -460,6 +500,12 @@ function loadUiPrefs() if type(data.disableTips) == "boolean" then widgetState.uiPrefs.disableTips = data.disableTips end + if type(data.perfMode) == "boolean" then + widgetState.uiPrefs.perfMode = data.perfMode + end + if type(data.clayStack) == "boolean" then + widgetState.uiPrefs.clayStack = data.clayStack + end if type(data.seenInstrumentsHint) == "boolean" then widgetState.uiPrefs.seenInstrumentsHint = data.seenInstrumentsHint end @@ -533,7 +579,7 @@ function saveUiPrefs() end f:write( string.format( - "return {\n\tdisableTips = %s,\n\tseenInstrumentsHint = %s,\n\tseenSplatDisplayHint = %s,\n\tseenStartposShapeHint = %s,\n\tseenMetalStampHint = %s,\n\tseenMetalMapHint = %s,\n\tseenFeaturesFiltersHint = %s,\n\tseenGrassColorFilterHint = %s,\n\tseenSplatFiltersHint = %s,\n\tseenWeatherPersistHint = %s,\n\tseenLightsTypeHint = %s,\n\tseenCloneLayersHint = %s,\n\tseenSceneSkyboxHint = %s,\n\theightmapExportRangeMode = %q,\n\theightmapExportCustomMin = %.6f,\n\theightmapExportCustomMax = %.6f,\n\twindowPositions = {\n", + "return {\n\tdisableTips = %s,\n\tseenInstrumentsHint = %s,\n\tseenSplatDisplayHint = %s,\n\tseenStartposShapeHint = %s,\n\tseenMetalStampHint = %s,\n\tseenMetalMapHint = %s,\n\tseenFeaturesFiltersHint = %s,\n\tseenGrassColorFilterHint = %s,\n\tseenSplatFiltersHint = %s,\n\tseenWeatherPersistHint = %s,\n\tseenLightsTypeHint = %s,\n\tseenCloneLayersHint = %s,\n\tseenSceneSkyboxHint = %s,\n\tperfMode = %s,\n\tclayStack = %s,\n\theightmapExportRangeMode = %q,\n\theightmapExportCustomMin = %.6f,\n\theightmapExportCustomMax = %.6f,\n\twindowPositions = {\n", tostring(widgetState.uiPrefs.disableTips and true or false), tostring(widgetState.uiPrefs.seenInstrumentsHint and true or false), tostring(widgetState.uiPrefs.seenSplatDisplayHint and true or false), @@ -547,6 +593,8 @@ function saveUiPrefs() tostring(widgetState.uiPrefs.seenLightsTypeHint and true or false), tostring(widgetState.uiPrefs.seenCloneLayersHint and true or false), tostring(widgetState.uiPrefs.seenSceneSkyboxHint and true or false), + tostring(widgetState.uiPrefs.perfMode and true or false), + tostring(widgetState.uiPrefs.clayStack and true or false), widgetState.uiPrefs.heightmapExportRangeMode or "auto", tonumber(widgetState.uiPrefs.heightmapExportCustomMin) or 0, tonumber(widgetState.uiPrefs.heightmapExportCustomMax) or 1 @@ -568,6 +616,53 @@ end widgetState.saveUiPrefs = saveUiPrefs +-- Settings > Performance and Stroke > Clay build-up live in ui_prefs and in +-- the brush widget: mirror the prefs into the data model and push them to +-- the widget. Idempotent; called on toggle, after the prefs load, and once +-- from Update if the widget shows up after this panel (load order is not +-- fixed between LuaUI widget folders). +widgetState.pushPerfPrefs = function() + local up = widgetState.uiPrefs or {} + local perf = up.perfMode and true or false + local stack = up.clayStack and true or false + local d = widgetState.dmHandle + if d then + if d.perfModeActive ~= perf then + d.perfModeActive = perf + d.perfModeStr = perf and "ON" or "OFF" + end + if d.clayStackActive ~= stack then + d.clayStackActive = stack + d.clayStackStr = stack and "ON" or "OFF" + end + end + widgetState.perfMode = perf + ---@type table? + local tb = WG.TerraformBrush + if tb and tb.setPerfMode then + tb.setPerfMode(perf) + tb.setClayStack(stack) + widgetState.perfPrefsPushed = true + else + widgetState.perfPrefsPushed = false + end +end + +-- The terraform mirror in Update (900 lines of per-frame readout, slider +-- and class syncing that dirties RmlUi) is not being read while the brush +-- is down on the world: stride it to every 4th draw frame during a sculpt +-- drag, and always under performance mode. A hover over the panel ends the +-- stride so its controls answer at frame rate. +widgetState.mirrorStrided = function(tfState) + if not (tfState.dragging or widgetState.perfMode) then + return false + end + if widgetState.mouseOverPanel then + return false + end + return Spring.GetDrawFrame() % 4 ~= 0 +end + function widgetState.restoreWindowPosition(rootId, rootEl) local pos = widgetState.uiPrefs.windowPositions[rootId] if not pos or not rootEl then @@ -765,11 +860,6 @@ local function quatRotateVec(qx, qy, qz, qw, vx, vy, vz) return vx + qw * tx + (qy * tz - qz * ty), vy + qw * ty + (qz * tx - qx * tz), vz + qw * tz + (qx * ty - qy * tx) end --- Quaternion inverse (conjugate for unit quaternions) -local function quatInv(qx, qy, qz, qw) - return -qx, -qy, -qz, qw -end - local function tickSkyDynamic(dt) if not skyDynamic.playing then return @@ -819,6 +909,9 @@ local function tickSkyDynamic(dt) setSlLb(skyDynamic.sunSliderY, skyDynamic.sunLabelY, sy) setSlLb(skyDynamic.sunSliderZ, skyDynamic.sunLabelZ, sz) uiState.updatingFromCode = false + if widgetState.refreshEnvSunAzEl then + widgetState.refreshEnvSunAzEl() + end end end end @@ -912,7 +1005,9 @@ local function applySkybox(texturePath) -- Spring.SetSkyBoxTexture looks up by CNamedTextures, which requires the path -- to be registered via gl.Texture first. gl.Texture can only be called from -- Draw call-ins. RmlUI click handlers fire from Update, so we defer: store the - -- path in a pending field and do gl.Texture + SetSkyBoxTexture in DrawScreen. + -- path in a pending field and do gl.Texture + SetSkyBoxTexture in the + -- DrawScreenPost drain (drainDeferredApplies; DrawScreen is skipped while the + -- interface is hidden, and FOCUS MODE hides it on purpose). widgetState._pendingSkyboxPath = normalized end widgetState.applySkybox = applySkybox @@ -923,23 +1018,26 @@ widgetState.applySkybox = applySkybox -- vary (SpaceSkybox1/2/3, EarthSkybox1/2/3, ...). namaqualand -> red desert planet -- is our pick (user specified only bismuth/teizer/enborelde). local IS_BAR = (Game.gameName or ""):find("Beyond All Reason") ~= nil -local BIOME_SKYBOX_MATCH = { - bismuth = "spaceskybox", -- starry sky - teizer = "goldsunrise", -- sunset (bespoke desert kept the old pick) - protodesert = "goldsunrise", -- the renamed original Teizer stand-in set - enborelde = "earthskybox", -- sunny blue sky with clouds (bespoke earthlike kept the old pick) - prototemperate = "earthskybox", -- the renamed original Enborelde stand-in set - namaqualand = "redplanet", -- red desert planet - palehang = "allthatglitters", -- crystal-desert sky (Theta Crystals family) -} +-- The fragment per biome comes from its manifest (tileset_dev/tilesets/.lua, +-- field `skybox`), read through WG.TilesetTerrain.getBiomes(); nothing is +-- hardcoded here any more, so a new biome brings its own sky. -- Resolve a biome key to a full DDS path in the skybox library, or nil if unmapped / -- the matching file is absent. Deterministic: lowest-sorted name wins (so *1 variants). local function resolveBiomeSkybox(biomeKey) - local frag = BIOME_SKYBOX_MATCH[biomeKey] - if not frag then + local frag + local T = WG.TilesetTerrain + local rows = T and T.getBiomes and T.getBiomes() + for _, b in ipairs(rows or {}) do + if b.key == biomeKey then + frag = b.skybox + break + end + end + if not frag or frag == "" then return nil end + frag = tostring(frag):lower() local files = VFS.DirList("Terraform Brush/SkyBoxes/", "*.dds", VFS.RAW_FIRST) or {} table.sort(files) for _, fp in ipairs(files) do @@ -967,6 +1065,27 @@ local function syncSkyboxToBiome(biomeKey) end end +-- Pick a biome: shared by the data-model onPickBiome and the BIOME LIBRARY +-- tiles tf_tileset.lua builds at runtime from the manifests. On widgetState, +-- not a local: the main chunk sits near Lua 5.1's 200-local ceiling. +widgetState.pickBiome = function(key) + if not (WG.TilesetTerrain and WG.TilesetTerrain.setBiome) then + return false + end + local ok = WG.TilesetTerrain.setBiome(key) + if ok then + playSound("click") + local dm = widgetState.dmHandle + if dm then + dm.tsBiome = key + end + -- Each biome is a planet: swap the skybox to match (no-op unless BAR + + -- toggle on, or when the manifest names no sky). + syncSkyboxToBiome(key) + end + return ok +end + local function tickSkyboxFade(dt) if not skyFade.active then return @@ -1081,6 +1200,44 @@ function widgetState.pushPanelClip(el) return false end +-- The FILE dropdown must stay on top of everything, but the GL thumbnail +-- passes run in DrawScreenPost, after RmlUi has rendered, so an open menu +-- would be painted over (reported by Moose for the SURFACE tiles; the same +-- held for every tile grid). Measured once per frame into widgetState.fmBox; +-- every pass skips tiles that touch it. Element coords, y down. Gated on the +-- data model, not the element box: an element that is not laid out can still +-- report a stale non-zero box (the panel-down lesson above). +function widgetState.measureFileMenuBox() + widgetState.fmBoxX = nil + local dm = widgetState.dmHandle + if not (dm and dm.fileMenuOpen) then + return + end + local doc = widgetState.document + local menu = doc and doc:GetElementById("tf-file-menu") + if not menu then + return + end + local w, h = menu.offset_width, menu.offset_height + if w and h and w > 0 and h > 0 then + widgetState.fmBoxX = menu.absolute_left + widgetState.fmBoxY = menu.absolute_top + widgetState.fmBoxW = w + widgetState.fmBoxH = h + end +end +function widgetState.underFileMenu(x, y, w, h) + local bx = widgetState.fmBoxX + if not bx then + return false + end + -- set together with fmBoxX; the or-defaults are for the analyzer + local by = widgetState.fmBoxY or 0 + local bw = widgetState.fmBoxW or 0 + local bh = widgetState.fmBoxH or 0 + return x < bx + bw and x + w > bx and y < by + bh and y + h > by +end + -- Forward declaration: clearPassthrough is defined after initialModel but captured as upvalue -- by onCl* (and any future) model-king handlers inside initialModel. local clearPassthrough @@ -1309,6 +1466,33 @@ local function _tbFindAnglePresetIdx(val) end return best end +-- FOLLOW STROKE applies to the terrain sculpt drag only: the other tools in the +-- SHAPE row (metal, grass, features, splat) stamp rather than stroke, and ramp / +-- noise / autoramp / restore / erode own their own sampling. +local _tbFollowModes = { raise = true, lower = true, level = true, smooth = true, smudge = true } +-- PASSABILITY overlay (MrBob's F6 check without a selected unit): the tileset +-- shader tints everything steeper than the class's max slope in the engine's +-- impassable purple, so a cliff can be judged while sculpting. Degrees are read +-- off a representative unit's movedef so the band matches what F6 draws; the +-- literals are gamedata/movedefs.lua's own SLOPE values as a fallback. +local _tbPassClasses = { + { key = "BOT", unit = "armpw", deg = 54 }, + { key = "VEH", unit = "armflash", deg = 27 }, + { key = "HOVER", unit = "corch", deg = 33 }, + { key = "AMPH", unit = "coramph", deg = 54 }, +} +local _tbPassIdx = 0 -- 0 = off +local function _tbPassDeg(entry) + ---@diagnostic disable-next-line: undefined-global + local ud = UnitDefNames and UnitDefNames[entry.unit] + local ms = ud and ud.moveDef and ud.moveDef.maxSlope + -- movedef maxSlope is stored as 1 - cos(angle), same space as + -- Spring.GetGroundNormal's fourth return. + if ms and ms > 0 and ms < 2 then + return math.deg(math.acos(1 - ms)) + end + return entry.deg +end local function _tbMirrorToggle(P, stateKey, setter, dmKey) if not WG.TerraformBrush then return @@ -1321,6 +1505,204 @@ local function _tbMirrorToggle(P, stateKey, setter, dmKey) end playSound("tick") end +-- ── IMAGE overlay (DISPLAY > Image) ────────────────────────────────────────── +-- One overlay shared by every tool's DISPLAY row (WG.TerraformImageOverlay, +-- cmd_terraform_image_overlay.lua). The chips toggle it or open the single +-- IMAGE OVERLAY floating window (tf-imgov-root); the helpers sit on one table +-- to stay clear of the main chunk's local budget. +local _imgOv = {} +-- { slider id suffix, state -> slider value, slider value -> overlay setter } +_imgOv.SLIDERS = { + { + "opacity", + function(s) + return (s.opacity or 0) * 100 + end, + function(v, IO) + IO.setOpacity(v / 100) + end, + }, + { + "offx", + function(s) + return (s.offsetX or 0) * 100 + end, + function(v, IO) + IO.setOffset(v / 100, nil) + end, + }, + { + "offy", + function(s) + return (s.offsetZ or 0) * 100 + end, + function(v, IO) + IO.setOffset(nil, v / 100) + end, + }, + { + "scale", + function(s) + return (s.scale or 1) * 100 + end, + function(v, IO) + IO.setScale(v / 100) + end, + }, +} +function _imgOv.active() + ---@type table? + local IO = WG.TerraformImageOverlay + return (IO and IO.isEnabled()) or false +end +function _imgOv.esc(s) + return (tostring(s):gsub("&", "&"):gsub("<", "<"):gsub(">", ">")) +end +-- Rebuild the file rows in the window (same row markup as the feature-map list). +function _imgOv.rebuildList(rescan) + local doc = widgetState.document + if not doc then + return + end + local listEl = doc:GetElementById("imgov-list") + if not listEl then + return + end + ---@type table? + local IO = WG.TerraformImageOverlay + listEl.inner_rml = "" + if not IO then + listEl.inner_rml = '
Image Overlay widget is not loaded (Settings > Widgets).
' + return + end + local files = IO.list(rescan) or {} + if #files == 0 then + listEl.inner_rml = '
No images in ' + .. _imgOv.esc(IO.getDir()) + .. " yet. Drop some in and hit Rescan folder.
" + return + end + local current = (IO.getState() or {}).file + for _, name in ipairs(files) do + local item = doc:CreateElement("div") + item:SetClass("tf-hm-row", true) + if name == current then + item:SetClass("imgov-current", true) + end + item.inner_rml = '
' .. _imgOv.esc(name) .. "
" + item:AddEventListener("click", function(ev) + ---@type table? + local api = WG.TerraformImageOverlay + if api then + local ok = api.select(name) + playSound(ok and "apply" or "toggleOff") + end + _imgOv.rebuildList(false) + ev:StopPropagation() + end, false) + listEl:AppendChild(item) + end +end +-- Push the overlay placement into the window sliders, skipping the one being +-- dragged (the drag ids come from the SNAP_SLIDERS registration). +function _imgOv.stamp(force) + local doc = widgetState.document + ---@type table? + local IO = WG.TerraformImageOverlay + if not doc or not IO then + return + end + local s = IO.getState() or {} + local cache = widgetState.imgOvLastVal + if not cache then + cache = {} + widgetState.imgOvLastVal = cache + end + local ds = uiState.draggingSlider + local stamped = false + uiState.updatingFromCode = true + for _, row in ipairs(_imgOv.SLIDERS) do + local id = "imgov-slider-" .. row[1] + if ds ~= ("imgov-" .. row[1]) then + local str = tostring(math.floor(row[2](s) + 0.5)) + if force or cache[id] ~= str then + cache[id] = str + local sl = doc:GetElementById(id) + if sl then + sl:SetAttribute("value", str) + stamped = true + end + local nb = doc:GetElementById(id .. "-numbox") + if nb then + nb:SetAttribute("value", str .. "%") + end + end + end + end + uiState.updatingFromCode = false + -- The change events these stamps raise land on a later frame (see + -- onTilesetKnob); onImgOvSlider drops them by this timestamp. + if stamped then + uiState.imgOvStampFrame = Spring.GetDrawFrame() + end +end +-- Numbox readout next to one placement slider ("37%"). +function _imgOv.setNumbox(key, str) + local doc = widgetState.document + if not doc or not key then + return + end + local nb = doc:GetElementById("imgov-slider-" .. key .. "-numbox") + if nb then + nb:SetAttribute("value", str .. "%") + end +end +function _imgOv.setWindow(open) + local dm = widgetState.dmHandle + if dm then + dm.imgOvVisible = open and true or false + end + if open then + _imgOv.rebuildList(true) + _imgOv.stamp(true) + end +end +-- Flip the overlay on/off; false when nothing is loaded yet. +function _imgOv.toggleShow() + ---@type table? + local IO = WG.TerraformImageOverlay + if not IO or not IO.hasImage() then + return false + end + local nv = not IO.isEnabled() + IO.setEnabled(nv) + local dm = widgetState.dmHandle + if dm then + dm.tbImgActive = nv + end + playSound(nv and "toggleOn" or "toggleOff") + return true +end +-- Per frame: chip state for every DISPLAY row, window readouts while it is up. +function _imgOv.sync(setDm) + ---@type table? + local IO = WG.TerraformImageOverlay + local s = IO and IO.getState() or nil + setDm("tbImgActive", (s and s.enabled and s.hasImage) or false) + local dm = widgetState.dmHandle + if not (dm and dm.imgOvVisible) then + return + end + setDm("imgOvHasImage", (s and s.hasImage) or false) + setDm("imgOvFileStr", (s and s.file) or "none") + setDm("imgOvSizeStr", (s and s.hasImage) and (tostring(s.width) .. " x " .. tostring(s.height) .. " px") or "") + setDm("imgOvError", (s and s.error) or (IO and "" or "Image Overlay widget is not loaded")) + setDm("imgOvFit", (s and s.fit) or "stretch") + setDm("imgOvFlipH", (s and s.flipH) or false) + setDm("imgOvFlipV", (s and s.flipV) or false) + setDm("imgOvSupported", not (s and s.supported == false)) + _imgOv.stamp(false) +end local function _deactivateAllTools() if WG.TerraformBrush then WG.TerraformBrush.deactivate() @@ -1524,9 +1906,11 @@ end -- block in sync with tools/mapgen/scan_environments.py / env_presets.lua. widgetState.newMapEnvPresets = { { + -- sunDir + sunColor = PtaQ's canonical editor sun (2026-09-03), see + -- envSunPresets[1]; the rest is the harvested Altair Crossing mood. name = "Clear Daylight", source = "Altair_Crossing_V4.1", - sunDir = { 0.8000, 0.8000, -0.7000 }, + sunDir = { 0.4490, 0.5645, -0.6926 }, groundShadowDensity = 0.7500, modelShadowDensity = 0.7500, groundAmbientColor = { 0.5000, 0.5000, 0.5000 }, @@ -1538,7 +1922,7 @@ widgetState.newMapEnvPresets = { fogStart = 0.8000, fogEnd = 1.0000, fogColor = { 0.8000, 0.8000, 0.5000, 1.0000 }, - sunColor = { 1.0000, 0.9200, 0.7800 }, + sunColor = { 1.0000, 1.0000, 1.0000 }, skyColor = { 0.4288, 0.5802, 0.6400 }, cloudColor = { 0.9600, 0.9600, 0.9600 }, splatTexMults = { 1.2000, 0.7000, 0.5300, 0.5000 }, @@ -1909,27 +2293,19 @@ do end Spring.Echo("[Terraform Brush] Environment presets: " .. #widgetState.newMapEnvPresets) end --- Environment a fresh map starts with. 0 = Default (keep engine defaults), 1..N --- = preset. This USED to default to 0, but "engine defaults" is placeholder --- lighting: ground ambient and diffuse both a flat 0.5, against ~0.99 diffuse on --- a real BAR daylight map, so a new map receives roughly 60% of the light one --- should. A baked map texture carries the mapper's own brightness and hides that; --- the tileset shader draws raw PBR albedo and cannot, so new maps read as though --- the SHADER were broken (diagnosed 2026-08-12 — /tileset probe reported --- flat-lit 0.596 against ~0.91 for the preset below). Start from a real harvested --- mood instead; Default stays selectable in the wizard. --- Resolved by NAME, not index: a regenerated env_presets.lua replaces this list --- wholesale and can reorder it, and silently defaulting to whatever landed in --- slot 1 would be worse than the engine defaults we are replacing. +-- Environment a fresh map starts with. 0 = Default, 1..N = a harvested mood. +-- Default does NOT mean "leave the engine lighting alone": the engine's is +-- placeholder lighting, ground ambient and diffuse both a flat 0.5 against ~0.99 +-- diffuse on a real BAR daylight map, so a new map receives roughly 60% of the +-- light it should. A baked map texture carries the mapper's own brightness and +-- hides that; the tileset shader draws raw PBR albedo and cannot, so new maps read +-- as though the SHADER were broken (diagnosed 2026-08-12 — /tileset probe +-- reported flat-lit 0.596 against ~0.91 for a real daylight mood). So Default +-- applies the canonical sun instead (widgetState.newMapDefaultEnv below): the +-- wizard opens on "Default" and a fresh map is still properly lit. The harvested +-- moods stay in the picker for anyone who wants one, and picking a mood brings its +-- water, fog and sky too, which Default deliberately leaves alone. widgetState.newMapEnvIdx = 0 -do - for i, p in ipairs(widgetState.newMapEnvPresets) do - if p.name == "Clear Daylight" then - widgetState.newMapEnvIdx = i - break - end - end -end -- Push the selected environment name into the data-model label. widgetState._nmRefreshEnvLabel = function() @@ -1956,6 +2332,44 @@ widgetState.disableFog = function() Spring.SetAtmosphere({ fogStart = FOG_OFF.fogStart, fogEnd = FOG_OFF.fogEnd }) end +-- Push the live sun state into the ENV panel sliders. The sliders seed only once +-- at document attach, so an environment applied afterwards (project load) must +-- refresh them — otherwise the next nudge on any sun slider writes its stale +-- attach-time value back through Spring.SetSunDirection. +widgetState.refreshEnvSunSliders = function() + local sx, sy, sz = gl.GetSun("pos") + if not sx then + return + end + uiState.updatingFromCode = true + _envSetSlider("slider-env-sun-x", "lbl-env-sun-x", math.floor(sx * 10000 + 0.5), string.format("%.2f", sx)) + _envSetSlider("slider-env-sun-y", "lbl-env-sun-y", math.floor(sy * 10000 + 0.5), string.format("%.2f", sy)) + _envSetSlider("slider-env-sun-z", "lbl-env-sun-z", math.floor(sz * 10000 + 0.5), string.format("%.2f", sz)) + local si = widgetState.envSunIntensity or 1.0 + _envSetSlider( + "slider-env-sun-intensity", + "lbl-env-sun-intensity", + math.floor(si * 1000 + 0.5), + string.format("%.2f", si) + ) + uiState.updatingFromCode = false +end + +-- Same for the AZIMUTH / ELEVATION pair. Kept separate from the XYZ refresh so a +-- drag on either pair only restamps the other (restamping the slider under the +-- pointer fights the drag). +widgetState.refreshEnvSunAzEl = function() + local sx, sy, sz = gl.GetSun("pos") + if not sx then + return + end + local az, el = widgetState.azElFromSunDir(sx, sy, sz) + uiState.updatingFromCode = true + _envSetSlider("slider-env-sun-az", "lbl-env-sun-az", math.floor(az * 10 + 0.5), string.format("%.1f", az)) + _envSetSlider("slider-env-sun-el", "lbl-env-sun-el", math.floor(el * 10 + 0.5), string.format("%.1f", el)) + uiState.updatingFromCode = false +end + -- Apply a full environment config table (schema = env_presets.lua / onEnvSave) to -- the live engine. Mirrors onEnvLoad's apply body so the env editor and the New -- Map preset path drive the engine identically. Every field is optional. @@ -1964,9 +2378,20 @@ widgetState.applyEnvConfig = function(d) return end if d.sunDir then - local intensity = d.sunIntensity or 1.0 - Spring.SetSunDirection(d.sunDir[1] or 0, d.sunDir[2] or 1, d.sunDir[3] or 0, intensity) - widgetState.envSunIntensity = intensity + local sdx, sdy, sdz = d.sunDir[1] or 0, d.sunDir[2] or 0, d.sunDir[3] or 0 + -- A config saved while gl.GetSun returned nothing carries {0,0,0}: applying + -- it would black out the map, so a degenerate direction is ignored. + if sdx * sdx + sdy * sdy + sdz * sdz > 1e-6 then + -- A config without an intensity (the harvested map moods have none) + -- keeps the session's; only an explicit value changes it. + local intensity = d.sunIntensity or widgetState.envSunIntensity or 1.0 + Spring.SetSunDirection(sdx, sdy, sdz, intensity) + widgetState.envSunIntensity = intensity + widgetState.refreshEnvSunSliders() + if widgetState.refreshEnvSunAzEl then + widgetState.refreshEnvSunAzEl() + end + end end local shadowParams = {} if d.groundShadowDensity then @@ -2000,6 +2425,17 @@ widgetState.applyEnvConfig = function(d) if next(lightParams) then Spring.SetSunLighting(lightParams) Spring.SendCommands("luarules updatesun") + -- A skybox fade in flight scales the six sun colours from its captured + -- originals and restores those at the end, which would overwrite what + -- was just applied: retarget the fade at the new colours instead. + if skyFade.active then + skyFade.origGroundAmbient = lightParams.groundAmbientColor or skyFade.origGroundAmbient + skyFade.origGroundDiffuse = lightParams.groundDiffuseColor or skyFade.origGroundDiffuse + skyFade.origGroundSpecular = lightParams.groundSpecularColor or skyFade.origGroundSpecular + skyFade.origUnitAmbient = lightParams.unitAmbientColor or skyFade.origUnitAmbient + skyFade.origUnitDiffuse = lightParams.unitDiffuseColor or skyFade.origUnitDiffuse + skyFade.origUnitSpecular = lightParams.unitSpecularColor or skyFade.origUnitSpecular + end end local atmosParams = {} -- Env-preset fog intentionally NOT applied (placeholder + obscuring): force it off. @@ -2065,6 +2501,248 @@ widgetState.applyEnvConfig = function(d) Spring.SetWaterParams(wcParams) Spring.SendCommands("water 4") end + -- Skybox: the engine has no getter for the active skybox, so the config + -- carries the library path the user picked (applySkybox re-validates it). + if type(d.skybox) == "string" and d.skybox ~= "" and widgetState.applySkybox then + widgetState.applySkybox(d.skybox) + widgetState.envCurrentSkybox = d.skybox + for _, t in ipairs(widgetState.envSkyboxThumbs or {}) do + t.element:SetClass("active", t.path == d.skybox) + end + end + -- The ENV panel's RESET buttons return to "the defaults": after a project + -- or preset apply those are the applied values, not whatever the engine + -- held when the panel first opened (often the flat blank-map lighting). + if widgetState.captureEnvDefaults then + widgetState.captureEnvDefaults() + end +end + +-- Sun direction <-> azimuth/elevation (degrees). Azimuth is compass-like on the +-- map: 0 = north (toward -Z, the top of the minimap), 90 = east (+X). +-- Elevation is the angle above the horizon. sunDir points AT the sun. +widgetState.sunDirFromAzEl = function(azDeg, elDeg) + local az, el = math.rad(azDeg or 0), math.rad(math.max(0.5, math.min(89.5, elDeg or 45))) + local c = math.cos(el) + return c * math.sin(az), math.sin(el), -c * math.cos(az) +end +widgetState.azElFromSunDir = function(x, y, z) + local len = math.sqrt((x or 0) ^ 2 + (y or 0) ^ 2 + (z or 0) ^ 2) + if len < 1e-6 then + return 0, 45 + end + local el = math.deg(math.asin(math.max(-1, math.min(1, (y or 0) / len)))) + local az = math.deg(math.atan2(x or 0, -(z or 0))) + if az < 0 then + az = az + 360 + end + return az, el +end + +-- Sun-only quick presets for the ENV panel: azimuth, elevation, intensity, the +-- six sun colours, the sun tint and both shadow densities. They never touch +-- water, fog or sky, so they are safe on any map. Three on purpose (PtaQ, +-- 2026-09-03): the canonical sun, a low warm one and a flat one. +widgetState.envSunPresets = { + { + -- PtaQ's canonical editor sun (Terraform Brush/Environments/Canonical sun.lua, + -- 2026-09-03): the default here and the New Map wizard's Clear Daylight sun. + name = "Canonical", + az = 33, + el = 34.4, + sunIntensity = 1.0, + groundAmbientColor = { 0.5, 0.5, 0.5 }, + groundDiffuseColor = { 0.99, 0.99, 0.95 }, + groundSpecularColor = { 0.7, 0.7, 0.7 }, + unitAmbientColor = { 0.56, 0.56, 0.6 }, + unitDiffuseColor = { 0.95, 0.955, 0.9 }, + unitSpecularColor = { 0.8, 0.6, 0.6 }, + sunColor = { 1.0, 1.0, 1.0 }, + groundShadowDensity = 0.75, + modelShadowDensity = 0.75, + }, + { + name = "Dusk", + az = 272, + el = 10, + sunIntensity = 0.85, + groundAmbientColor = { 0.4, 0.36, 0.46 }, + groundDiffuseColor = { 1.0, 0.66, 0.45 }, + groundSpecularColor = { 0.6, 0.45, 0.4 }, + unitAmbientColor = { 0.46, 0.42, 0.52 }, + unitDiffuseColor = { 1.0, 0.72, 0.52 }, + unitSpecularColor = { 0.8, 0.55, 0.45 }, + sunColor = { 1.0, 0.62, 0.36 }, + groundShadowDensity = 0.55, + modelShadowDensity = 0.55, + }, + { + name = "Overcast", + az = 180, + el = 58, + sunIntensity = 0.75, + groundAmbientColor = { 0.62, 0.63, 0.66 }, + groundDiffuseColor = { 0.72, 0.74, 0.77 }, + groundSpecularColor = { 0.4, 0.4, 0.42 }, + unitAmbientColor = { 0.64, 0.65, 0.68 }, + unitDiffuseColor = { 0.75, 0.77, 0.8 }, + unitSpecularColor = { 0.5, 0.5, 0.52 }, + sunColor = { 0.85, 0.87, 0.9 }, + groundShadowDensity = 0.35, + modelShadowDensity = 0.35, + }, +} + +-- The ENV panel's preset catalog: harvested map moods (the New Map wizard's +-- list), the user's own files in Terraform Brush/Environments/ (SAVE in the +-- panel; legacy Lightmaps/*_environ_*.lua saves are listed too), and the +-- sun-only quick presets above. Each entry = { name, kind, data | path }. +-- (Fields on widgetState, not chunk locals: the main chunk is near the Lua 5.1 +-- 200-local ceiling.) +widgetState.envPresetDir = "Terraform Brush/Environments/" +widgetState.listEnvPresets = function() + local ENV_PRESET_DIR = widgetState.envPresetDir + local out = {} + for _, p in ipairs(widgetState.envSunPresets) do + out[#out + 1] = { name = p.name, kind = "sun", data = p } + end + for _, p in ipairs(widgetState.newMapEnvPresets or {}) do + out[#out + 1] = { name = p.name, kind = "mood", data = p } + end + local user = {} + for _, f in ipairs(VFS.DirList(ENV_PRESET_DIR, "*.lua", VFS.RAW) or {}) do + local base = (f:match("([^/\\]+)%.lua$") or f) + user[#user + 1] = { name = base, kind = "user", path = f } + end + for _, f in ipairs(VFS.DirList("Terraform Brush/Lightmaps/", "*_environ_*.lua", VFS.RAW) or {}) do + local base = (f:match("([^/\\]+)%.lua$") or f) + user[#user + 1] = { name = base, kind = "user", path = f } + end + table.sort(user, function(a, b) + return a.name:lower() < b.name:lower() + end) + for _, u in ipairs(user) do + out[#out + 1] = u + end + return out +end + +-- Resolve an entry's config table (files load on demand, BOM-stripped: Recoil +-- runs stock Lua 5.1 and loadstring chokes on a UTF-8 BOM). +widgetState.loadEnvPresetData = function(entry) + if entry.data then + return entry.data + end + local raw = entry.path and VFS.LoadFile(entry.path, VFS.RAW) + if not raw or raw == "" then + return nil, "could not read " .. tostring(entry.path) + end + raw = raw:gsub("^\239\187\191", "") + local chunk = loadstring(raw) + if not chunk then + return nil, "parse failed for " .. tostring(entry.path) + end + local ok, d = pcall(chunk) + if not ok or type(d) ~= "table" then + return nil, "invalid data in " .. tostring(entry.path) + end + return d +end + +-- Apply a preset with the panel's scope. "sun" takes only the sun keys (a +-- sun-only preset has nothing else anyway); "full" hands the whole table to +-- applyEnvConfig. A sun-only preset's az/el become a sunDir first. +widgetState.envSunKeys = { + "sunDir", + "sunIntensity", + "groundShadowDensity", + "modelShadowDensity", + "groundAmbientColor", + "groundDiffuseColor", + "groundSpecularColor", + "unitAmbientColor", + "unitDiffuseColor", + "unitSpecularColor", + "sunColor", +} +widgetState.applyEnvPreset = function(entry, scope) + local d, err = widgetState.loadEnvPresetData(entry) + if not d then + Spring.Echo("[Environ] preset '" .. tostring(entry.name) .. "': " .. tostring(err)) + return false + end + if d.az and d.el and not d.sunDir then + local x, y, z = widgetState.sunDirFromAzEl(d.az, d.el) + local copy = {} + for k, v in pairs(d) do + copy[k] = v + end + copy.sunDir = { x, y, z } + d = copy + end + if scope == "sun" or entry.kind == "sun" then + local subset = {} + for _, k in ipairs(widgetState.envSunKeys) do + subset[k] = d[k] + end + d = subset + end + widgetState.applyEnvConfig(d) + widgetState.envPresetCurrent = entry.name + return true +end + +-- SAVE in the panel: the full live environment (buildEnvConfigContent) under a +-- user-chosen name, so it lists in every session and on every map. +widgetState.saveEnvPreset = function(name) + local trimmed = tostring(name or ""):match("^%s*(.-)%s*$") or "" + name = trimmed:gsub("[^%w_%- ]", "_") + if name == "" then + return false, "type a preset name first" + end + Spring.CreateDir(widgetState.envPresetDir) + local path = widgetState.envPresetDir .. name .. ".lua" + local f = io.open(path, "w") + if not f then + return false, "could not write " .. path + end + f:write(widgetState.buildEnvConfigContent()) + f:close() + Spring.Echo("[Environ] saved environment preset: " .. path) + return true, path +end + +-- /tf_sunlog: log every sun write (direction and lighting) with a traceback, so +-- "who reset my sun?" is answered by the console instead of by guessing. The +-- wrappers sit on the shared Spring table, so every LuaUI widget's writes show. +widgetState.setSunLog = function(on) + if on and not widgetState._sunLogOrig then + local orig = { dir = Spring.SetSunDirection, light = Spring.SetSunLighting } + widgetState._sunLogOrig = orig + Spring.SetSunDirection = function(x, y, z, i) + Spring.Echo( + string.format("[sunlog] SetSunDirection(%.3f, %.3f, %.3f, %s)", x or 0, y or 0, z or 0, tostring(i)) + ) + Spring.Echo(debug.traceback("", 2)) + return orig.dir(x, y, z, i) + end + Spring.SetSunLighting = function(t) + local keys = {} + for k in pairs(type(t) == "table" and t or {}) do + keys[#keys + 1] = tostring(k) + end + table.sort(keys) + Spring.Echo("[sunlog] SetSunLighting{" .. table.concat(keys, ", ") .. "}") + Spring.Echo(debug.traceback("", 2)) + return orig.light(t) + end + Spring.Echo("[Terraform Brush] sun write logging ON (/tf_sunlog again to stop)") + elseif not on and widgetState._sunLogOrig then + Spring.SetSunDirection = widgetState._sunLogOrig.dir + Spring.SetSunLighting = widgetState._sunLogOrig.light + widgetState._sunLogOrig = nil + Spring.Echo("[Terraform Brush] sun write logging OFF") + end end -- Serialize the live environment state into the env-config Lua format (the same @@ -2104,6 +2782,12 @@ widgetState.buildEnvConfigContent = function(opts) local bstr = function(v) return v and "true" or "false" end + -- A nil or zero-length sun vector must not serialize as {0,0,0} — a config + -- carrying that would black out the map it is later applied to. + local sunDirLine = "\t-- sunDir omitted: engine returned no sun position at save time" + if sX and ((sX * sX + (sY or 0) * (sY or 0) + (sZ or 0) * (sZ or 0)) > 1e-6) then + sunDirLine = "\tsunDir = " .. fmt3({ sX, sY, sZ }) .. "," + end local outLines = { "-- Environment config exported from BAR Terraform Brush", "-- Map: " .. (Game.mapName or "unknown"), @@ -2117,7 +2801,7 @@ widgetState.buildEnvConfigContent = function(opts) '\tmapName = "' .. (Game.mapName or "unknown") .. '",', "", "\t-- Sun direction", - "\tsunDir = " .. fmt3({ sX, sY, sZ }) .. ",", + sunDirLine, "", "\t-- Shadow density", "\tgroundShadowDensity = " .. string.format("%.4f", gShadow) .. ",", @@ -2149,6 +2833,9 @@ widgetState.buildEnvConfigContent = function(opts) "\t-- Skybox rotation", "\tskyAxisAngle = " .. fmt4(skAA) .. ",", "", + "\t-- Skybox texture (library path; the engine has no getter, the UI tracks the pick)", + "\tskybox = " .. string.format("%q", widgetState.envCurrentSkybox or "") .. ",", + "", "\t-- Map rendering", "\tsplatDetailNormalDiffuseAlpha = " .. bstr(sdnda) .. ",", "\tsplatTexMults = " .. fmt4({ smR, smG, smB, smA }) .. ",", @@ -2220,7 +2907,33 @@ widgetState.buildEnvConfigContent = function(opts) return table.concat(outLines, "\n") end --- Resolve the env preset to apply after a New Map reload (nil = Default/none). +-- The wizard's "Default" environment: PtaQ's canonical sun and nothing else, so a +-- fresh map is lit like a real one without adopting some other map's water, fog and +-- sky. Built from envSunPresets[1], the single place that sun is defined, rather +-- than from a copy: the harvested moods in env_presets.lua are regenerated by +-- tools/mapgen/scan_environments.py, so a sun stored there cannot be trusted to +-- survive a re-harvest. Lazy on purpose (envSunKeys is defined further down). +widgetState.newMapDefaultEnv = function() + local sun = widgetState.envSunPresets and widgetState.envSunPresets[1] + if not sun then + return nil + end + local x, y, z = widgetState.sunDirFromAzEl(sun.az, sun.el) + ---@type table + local out = { name = sun.name, sunDir = { x, y, z } } + for _, k in ipairs(widgetState.envSunKeys) do + local v = sun[k] + if k ~= "sunDir" and v ~= nil then + -- colours are copied element-wise: sharing the table would let an ENV + -- panel edit reach back into the preset + out[k] = (type(v) == "table") and { v[1], v[2], v[3] } or v + end + end + return out +end + +-- Resolve the env preset to apply after a New Map reload (nil = Default, which the +-- reader turns into newMapDefaultEnv above). widgetState._nmCurrentEnvPreset = function() local idx = widgetState.newMapEnvIdx or 0 if idx <= 0 then @@ -2388,10 +3101,16 @@ local function buildBlankMapStartScript(widthUnits, heightUnits, dntsSet, skybox -- game_team_com_ends remove themselves at init, so teams survive with zero -- units (edit without commanders) and commander death cannot end the session. script = script:gsub("[Dd][Ee][Aa][Tt][Hh][Mm][Oo][Dd][Ee]%s*=[^;\r\n]*;?", "") + -- editor_sandbox=1 marks the session as a map editor canvas for the game + -- gadgets: game_initial_spawn spawns no commanders (the map maker edits an + -- empty canvas or the project's own unit loadout), and game_end / + -- game_team_com_ends stand down whatever deathmode the lobby set. Strip an + -- inherited copy first so editor-to-editor reloads stay idempotent. + script = script:gsub("[Ee][Dd][Ii][Tt][Oo][Rr]_[Ss][Aa][Nn][Dd][Bb][Oo][Xx]%s*=[^;\r\n]*;?", "") local needModoptions = true local _, moE = script:find("%[[Mm][Oo][Dd][Oo][Pp][Tt][Ii][Oo][Nn][Ss]%]%s*\r?\n?%s*{") if moE then - script = script:sub(1, moE) .. "\ndeathmode=neverend;" .. script:sub(moE + 1) + script = script:sub(1, moE) .. "\ndeathmode=neverend;\neditor_sandbox=1;" .. script:sub(moE + 1) needModoptions = false end @@ -2459,6 +3178,7 @@ local function buildBlankMapStartScript(widthUnits, heightUnits, dntsSet, skybox injectParts[#injectParts + 1] = "[modoptions]" injectParts[#injectParts + 1] = "{" injectParts[#injectParts + 1] = "deathmode=neverend;" + injectParts[#injectParts + 1] = "editor_sandbox=1;" injectParts[#injectParts + 1] = "}" end local inject = table.concat(injectParts, "\n") @@ -2531,6 +3251,18 @@ widgetState.buildProjectStartScript = function(manifest, slug) break end end + if not skyboxPath then + -- The thumb cache only exists once the panel document has been built; + -- resolve straight against the library so a project reopens with its + -- sky even when the panel was never opened this session. + local files = VFS.DirList("Terraform Brush/SkyBoxes/", "*.dds", VFS.RAW_FIRST) or {} + for _, fp in ipairs(files) do + if fp:match("([^/\\]+)$") == m.skybox then + skyboxPath = fp:gsub("\\", "/") + break + end + end + end if not skyboxPath then Spring.Echo( "[Terraform Brush] Project skybox '" .. m.skybox .. "' not found in the skybox library; using none." @@ -2778,6 +3510,55 @@ function capUI.set(key, value) capUI.sync() end +-- "3 h ago" / "yesterday" / "2026-08-22" for the project lists. Manifests and +-- the recent-projects journal stamp ISO-8601 UTC; os.time() reads a table as +-- local time, so the parsed stamp is shifted by the local UTC offset. Dates a +-- week or older show as the (local) calendar day. On widgetState: the main +-- chunk is near the Lua 5.1 200-local ceiling. +widgetState.relativeAge = function(iso, now) + local stamp = tostring(iso or "") + local y, mo, d, h, mi, s = stamp:match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):?(%d*)") + if not y then + return stamp ~= "" and stamp or "(no date)" + end + -- isdst = false on BOTH conversions: the stamp and the offset probe then go + -- through the same standard-time interpretation, so the offset cancels + -- exactly whatever the daylight-saving state of either date is. + local t = os.time({ + year = math.floor(tonumber(y) or 0), + month = math.floor(tonumber(mo) or 1), + day = math.floor(tonumber(d) or 1), + hour = math.floor(tonumber(h) or 0), + min = math.floor(tonumber(mi) or 0), + sec = math.floor(tonumber(s) or 0), + isdst = false, + }) + if not t then + return string.format("%s-%s-%s", y, mo, d) + end + local nowT = now or os.time() + local probe = os.date("!*t", nowT) + probe.isdst = false + local utcOffset = nowT - os.time(probe) + local epoch = t + utcOffset + local diff = nowT - epoch + if diff < 0 then + diff = 0 + end + if diff < 60 then + return "just now" + elseif diff < 3600 then + return string.format("%d min ago", math.floor(diff / 60)) + elseif diff < 86400 then + return string.format("%d h ago", math.floor(diff / 3600)) + elseif diff < 2 * 86400 then + return "yesterday" + elseif diff < 7 * 86400 then + return string.format("%d d ago", math.floor(diff / 86400)) + end + return os.date("%Y-%m-%d", epoch) +end + -- Opens the Save Project As dialog: prefills the name (current project > -- last-typed > slugified map name) and rebuilds the existing-projects list, -- where clicking a row fills the NAME field (pick-to-overwrite, modern Save @@ -2825,10 +3606,10 @@ widgetState.openProjectSaveDialog = function() return (tostring(s):gsub("&", "&"):gsub("<", "<"):gsub(">", ">")) end local parts = {} + local now = os.time() for i, p in ipairs(projects) do - local stamp = tostring(p.modified or "") - local y, mo, dd, hh, mi = stamp:match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+)") - local when = y and string.format("%s-%s-%s %s:%s", y, mo, dd, hh, mi) or (stamp ~= "" and stamp or "(no date)") + -- Nested projects show their path: that is what the NAME field receives. + local label = (p.folder and p.folder ~= "") and p.slug or (p.name or p.slug) parts[#parts + 1] = string.format( '
' .. '
%s
' @@ -2836,8 +3617,8 @@ widgetState.openProjectSaveDialog = function() .. '
%sx%s
' .. "
", i, - esc(when), - esc(p.name or p.slug), + esc(widgetState.relativeAge(p.modified, now)), + esc(label), esc(p.size_x or "?"), esc(p.size_z or "?") ) @@ -2889,6 +3670,7 @@ local initialModel = { -- Phase 2 step 3: data-if visibility flags (tf_guide pilot) passthroughActive = false, + focusActive = false, settingsOpen = false, settingsTab = "keybinds", -- Map Labels window (gui_map_labels widget) — header button highlight @@ -2929,6 +3711,7 @@ local initialModel = { projectSaveOpen = false, projectSaveHint = "", projectSaveUnits = false, -- "save units loadout" toggle (position/team of every unit) + projectOpenSort = "recent", -- Open Project sort chip: recent | name | size projectCurrentName = "", -- FILE > Save target ("" = none yet → Save acts as Save As) -- Open Project dialog (FILE > Open Project, backed by WG.MapProject) projectOpenOpen = false, @@ -2966,8 +3749,31 @@ local initialModel = { -- Active biome key for the TILESET tool BIOME LIBRARY tiles -- (data-class-active="tsBiome == ''"); synced from WG.TilesetTerrain. tsBiome = "", + -- SLOT 4 mode buttons in the PLACEMENT section (data-class-active = + -- "tsSlot4Mode == ''"); synced from WG.TilesetTerrain.getSlot4Mode. + tsSlot4Mode = "plateau", + -- PERFORMANCE section quality preset (data-class-active="tsQuality == ''"); + -- synced from WG.TilesetTerrain.getQuality. + tsQuality = "high", tsDebugView = 0, -- active TILESET debug view (drives the DEBUG multi-toggle highlight) tsMetalStyle = "", -- active METAL SPOTS style tile (data-class-active="tsMetalStyle == ''") + tsGlowOn = false, -- METAL SPOTS glow light master (grays the GLOW LIGHT block via data-class-disabled) + -- HEIGHT TINT (tileset shader 0.27): axis mode chips, the selected colour + -- chip (grade stops / strata beds / snow) the shared palette + trio edits, + -- strata chip visibility + layer-mask chips, ramp mode chips + file label. + -- Synced from the knob table in tf_tileset.sync (syncHeightTint). + tsHgRef = 0, + tsHgTarget = "low", + tsHgTargetName = "GRADE LOW", + tsStrataCount = 4, + tsStrataBase = true, + tsStrataInter = true, + tsStrataCliff = true, + tsStrataPlat = true, + tsRampMode = 0, + tsRampFile = "none", + tsStopsCount = 3, -- GRADIENT STOPS chips shown (data-if) and the Multiply / Colorize chips + tsStopsMode = 1, -- SURFACE tool (tileset variant paint; engine = dev_surface_painter.lua, -- catalog/shader = dev_tileset_terrain.lua, UI module = tf_surface.lua) surfPreset = "dot", @@ -2975,12 +3781,20 @@ local initialModel = { surfHasVariants = false, surfHasSculpted = false, surfShaderOff = false, - surfCoverageStr = "\226\128\148", - surfCoverageAmber = false, surfSlot1Name = "\226\128\148", surfSlot2Name = "\226\128\148", + surfSlot3Name = "\226\128\148", + surfSlot4Name = "\226\128\148", + surfSlot5Name = "\226\128\148", + surfSlot6Name = "\226\128\148", + surfSlot7Name = "\226\128\148", surfFillV1 = true, surfFillV2 = true, + surfFillV3 = true, + surfFillV4 = true, + surfFillV5 = true, + surfFillV6 = true, + surfFillV7 = true, -- FILL WITH NOISE is a no-op unless some slot is both assigned and enabled -- (the fill shader preserves channels it is not allowed to write), so the -- button grays out rather than looking broken. @@ -2989,14 +3803,20 @@ local initialModel = { surfNowName = "base (erase)", surfNowDetail = "", surfNowMode = "PAINT", - surfSelSlot = 0, -- 0 = base/erase, 1/2 = variant slots + surfSelSlot = 0, -- 0 = base/erase, 1-7 = variant slots surfSlot1Assigned = false, surfSlot2Assigned = false, - surfSlot1Share = "", - surfSlot2Share = "", - surfBaseShare = "", + surfSlot3Assigned = false, + surfSlot4Assigned = false, + surfSlot5Assigned = false, + surfSlot6Assigned = false, + surfSlot7Assigned = false, -- Per-slot variant picker (dropdown opened from a slot chip's caret) surfPickerTitle = "", + surfPickSlot = 0, -- slot whose library is open (lights that tile's PICK) + -- Picker hover preview (tf_surface drives both from the hovered tile) + surfPreviewName = "\226\128\148", + surfPreviewHint = "", surfPickerHasPaint = false, surfClearArm = false, -- CLEAR VARIANT armed, waiting for the confirm click surfClearAllArm = false, -- CLEAR ALL armed @@ -3018,6 +3838,41 @@ local initialModel = { surfHardAltMin = false, surfHardAltMax = false, surfHardExportFmt = "PNG", + surfHardOverlay = false, -- LAYERS: splat override channel overlay (engine flag mirror) + -- SURFACE soft-submode smart filters (engine = dev_surface_painter) + surfSoftAvoidWater = false, + -- INFLUENCE section (both submodes): chip state + the profile's owner + surfInfAlt = false, + surfInfSlope = false, + surfInfKey = "", + surfSoftAvoidCliffs = false, + surfSoftAltMin = false, + surfAltMinSample = false, + surfAltMaxSample = false, + surfInfAltMinSample = false, + surfInfAltMaxSample = false, + surfSoftAltMax = false, + -- WYSIWYG Ctrl sneak peek (DISPLAY chip, both submodes): holding Ctrl over + -- the map renders the selected layer inside the brush ring as if the + -- stroke had landed (engines drive WG.TilesetTerrain.setSurfacePreview), + -- so the artist can inspect where the texture's fixed features fall + -- before painting. This flag is the on/off gate, mirrored into both + -- engines by tf_surface's sync. + surfReveal = true, + -- sf (SURFACE/LAYERS shared panel) TB mirror set, syncTBMirrorControls + sfGridOverlay = false, + sfHeightColormap = false, + sfGridSnap = false, + sfAngleSnap = false, + sfMeasureActive = false, + sfSymmetryActive = false, + sfSymmetryRadial = false, + sfSymMirrorX = false, + sfSymMirrorY = false, + sfSymHasAxis = false, + sfMeasureShowLength = false, + sfMeasureRulerMode = false, + sfMeasureStickyMode = false, stpSubMode = "", stpStartboxMode = "", -- Diffuse painter (Phase A MVP) @@ -3161,7 +4016,7 @@ local initialModel = { -- Phase 2 step 2: active-state dm fields (data-class-active bindings) activeMode = "", -- "raise"/"lower"/"smooth"/"ramp"/"restore"/"noise" activeShape = "circle", -- shared shape for all tools - activeSmoothMode = "", -- "smooth"/"level" when in smooth/level group, else "" + activeSmoothMode = "", -- "smooth"/"level"/"smudge" when in the modify group, else "" noiseType = "perlin", -- noise type selection mbSubMode = "paint", -- metal brush sub-mode gbSubMode = "paint", -- grass brush sub-mode @@ -3299,6 +4154,8 @@ local initialModel = { fpRadiusStr = "200", fpRotationStr = "0", fpRotRandomStr = "0", + fpScaleMinStr = "1.00", + fpScaleMaxStr = "1.00", fpCountStr = "1", fpCadenceStr = "1", fpSlopeMaxStr = "45", @@ -3328,6 +4185,9 @@ local initialModel = { envCurrMinStr = "--", envCurrMaxStr = "--", envWaterPlaneStr = "--", + envWaterTargetStr = "Drag to move the shoreline.", + envDimRangeMode = "scale", + envDimRangeDescStr = "Stretches the terrain onto the new range. Relief is kept, nothing is cut off.", -- Phase 2 step 4: tf shared (ring/restore) label interpolation strings tfRingWidthStr = "40%", tfRestoreStrengthStr = "100%", @@ -3364,6 +4224,8 @@ local initialModel = { seismicEffectsStr = "OFF", penPressureStr = "OFF", wiggleStr = "OFF", + perfModeStr = "OFF", -- Settings > Performance + clayStackStr = "OFF", -- Settings > Stroke > Clay build-up disableTipsStr = "OFF", keepAliveStr = "OFF", -- Settings > General: match end disabled for this session penSensitivityStr = "100", @@ -3373,6 +4235,8 @@ local initialModel = { seismicActive = false, penPressureActive = false, wiggleActive = false, + perfModeActive = false, + clayStackActive = false, disableTipsActive = false, keepAliveActive = false, -- Phase 2 step 6: sub-panel dj-disabled states (true = grayed out) @@ -3400,6 +4264,9 @@ local initialModel = { tfRingVisible = false, tfInRestore = false, tfRampMode = false, + tfRampType = "", -- "straight"/"spline"/"auto" when in a ramp mode, else "" + arStart = "average", -- autoramp cliff anchor: "extend"/"subtract"/"average" + arPreview = true, -- autoramp WYSIWYG hover preview toggle tfShapeRowVisible = true, tfSmoothSubmodesVisible = false, tfErodeControlsVisible = false, @@ -3411,6 +4278,22 @@ local initialModel = { tfHeightColormap = false, tfCurveOverlay = false, tfVelocityIntensity = false, + tfFollowStroke = false, + tfFollowVisible = true, + -- PASSABILITY overlay: one shared state across every DISPLAY row + tbPassActive = false, + tbPassLabelStr = "Passability", + -- IMAGE overlay (DISPLAY > Image): one shared state across every DISPLAY row + tbImgActive = false, + imgOvVisible = false, + imgOvHasImage = false, + imgOvFileStr = "none", + imgOvSizeStr = "", + imgOvError = "", + imgOvFit = "stretch", + imgOvFlipH = false, + imgOvFlipV = false, + imgOvSupported = true, tfSymMirrorX = false, tfSymMirrorY = false, tfSymFlipped = false, @@ -3444,6 +4327,8 @@ local initialModel = { splatTexVisible = false, skyboxLibraryVisible = false, envSunVisible = false, + envPresetScope = "full", -- Sun & Shadows PRESETS: what a preset click applies ("sun" | "full") + envPresetHint = "", envFogVisible = false, envGroundLightingVisible = false, envUnitLightingVisible = false, @@ -4114,6 +4999,14 @@ local initialModel = { WG.StartPosTool.saveStartboxes() end end, + -- Copies the startbox override as a !bSet the user can paste into lobby chat. Startbox + -- only: start positions travel as a different modoption entirely. + onSpCopy = function(_event) + playSound("apply") + if WG.StartPosTool then + WG.StartPosTool.copyStartboxOverride() + end + end, onSpLoad = function(_event) playSound("apply") if WG.StartPosTool then @@ -5277,6 +6170,44 @@ local initialModel = { WG.FeaturePlacer.setRotRandom(math.max(0, (st.rotRandom or 100) - 5)) end, + -- Scale variation (per-feature visual scale range) + onFpScaleMinChange = function(_event) + if uiState.updatingFromCode or not WG.FeaturePlacer then + return + end + WG.FeaturePlacer.setScaleMin(_elemSliderVal("fp-slider-scale-min", 1)) + end, + onFpScaleMinDown = function(_event) + if not WG.FeaturePlacer then + return + end + WG.FeaturePlacer.setScaleMin(((WG.FeaturePlacer.getState() or {}).scaleMin or 1) - 0.1) + end, + onFpScaleMinUp = function(_event) + if not WG.FeaturePlacer then + return + end + WG.FeaturePlacer.setScaleMin(((WG.FeaturePlacer.getState() or {}).scaleMin or 1) + 0.1) + end, + onFpScaleMaxChange = function(_event) + if uiState.updatingFromCode or not WG.FeaturePlacer then + return + end + WG.FeaturePlacer.setScaleMax(_elemSliderVal("fp-slider-scale-max", 1)) + end, + onFpScaleMaxDown = function(_event) + if not WG.FeaturePlacer then + return + end + WG.FeaturePlacer.setScaleMax(((WG.FeaturePlacer.getState() or {}).scaleMax or 1) - 0.1) + end, + onFpScaleMaxUp = function(_event) + if not WG.FeaturePlacer then + return + end + WG.FeaturePlacer.setScaleMax(((WG.FeaturePlacer.getState() or {}).scaleMax or 1) + 0.1) + end, + -- Count onFpCountChange = function(_event) if uiState.updatingFromCode or not WG.FeaturePlacer then @@ -6706,9 +7637,11 @@ local initialModel = { end return end - if not name:match("^[A-Za-z0-9_%-]+$") then + -- Coarse screen only; cmd_map_project's validateSlug is the rule (spaces + -- inside a segment are fine, / separates folders). + if not name:match("^[A-Za-z0-9_%- /]+$") then if d then - d.projectSaveHint = "Only letters, digits, - and _ (no spaces)." + d.projectSaveHint = "Only letters, digits, spaces, - and _; / for a folder." end return end @@ -6786,12 +7719,19 @@ local initialModel = { -- Clicking a row only selects it — LOAD and DELETE live at the bottom of -- the dialog, like Save Project and New Map. Neither belongs on a stray -- click in a list: one restarts the session, the other destroys files. + ---@type table? local doc = widgetState.document local listEl = doc and doc:GetElementById("tf-project-open-list") - if not listEl then + if not (doc and listEl) then return end local function rebuild() + if not doc then + return + end + -- The selection survives a folder toggle, a sort or a filter change; + -- it drops only when the selected project is no longer listed. + local keepSlug = tostring(widgetState.projectOpenSelectedSlug or "") widgetState.projectOpenRowEls = {} widgetState.projectOpenSelectedSlug = nil widgetState.projectDeleteConfirmExpiry = 0 @@ -6809,42 +7749,178 @@ local initialModel = { end return end - local projects = WG.MapProject.listDetailed() - if #projects == 0 then + local all = WG.MapProject.listDetailed() + if #all == 0 then listEl.inner_rml = '
' - .. "No projects found in MapProjects/. Projects saved this session may need an engine restart to appear (VFS folder cache).
" + .. "No projects found in MapProjects/. Projects saved this session may need an engine restart to appear (VFS folder cache). " + .. "To browse a shared maps repository, clone it inside that folder: git clone <url> MapProjects/<name>." return end local function esc(s) return (tostring(s):gsub("&", "&"):gsub("<", "<"):gsub(">", ">")) end - local parts = {} - for i, p in ipairs(projects) do - -- Manifests stamp ISO-8601 UTC ("2026-07-27T14:22:31Z"); the heightmap - -- browser shows "YYYY-MM-DD HH:MM", so drop the seconds and the T/Z. - local stamp = tostring(p.modified or "") - local y, mo, dd, hh, mi = stamp:match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+)") - local when = y and string.format("%s-%s-%s %s:%s", y, mo, dd, hh, mi) - or (stamp ~= "" and stamp or "(no date)") - parts[#parts + 1] = string.format( - '
' - .. '
%s
' - .. '
%s
' - .. '
%sx%s
' + local filter = tostring(widgetState.projectOpenFilter or ""):lower() + local sortMode = tostring(widgetState.projectOpenSort or "recent") + local now = os.time() + -- RECENT means last touched: the newer of "opened or saved through the + -- editor" (journal) and the manifest's modified stamp, both ISO-8601 so + -- string order is time order. + local function touched(p) + local a, b = tostring(p.last_touched or ""), tostring(p.modified or "") + return a > b and a or b + end + local function less(a, b) + if sortMode == "name" then + local an, bn = tostring(a.name or a.slug):lower(), tostring(b.name or b.slug):lower() + if an ~= bn then + return an < bn + end + elseif sortMode == "size" then + local aa = (tonumber(a.size_x) or 0) * (tonumber(a.size_z) or 0) + local bb = (tonumber(b.size_x) or 0) * (tonumber(b.size_z) or 0) + if aa ~= bb then + return aa > bb + end + else + local ta, tb = touched(a), touched(b) + if ta ~= tb then + return ta > tb + end + end + return a.slug < b.slug + end + -- Search: case-insensitive substring over the name, the path and the + -- NxN size, so "cm0", "campaign/" and "16x16" all work. + local projects = {} + for _, p in ipairs(all) do + if filter == "" then + projects[#projects + 1] = p + else + local hay = string.format("%s %s %sx%s", p.name or "", p.slug or "", p.size_x or "", p.size_z or "") + if hay:lower():find(filter, 1, true) then + projects[#projects + 1] = p + end + end + end + if #projects == 0 then + listEl.inner_rml = '
No project matches "' + .. esc(widgetState.projectOpenFilter) + .. '".
' + return + end + table.sort(projects, less) + local parts, rows, folders = {}, {}, {} + local collapsed = widgetState.projectOpenCollapsed or {} + local function projectRow(p, depth, showPath) + rows[#rows + 1] = p + local pathHtml = "" + if showPath and p.folder and p.folder ~= "" then + pathHtml = '
' .. esc(p.folder .. "/") .. "
" + end + parts[#parts + 1] = string.format( + '
' + .. '
%s
' + .. '
%s
%s' + .. '
%sx%s
' .. "
", - i, - esc(when), + #rows, + depth, + esc(widgetState.relativeAge(touched(p), now)), esc(p.name or p.slug), + pathHtml, esc(p.size_x or "?"), esc(p.size_z or "?") ) end + if filter ~= "" then + -- Flat while searching; the folder path travels with each row. + for _, p in ipairs(projects) do + projectRow(p, 0, true) + end + else + -- Tree: a folder's own projects first (in the chosen order), then its + -- subfolders. Every intermediate folder gets a node even when it + -- holds no project of its own, so a cloned repository's layout shows + -- as it is on disk. + local byFolder, children, count, newest = { [""] = {} }, {}, {}, {} + local function parentOf(path) + return path:match("^(.*)/[^/]+$") or "" + end + local function ensureFolder(path) + if path == "" or rawget(byFolder, path) then + return + end + byFolder[path] = {} + local parent = parentOf(path) + ensureFolder(parent) + children[parent] = children[parent] or {} + children[parent][#children[parent] + 1] = path + end + for _, p in ipairs(projects) do + local f = p.folder or "" + ensureFolder(f) + byFolder[f][#byFolder[f] + 1] = p + local t = touched(p) + local anc = f + while anc ~= "" do + count[anc] = (count[anc] or 0) + 1 + if t > (newest[anc] or "") then + newest[anc] = t + end + anc = parentOf(anc) + end + end + local function folderLess(a, b) + if sortMode == "recent" then + local na, nb = newest[a] or "", newest[b] or "" + if na ~= nb then + return na > nb + end + elseif sortMode == "size" then + local ca, cb = count[a] or 0, count[b] or 0 + if ca ~= cb then + return ca > cb + end + end + return a:lower() < b:lower() + end + local function render(path, depth) + for _, p in ipairs(byFolder[path] or {}) do + projectRow(p, depth, false) + end + local subs = children[path] or {} + table.sort(subs, folderLess) + for _, sub in ipairs(subs) do + local open = not collapsed[sub] + folders[#folders + 1] = sub + parts[#parts + 1] = string.format( + '
' + .. '
%s
' + .. '
%s/
' + .. '
%d
', + #folders, + depth, + open and "-" or "+", + esc(sub:match("([^/]+)$") or sub), + count[sub] or 0 + ) + if open then + render(sub, depth + 1) + end + end + end + render("", 0) + end listEl.inner_rml = table.concat(parts) - for i, p in ipairs(projects) do + for i, p in ipairs(rows) do local row = doc:GetElementById("tf-proj-r" .. i) if row then - local slug, label = p.slug, (p.name or p.slug) - widgetState.projectOpenRowEls[#widgetState.projectOpenRowEls + 1] = { slug = slug, el = row } + -- Nested projects select by their path so "Selected:" and the + -- console echoes say exactly what will open. + local slug = p.slug + local label = (p.folder and p.folder ~= "") and slug or (p.name or slug) + widgetState.projectOpenRowEls[#widgetState.projectOpenRowEls + 1] = + { slug = slug, label = label, el = row } row:AddEventListener("click", function(ev) ev:StopPropagation() playSound("click") @@ -6863,6 +7939,32 @@ local initialModel = { end, false) end end + for i, path in ipairs(folders) do + local fEl = doc:GetElementById("tf-proj-f" .. i) + if fEl then + fEl:AddEventListener("click", function(ev) + ev:StopPropagation() + playSound("click") + local c = widgetState.projectOpenCollapsed or {} + c[path] = (not c[path]) and true or nil + widgetState.projectOpenCollapsed = c + -- Rebuild next frame, not from inside the click on a row the + -- rebuild destroys. + widgetState.projectOpenNeedsRebuild = true + end, false) + end + end + if keepSlug ~= "" then + for _, r in ipairs(widgetState.projectOpenRowEls) do + if r.slug == keepSlug then + widgetState.projectOpenSelectedSlug = keepSlug + r.el:SetClass("selected", true) + if dm then + dm.projectOpenSelected = r.label + end + end + end + end end -- Stashed on widgetState (not a chunk local) so the bottom buttons can -- refresh the list after a delete. @@ -6946,6 +8048,37 @@ local initialModel = { -- Never leave DELETE armed for the next time the dialog opens. widgetState.projectDeleteConfirmExpiry = 0 end, + -- Open Project search box (change fires per keystroke) and sort chips. All + -- three queue the deferred rebuild rather than rebuilding here: the list is + -- torn down and rebuilt, which must not happen inside an event dispatch. + onProjectSearch = function(_event) + ---@type table? + local doc2 = widgetState.document + local inp = doc2 and doc2:GetElementById("tf-project-search") + widgetState.projectOpenFilter = (inp and inp:GetAttribute("value")) or "" + widgetState.projectOpenNeedsRebuild = true + end, + onProjectSearchClear = function(_event) + playSound("click") + ---@type table? + local doc2 = widgetState.document + local inp = doc2 and doc2:GetElementById("tf-project-search") + if inp then + inp:SetAttribute("value", "") + end + widgetState.projectOpenFilter = "" + widgetState.projectOpenNeedsRebuild = true + end, + onProjectSort = function(_event, mode) + playSound("click") + widgetState.projectOpenSort = mode or "recent" + ---@type table? + local d = widgetState.dmHandle + if d then + d.projectOpenSort = widgetState.projectOpenSort + end + widgetState.projectOpenNeedsRebuild = true + end, -- GENERATE TERRAIN toggle: off (default) creates a dead-flat map; on reveals -- the procedural terrain/water/resources/layout controls and the randomizer. onNewMapGenToggle = function(_event) @@ -7277,6 +8410,10 @@ local initialModel = { widgetState.g3Toast.expiry = 0 end end, + onGuideToggleFocus = function(_event) + widgetState.setFocusMode(not widgetState.focusMode) + playSound("modeSwitch") + end, onGuideTogglePassthrough = function(_event) if not widgetState.passthroughMode then local saved = nil @@ -7289,14 +8426,23 @@ local initialModel = { local lpSt = WG.LightPlacer and WG.LightPlacer.getState() local stSt = WG.StartPosTool and WG.StartPosTool.getState() local clSt = WG.CloneTool and WG.CloneTool.getState() + ---@type table? + local sfPtr = WG.SurfacePainter + local sfSt = sfPtr and sfPtr.getState and sfPtr.getState() if tfSt and tfSt.active then saved = { tool = "terraform", mode = tfSt.mode } elseif fpSt and fpSt.active then saved = { tool = "features", mode = fpSt.mode } elseif wbSt and wbSt.active then saved = { tool = "weather", mode = wbSt.mode } + elseif widgetState.surfHardActive then + -- LAYERS: the splat engine runs headless under the SURFACE panel; + -- the pin (not the engine) tells it apart from the legacy SPLAT tool. + saved = { tool = "surfaceHard" } elseif spSt and spSt.active then saved = { tool = "splat" } + elseif sfSt and sfSt.active then + saved = { tool = "surface" } elseif mbSt and mbSt.active then saved = { tool = "metal", mode = mbSt.subMode } elseif gbSt and gbSt.active then @@ -7322,6 +8468,9 @@ local initialModel = { if WG.SplatPainter then WG.SplatPainter.deactivate() end + if sfPtr and sfPtr.deactivate then + sfPtr.deactivate() + end if WG.MetalBrush then WG.MetalBrush.deactivate() end @@ -7341,6 +8490,8 @@ local initialModel = { widgetState.lightActive = false widgetState.startposActive = false widgetState.cloneActive = false + widgetState.surfHardActive = false + widgetState.surfPickerSlot = nil -- pausing the tool closes the variant picker widgetState.passthroughSaved = saved widgetState.passthroughMode = true local d = widgetState.dmHandle @@ -7362,6 +8513,10 @@ local initialModel = { end local s = widgetState.passthroughSaved widgetState.passthroughSaved = nil + ---@type table? + local sfPtr = WG.SurfacePainter + ---@type table? + local spPtr = WG.SplatPainter if s then -- Splat/Metal/Grass/StartPos expose activate(subMode), not setMode; -- Weather's setMode only picks the submode without re-arming the tool. @@ -7373,6 +8528,11 @@ local initialModel = { WG.WeatherBrush.activate(s.mode or "scatter") elseif s.tool == "splat" and WG.SplatPainter then WG.SplatPainter.activate() + elseif s.tool == "surface" and sfPtr and sfPtr.activate then + sfPtr.activate() + elseif s.tool == "surfaceHard" and spPtr and spPtr.activate then + spPtr.activate() + widgetState.surfHardActive = true elseif s.tool == "metal" and WG.MetalBrush then WG.MetalBrush.activate(s.mode or "stamp") elseif s.tool == "grass" and WG.GrassBrush then @@ -7689,6 +8849,26 @@ local initialModel = { d.wiggleSpdIdx = i end end, + onGuideTogglePerfMode = function(_event) + widgetState.uiPrefs = widgetState.uiPrefs or {} + local newVal = not widgetState.uiPrefs.perfMode + widgetState.uiPrefs.perfMode = newVal + playSound(newVal and "toggleOn" or "toggleOff") + widgetState.pushPerfPrefs() + if widgetState.saveUiPrefs then + widgetState.saveUiPrefs() + end + end, + onGuideToggleClayStack = function(_event) + widgetState.uiPrefs = widgetState.uiPrefs or {} + local newVal = not widgetState.uiPrefs.clayStack + widgetState.uiPrefs.clayStack = newVal + playSound(newVal and "toggleOn" or "toggleOff") + widgetState.pushPerfPrefs() + if widgetState.saveUiPrefs then + widgetState.saveUiPrefs() + end + end, onGuideToggleDisableTips = function(_event) widgetState.uiPrefs = widgetState.uiPrefs or {} local newVal = not widgetState.uiPrefs.disableTips @@ -8226,8 +9406,19 @@ local initialModel = { if not d then return end - Spring.SetSunDirection(d.sunPos[1], d.sunPos[2], d.sunPos[3]) + local intensity = d.sunIntensity or widgetState.envSunIntensity or 1.0 + Spring.SetSunDirection(d.sunPos[1], d.sunPos[2], d.sunPos[3], intensity) + widgetState.envSunIntensity = intensity Spring.SetSunLighting({ groundShadowDensity = d.groundShadowDensity, modelShadowDensity = d.unitShadowDensity }) + if widgetState.refreshEnvSunAzEl then + widgetState.refreshEnvSunAzEl() + end + _envSetSlider( + "slider-env-sun-intensity", + "lbl-env-sun-intensity", + math.floor(intensity * 1000 + 0.5), + string.format("%.2f", intensity) + ) _envSetSlider( "slider-env-sun-y", "lbl-env-sun-y", @@ -8483,66 +9674,110 @@ local initialModel = { end Spring.SendCommands("water 4") end, - onEnvDimRefresh = function(_event) - if widgetState.envRefreshDimExtremes then - widgetState.envRefreshDimExtremes() - end - end, + -- Commits the previewed shoreline: the terrain slides so the water plane + -- lands on the slider's height. The slider is then reseeded (the terrain it + -- was measured against just moved) on a short delay, once the sim has + -- applied the shift. onEnvApplyWaterLevel = function(_event) + local tb = WG.TerraformBrush + if not (tb and tb.applyWaterLevel) then + return + end local doc = widgetState.document - local wlInputEl = doc and doc:GetElementById("input-dim-waterlevel") - local val = wlInputEl and tonumber(wlInputEl:GetAttribute("value")) - if val and val ~= 0 then - Spring.SendCommands("luarules waterlevel " .. tostring(val)) - if wlInputEl then - wlInputEl:SetAttribute("value", "0") - end - if widgetState.envRefreshDimExtremes then - widgetState.envRefreshDimExtremes() - end + local sl = doc and doc:GetElementById("slider-env-waterlevel") + local level = sl and tonumber(sl:GetAttribute("value")) + if not level then + return + end + if not tb.applyWaterLevel(level) then + Spring.Echo("[Terraform Brush] Shoreline is already at that height.") + return end + playSound("save") + widgetState.envWaterReseedTicks = 40 end, - onEnvApplyMinHeight = function(_event) - local doc = widgetState.document - local minHEl = doc and doc:GetElementById("input-dim-minheight") - local val = minHEl and tonumber(minHEl:GetAttribute("value")) - if val then - Spring.SendCommands("luarules clampminheight " .. tostring(val)) - if widgetState.envRefreshDimExtremes then - widgetState.envRefreshDimExtremes() - end + onEnvDimRangeMode = function(_event, mode) + local dm = widgetState.dmHandle + if not dm or dm.envDimRangeMode == mode then + return + end + playSound("click") + dm.envDimRangeMode = mode + if mode == "clamp" then + dm.envDimRangeDescStr = "Cuts everything outside the range. Peaks and pits come out flat." + else + dm.envDimRangeDescStr = "Stretches the terrain onto the new range. Relief is kept, nothing is cut off." end end, - onEnvApplyMaxHeight = function(_event) + -- Applies the slider min/max to the whole map. RESCALE remaps the live + -- extremes onto the range (the thing the old clamp-only buttons could never + -- do: lowering the max used to just shear the mountain tops off); CLAMP is + -- the old behaviour, kept for shaving a single runaway peak. + onEnvApplyHeightRange = function(_event) local doc = widgetState.document - local maxHEl = doc and doc:GetElementById("input-dim-maxheight") - local val = maxHEl and tonumber(maxHEl:GetAttribute("value")) - if val then - Spring.SendCommands("luarules clampmaxheight " .. tostring(val)) - if widgetState.envRefreshDimExtremes then - widgetState.envRefreshDimExtremes() - end + local minHEl = doc and doc:GetElementById("slider-env-dim-minheight") + local maxHEl = doc and doc:GetElementById("slider-env-dim-maxheight") + local newMin = minHEl and tonumber(minHEl:GetAttribute("value")) + local newMax = maxHEl and tonumber(maxHEl:GetAttribute("value")) + if not newMin or not newMax then + Spring.Echo("[Terraform Brush] Height range needs a number on both sliders.") + return + end + if newMax - newMin < 1 then + Spring.Echo("[Terraform Brush] Height range needs a max at least 1 above the min.") + return end + local tb = WG.TerraformBrush + if not (tb and tb.remapHeights) then + return + end + local dm = widgetState.dmHandle + playSound("save") + -- No refresh here: the sim applies a frame or two later, so it would + -- only re-show the pre-edit numbers. The window poll picks it up. + tb.remapHeights(newMin, newMax, dm and dm.envDimRangeMode or "scale") end, + -- Put the water back where the map had it, undoing every water level apply + -- made this session. Parking the slider is not enough on its own: an apply + -- already recentres it, so a slider-only reset is a visible no-op. onEnvResetWaterLevel = function(_event) - local doc = widgetState.document - local wlInputEl = doc and doc:GetElementById("input-dim-waterlevel") - if wlInputEl then - wlInputEl:SetAttribute("value", "0") + local tb = WG.TerraformBrush + local shift = tb and tb.resetWaterLevel and tb.resetWaterLevel() + if shift then + playSound("save") + Spring.Echo(string.format("[Terraform Brush] Water level restored (undid %.0f).", shift)) + widgetState.envWaterReseedTicks = 40 + else + playSound("click") + Spring.Echo("[Terraform Brush] Water is already at the map's own level.") + if widgetState.envSeedWaterSlider then + widgetState.envSeedWaterSlider() + end end end, - onEnvResetBounds = function(_event) - local doc = widgetState.document - if not doc then - return + -- "CURRENT" button: park the slider back on the water's live plane — + -- recentres the track and clears the shoreline preview without touching + -- the terrain (reseed = bounds centred on the plane, handle in the middle). + onEnvWaterCurrent = function(_event) + if widgetState.envSeedWaterSlider then + playSound("click") + widgetState.envSeedWaterSlider() end - local minHEl = doc:GetElementById("input-dim-minheight") - local maxHEl = doc:GetElementById("input-dim-maxheight") - if minHEl then - minHEl:SetAttribute("value", "") + end, + -- "RESET" chip: back to the map's own height range. Init min/max come from + -- the map's SMF header, so they survive every edit and stay a true default. + onEnvResetBounds = function(_event) + if widgetState.envFillDimRangeInputs then + playSound("click") + widgetState.envFillDimRangeInputs(true) end - if maxHEl then - maxHEl:SetAttribute("value", "") + end, + -- "CURRENT" button: refill both boxes from the live extremes, so editing one + -- end of the range does not need the other typed back in by hand. + onEnvFillBoundsCurrent = function(_event) + if widgetState.envFillDimRangeInputs then + playSound("click") + widgetState.envFillDimRangeInputs(false) end end, onEnvSave = function(_event) @@ -8599,6 +9834,49 @@ local initialModel = { playSound("save") Spring.Echo("[Environ] Loaded environment config: " .. newest) end, + -- ENV panel PRESETS (Sun & Shadows window): SAVE writes the live environment + -- under a name, BROWSE lists sun-only quick presets, the harvested map moods + -- and the user's files; the SUN ONLY / FULL chips set what a click applies. + onEnvPresetSave = function(_event) + ---@type table? + local doc = widgetState.document + local inp = doc and doc:GetElementById("env-preset-name-input") + local name = inp and (inp:GetAttribute("value") or "") or "" + local ok, msg = widgetState.saveEnvPreset(name) + ---@type table? + local d = widgetState.dmHandle + if d then + d.envPresetHint = ok and ("Saved " .. tostring(name)) or tostring(msg) + end + if ok then + playSound("save") + if inp then + inp:SetAttribute("value", "") + end + if widgetState.envPresetDropdownOpen and widgetState.rebuildEnvPresetList then + widgetState.rebuildEnvPresetList() + end + end + end, + onEnvPresetToggle = function(_event) + local open = not widgetState.envPresetDropdownOpen + if open and widgetState.rebuildEnvPresetList then + widgetState.rebuildEnvPresetList() + end + if widgetState.setEnvPresetDropdownOpen then + widgetState.setEnvPresetDropdownOpen(open) + end + playSound("click") + end, + onEnvPresetScope = function(_event, scope) + playSound("click") + widgetState.envPresetScope = scope == "sun" and "sun" or "full" + ---@type table? + local d = widgetState.dmHandle + if d then + d.envPresetScope = widgetState.envPresetScope + end + end, -- ── Terraform mode buttons ──────────────────────────────────────────────── -- data-event-click="onTfSetMode('raise')" @@ -8677,9 +9955,11 @@ local initialModel = { if WG.TerraformBrush.setErodeReposeDeg then WG.TerraformBrush.setErodeReposeDeg(val) end - -- Keep the attribute coherent for the steppers: outside a change event - -- GetAttribute returns the stale pre-drag value (rmlui quirk). - _noSetSliderVal("erode-repose", val) + -- No echo-write of the value attribute here: a stamp raises a DEFERRED + -- change event (see syncAndFlash), which re-enters this handler with + -- updatingFromCode already false and fights the native thumb drag. + -- The steppers read widget state, and the per-sync restamp reconciles + -- the attribute after release, so nothing needs the write. _noDmLabel("tfErodeReposeStr", tostring(val) .. "\xc2\xb0") end, -- Steppers read the authoritative widget state, not the slider attribute, @@ -8709,6 +9989,67 @@ local initialModel = { _noDmLabel("tfErodeReposeStr", tostring(val) .. "\xc2\xb0") end, + -- ── Autoramp submode sliders ───────────────────────────────────────────── + -- data-event-change="onTfAutorampSlider('angle')" etc. Angle is degrees; + -- the percent sliders map 0–100 onto the widget's 0–1 knobs. + onTfAutorampSlider = function(_event, key) + if uiState.updatingFromCode or not WG.TerraformBrush then + return + end + local tb = WG.TerraformBrush + -- Read-and-store only — no echo-write of the value attribute: a stamp + -- raises a deferred change event that re-enters this handler and fights + -- the native thumb drag (the marble sticks while the track still works). + -- The per-sync restamp reconciles the attribute once the drag ends. + if key == "angle" then + local val = _noSliderVal("ar-angle", 60) + if tb.setAutorampAngleDeg then + tb.setAutorampAngleDeg(val) + end + else + local setters = { + falloff = tb.setAutorampFalloff, + edgenoise = tb.setAutorampEdgeNoise, + erosion = tb.setAutorampErosion, + talus = tb.setAutorampTalus, + } + local defaults = { falloff = 50, edgenoise = 35, erosion = 35, talus = 40 } + local setter = setters[key] + if setter then + local val = _noSliderVal("ar-" .. key, defaults[key]) + setter(val / 100) + end + end + end, + + -- data-event-click="onTfArStart('extend')" — autoramp cliff anchor chips + onTfArStart = function(_event, mode) + playSound("toggleOn") + if WG.TerraformBrush and WG.TerraformBrush.setAutorampStart then + WG.TerraformBrush.setAutorampStart(mode) + end + if widgetState.dmHandle then + widgetState.dmHandle.arStart = mode + end + end, + + -- data-event-click="onTfArPreview()" — autoramp WYSIWYG preview toggle + onTfArPreview = function(_event) + local tb = WG.TerraformBrush + if not tb then + return + end + local s = tb.getState and tb.getState() + local nv = not (s and s.autorampPreview) + playSound(nv and "toggleOn" or "toggleOff") + if tb.setAutorampPreview then + tb.setAutorampPreview(nv) + end + if widgetState.dmHandle then + widgetState.dmHandle.arPreview = nv + end + end, + -- data-event-click="onTfSetShape('circle')" onTfSetShape = function(_event, shape) playSound("shapeSwitch") @@ -8752,10 +10093,16 @@ local initialModel = { onTfRampStraight = function(_event) playSound("tick") if WG.TerraformBrush then + -- Leaving Auto: shape changes are rejected while autoramp is active + local s = WG.TerraformBrush.getState and WG.TerraformBrush.getState() + if s and s.mode == "autoramp" then + WG.TerraformBrush.setMode("ramp") + end WG.TerraformBrush.setShape("square") end if widgetState.dmHandle then widgetState.dmHandle.activeShape = "square" + widgetState.dmHandle.tfRampType = "straight" end end, @@ -8763,10 +10110,27 @@ local initialModel = { onTfRampSpline = function(_event) playSound("tick") if WG.TerraformBrush then + local s = WG.TerraformBrush.getState and WG.TerraformBrush.getState() + if s and s.mode == "autoramp" then + WG.TerraformBrush.setMode("ramp") + end WG.TerraformBrush.setShape("circle") end if widgetState.dmHandle then widgetState.dmHandle.activeShape = "circle" + widgetState.dmHandle.tfRampType = "spline" + end + end, + + -- data-event-click="onTfRampAuto()" + onTfRampAuto = function(_event) + playSound("modeSwitch") + if WG.TerraformBrush then + WG.TerraformBrush.setMode("autoramp") + end + if widgetState.dmHandle then + widgetState.dmHandle.activeShape = "circle" + widgetState.dmHandle.tfRampType = "auto" end end, @@ -9118,6 +10482,12 @@ local initialModel = { if dm and dm.surfMode ~= "soft" then dm.surfMode = "soft" end + -- Sneak Peek re-arms on every entry into this mode: it is the + -- tool's discovery surface, so a mid-session toggle-off never + -- carries over to the next visit. + if dm then + dm.surfReveal = true + end WG.SurfacePainter.activate() end end, @@ -9151,6 +10521,10 @@ local initialModel = { if dm and dm.surfMode ~= "hard" then dm.surfMode = "hard" end + -- Sneak Peek re-arms on every entry into this mode (see SURFACE above) + if dm then + dm.surfReveal = true + end WG.SplatPainter.activate() widgetState.surfHardActive = true -- Strokes must be visible: the shader gates the override mask on @@ -9205,10 +10579,25 @@ local initialModel = { sp.setCurve(_elemSliderVal("surf-slider-falloff", 5) / 10) elseif key == "spacing" then sp.setSpacing(_elemSliderVal("surf-slider-spacing", 0)) + elseif key == "scatter-pos" then + sp.setScatterPos(_elemSliderVal("surf-slider-scatter-pos", 0) / 100) + elseif key == "scatter-size" then + sp.setScatterSize(_elemSliderVal("surf-slider-scatter-size", 0) / 100) + elseif key == "scatter-str" then + sp.setScatterStr(_elemSliderVal("surf-slider-scatter-str", 0) / 100) elseif key == "fill-scale" then sp.setFillScale(_elemSliderVal("surf-slider-fill-scale", 1400)) elseif key == "fill-seed" then sp.setFillSeed(_elemSliderVal("surf-slider-fill-seed", 0)) + elseif sp.setSmartFilter then + -- soft-submode FILTERS sliders (ids surf-soft-slider-*) + if key == "slope-max" then + sp.setSmartFilter("slopeMax", _elemSliderVal("surf-soft-slider-slope-max", 45)) + elseif key == "alt-min" then + sp.setSmartFilter("altMin", _elemSliderVal("surf-soft-slider-alt-min", 0)) + elseif key == "alt-max" then + sp.setSmartFilter("altMax", _elemSliderVal("surf-soft-slider-alt-max", 200)) + end end end, onSurfPreset = function(_event, name) @@ -9227,6 +10616,136 @@ local initialModel = { WG.SurfacePainter.setEraseMode(not st.eraseMode) playSound(st.eraseMode and "toggleOff" or "toggleOn") end, + -- WYSIWYG Ctrl sneak peek (DISPLAY chip, both submodes). Pure panel state: + -- tf_surface mirrors it into both paint engines each sync; the engines + -- watch Ctrl and drive WG.TilesetTerrain.setSurfacePreview themselves. + onSurfRevealToggle = function(_event) + local dm = widgetState.dmHandle + if not dm then + return + end + dm.surfReveal = not dm.surfReveal + playSound(dm.surfReveal and "toggleOn" or "toggleOff") + end, + -- Soft-submode smart filters (engine = dev_surface_painter; mirrors + -- onSurfHardFilter's enable-follows-any-chip behaviour). + onSurfFilter = function(_event, key) + local sp = WG.SurfacePainter + if not (sp and sp.setSmartFilter) then + return + end + local sf = (sp.getState() or {}).smartFilters or {} + local nv = not sf[key] + playSound(nv and "toggleOn" or "toggleOff") + sp.setSmartFilter(key, nv) + local sf2 = (sp.getState() or {}).smartFilters or {} + sp.setSmartEnabled( + (sf2.avoidWater or sf2.avoidCliffs or sf2.altMinEnable or sf2.altMaxEnable) and true or false + ) + end, + -- INFLUENCE (soft altitude / slope bands scaling the stroke): SURFACE edits + -- the armed texture's profile in dev_surface_painter, LAYERS the active + -- channel's in the splat engine. Same three handlers for both submodes. + onSurfInfluence = function(_event, key) + local dm = widgetState.dmHandle + local eng = (dm and dm.surfMode == "hard") and WG.SplatPainter or WG.SurfacePainter + if not (eng and eng.setInfluence and eng.getState) then + return + end + local inf = (eng.getState() or {}).influence or {} + local nv = not inf[key] + playSound(nv and "toggleOn" or "toggleOff") + eng.setInfluence(key, nv) + end, + onSurfInfluenceSlider = function(_event, key) + if uiState.updatingFromCode then + return + end + if uiState.surfStampFrame and (Spring.GetDrawFrame() - uiState.surfStampFrame) < 3 then + return + end + local dm = widgetState.dmHandle + local eng = (dm and dm.surfMode == "hard") and WG.SplatPainter or WG.SurfacePainter + if not (eng and eng.setInfluence) then + return + end + local map = { + ["alt-min"] = { "altMin", 0 }, + ["alt-max"] = { "altMax", 200 }, + ["alt-feather"] = { "altFeatherLo", 40 }, + ["slope-min"] = { "slopeMin", 0 }, + ["slope-max"] = { "slopeMax", 30 }, + ["slope-feather"] = { "slopeFeather", 10 }, + } + local m = map[key] + if not m then + return + end + local v = _elemSliderVal("surf-slider-inf-" .. key, m[2]) + eng.setInfluence(m[1], v) + -- one Feather slider drives both altitude feathers + if m[1] == "altFeatherLo" then + eng.setInfluence("altFeatherHi", v) + end + end, + onSurfInfluenceCopy = function(_event) + local sp = WG.SurfacePainter + if not (sp and sp.copyInfluenceToAll) then + return + end + local n = sp.copyInfluenceToAll() + playSound("click") + Spring.Echo("[Terraform Brush] influence profile copied to " .. tostring(n) .. " texture(s)") + end, + -- SELECTED SLOT tint (GRADING): per-asset albedo tint of the armed variant + -- in the tileset shader (T.setSlotTint, keyed like FLIP). One slider sets + -- one channel; the other two come from the current entry. + onSurfSlotTint = function(_event, ch) + if uiState.updatingFromCode then + return + end + ---@type table? + local T = WG.TilesetTerrain + local asset = widgetState.surfSelectedAsset and widgetState.surfSelectedAsset() + if not (T and T.setSlotTint and asset) then + return + end + local r, g, b = T.getSlotTint(asset) + local doc = widgetState.document + local sl = doc and doc:GetElementById("surf-slider-slotTint" .. tostring(ch)) + local v = sl and tonumber(sl:GetAttribute("value")) + if not v then + return + end + if ch == "R" then + r = v + elseif ch == "G" then + g = v + elseif ch == "B" then + b = v + end + T.setSlotTint(asset, r, g, b) + end, + onSurfSlotTintReset = function(_event) + ---@type table? + local T = WG.TilesetTerrain + local asset = widgetState.surfSelectedAsset and widgetState.surfSelectedAsset() + if not (T and T.setSlotTint and asset) then + return + end + T.setSlotTint(asset, 1, 1, 1) + playSound("reset") + end, + -- LAYERS display: the splat engine's channel overlay, colored per override + onSurfHardOverlay = function(_event) + local sp = WG.SplatPainter + if not (sp and sp.setSplatOverlay) then + return + end + local st = sp.getState() or {} + sp.setSplatOverlay(not st.showSplatOverlay) + playSound(st.showSplatOverlay and "toggleOff" or "toggleOn") + end, -- Slot rail: click BASE = erase-to-base brush; click a slot = paint that -- slot's variant (no-op when the slot is empty — the palette assigns). onSurfSelectBase = function(_event) @@ -9247,14 +10766,26 @@ local initialModel = { end local slot = tonumber(n) local st = WG.SurfacePainter.getState() or {} - local asset = (slot == 1) and st.slot1 or st.slot2 - if asset and asset ~= "" and WG.SurfacePainter.setVariant then - WG.SurfacePainter.setVariant(asset) + if not (slot and slot >= 1 and slot <= (st.slotCount or 0)) then + return -- a chip the painter does not have (stale click) + end + local asset = st["slot" .. slot] + if asset and asset ~= "" then + -- ARM THE SLOT, nothing else. This used to open the library as well, + -- so switching brush threw the whole catalog on screen every time; + -- the tile's PICK button owns that now. + if WG.SurfacePainter.setVariant then + WG.SurfacePainter.setVariant(asset) + end + playSound("click") + else + -- an empty slot has nothing to paint with, so the only useful thing + -- a click can mean is "let me choose something for it" + local open = (widgetState.surfPickerSlot ~= slot) and slot or nil + widgetState.surfPickerSlot = open + widgetState.surfPaletteSig = nil + playSound(open and "dropdown" or "click") end - local open = (widgetState.surfPickerSlot ~= slot) and slot or nil - widgetState.surfPickerSlot = open - widgetState.surfPaletteSig = nil -- rebuild for the new target - playSound(open and "dropdown" or "click") end, onSurfNoiseFill = function(_event) if not (WG.SurfacePainter and WG.SurfacePainter.noiseFill) then @@ -9265,13 +10796,21 @@ local initialModel = { -- mask verbatim, so the button silently did nothing. The RML grays it -- in that state (dm.surfCanFill) — this is the backstop that explains. local st = (WG.SurfacePainter.getState and WG.SurfacePainter.getState()) or {} - if not ((st.slot1 and st.fillV1) or (st.slot2 and st.fillV2)) then + local anyAssigned, anyFill = false, false + for i = 1, (st.slotCount or 0) do + if st["slot" .. i] then + anyAssigned = true + if st["fillV" .. i] then + anyFill = true + end + end + end + if not anyFill then Spring.Echo( "[Terraform Brush] SURFACE fill did nothing \226\128\148 " .. ( - (not st.slot1 and not st.slot2) - and "assign a variant to slot 1 or 2 first (the caret on a slot chip)." - or "enable V1 or V2 below." + anyAssigned and "enable a V chip below." + or "assign a variant to a slot first (click a slot chip)." ) ) return @@ -9285,16 +10824,16 @@ local initialModel = { end local st = WG.SurfacePainter.getState() or {} local dm = widgetState.dmHandle - if tonumber(n) == 1 then - WG.SurfacePainter.setFillV1(not st.fillV1) - if dm then - dm.surfFillV1 = not st.fillV1 - end - else - WG.SurfacePainter.setFillV2(not st.fillV2) - if dm then - dm.surfFillV2 = not st.fillV2 - end + local slot = tonumber(n) + if not (slot and slot >= 1 and slot <= (st.slotCount or 0)) then + return + end + local want = not st["fillV" .. slot] + if WG.SurfacePainter.setFillV then + WG.SurfacePainter.setFillV(slot, want) + end + if dm then + dm["surfFillV" .. slot] = want end playSound("tick") end, @@ -9358,6 +10897,21 @@ local initialModel = { playSound("modeSwitch") WG.SplatPainter.setChannel(tonumber(n) or 1) end, + -- SAMPLE buttons on the SURFACE altitude rows (FILTERS in both modes and the + -- INFLUENCE band): arm the brush widget's height sampler, which reads the + -- next click's ground height (or the colormap contour under the cursor) + -- into the target. 'infAltMin'/'infAltMax' resolve to the engine of the + -- active mode; the FILTERS rows pass their engine's target directly. + onSurfAltSample = function(_event, target) + if not WG.TerraformBrush then + return + end + if target == "infAltMin" or target == "infAltMax" then + target = (widgetState.surfHardActive and "spInf" or "sfInf") .. target:sub(4) + end + local cur = (WG.TerraformBrush.getState() or {}).heightSamplingMode + WG.TerraformBrush.setHeightSamplingMode(cur == target and nil or target) + end, onSurfHardFilter = function(_event, key) if not WG.SplatPainter then return @@ -9564,19 +11118,38 @@ local initialModel = { end end, onPickBiome = function(_event, key) - if not (WG.TilesetTerrain and WG.TilesetTerrain.setBiome) then + widgetState.pickBiome(key) + end, + -- SLOT 4 mode buttons (TILESET > PLACEMENT): the fourth material suite's + -- weight source (plateau / detail / interm 2 / cliff 2 / off). The shader + -- widget reseeds the two reused sliders on a change; tf_tileset.sync + -- restamps them and retitles their labels. + -- METAL SPOTS suite toggle: what TU22-24 serve. false = the metal-spot + -- material (legacy), true = a third paintable SURFACE variant (slot 3). + onTsSlot4Mode = function(_event, name) + if not (WG.TilesetTerrain and WG.TilesetTerrain.setSlot4Mode) then return end - local ok = WG.TilesetTerrain.setBiome(key) + local ok = WG.TilesetTerrain.setSlot4Mode(name) if ok then playSound("click") local dm = widgetState.dmHandle if dm then - dm.tsBiome = key + dm.tsSlot4Mode = name + end + end + end, + onTsQuality = function(_event, name) + if not (WG.TilesetTerrain and WG.TilesetTerrain.setQuality) then + return + end + local ok = WG.TilesetTerrain.setQuality(name) + if ok then + playSound("click") + local dm = widgetState.dmHandle + if dm then + dm.tsQuality = name end - -- Each biome is a planet: swap the skybox to match (no-op unless BAR + - -- toggle on; also no-op on maps that booted without a real cubemap sky). - syncSkyboxToBiome(key) end end, onTsToggleSkyboxSync = function(_event) @@ -9616,38 +11189,179 @@ local initialModel = { d.tsShaderOn = on and true or false end end, - -- METAL SPOTS style tiles (mirrors onPickBiome; styles live in the shader - -- widget's METAL_STYLES and swap the metal material + knob baseline live). - onPickMetalStyle = function(_event, key) - if not (WG.TilesetTerrain and WG.TilesetTerrain.setMetalStyle) then - return + -- METAL SPOTS style tiles (mirrors onPickBiome; styles live in the shader + -- widget's METAL_STYLES and swap the metal material + knob baseline live). + onPickMetalStyle = function(_event, key) + if not (WG.TilesetTerrain and WG.TilesetTerrain.setMetalStyle) then + return + end + local ok = WG.TilesetTerrain.setMetalStyle(key) + if ok then + playSound("click") + local dm = widgetState.dmHandle + if dm then + dm.tsMetalStyle = key + end + end + end, + -- Dim per-spot glow lights toggle (deferred point lights via lightsgl4). + onTsToggleMetalGlow = function(_event) + if not (WG.TilesetTerrain and WG.TilesetTerrain.setMetalLights) then + return + end + local on = WG.TilesetTerrain.setMetalLights( + not (WG.TilesetTerrain.getMetalLights and WG.TilesetTerrain.getMetalLights()) + ) + playSound(on and "toggleOn" or "toggleOff") + ---@type table? + local dm = widgetState.dmHandle + if dm then + dm.tsGlowOn = on + end + local doc = widgetState.document + local el = doc and doc:GetElementById("btn-ts-metal-glow") + if el then + el:SetAttribute( + "src", + on and "/luaui/images/terraform_brush/check_on.png" or "/luaui/images/terraform_brush/check_off.png" + ) + end + end, + -- GLOW LIGHT colour swatches, borrowed from the LIGHTS tool: they only write + -- tileset knobs; the shader widget rebuilds the deferred lights from the + -- knob table. + onTsGlowSwatch = function(_event, idx) + local c = widgetState.lpPalette and widgetState.lpPalette[tonumber(idx) or 0] + if not (c and WG.TilesetTerrain and WG.TilesetTerrain.setKnob) then + return + end + WG.TilesetTerrain.setKnob("metalGlowR", c[1]) + WG.TilesetTerrain.setKnob("metalGlowG", c[2]) + WG.TilesetTerrain.setKnob("metalGlowB", c[3]) + playSound("click") + end, + -- HEIGHT TINT (tileset shader 0.27). Axis mode chips, the colour target + -- chips (grade LOW / MID / HIGH, strata beds 1..8, SNOW) and the one shared + -- palette + R/G/B trio that edits whichever chip is selected. Everything + -- writes tileset knobs; tf_tileset.sync paints the chips and restamps the + -- trio from the knob table. The chip -> knob-prefix map comes from + -- tf_tileset (widgetState.tsHgTargets, set in its attach). + onTsHgRefMode = function(_event, n) + if WG.TilesetTerrain and WG.TilesetTerrain.setKnob then + WG.TilesetTerrain.setKnob("hgRefMode", tonumber(n) or 0) + end + playSound("click") + end, + onTsHgTarget = function(_event, t) + local dm = widgetState.dmHandle + if dm then + dm.tsHgTarget = tostring(t) + end + widgetState.tsHgTrioLast = nil -- restamp the trio from the new target + playSound("click") + end, + onTsHgSwatch = function(_event, idx) + local c = widgetState.lpPalette and widgetState.lpPalette[tonumber(idx) or 0] + local set = widgetState.tsHgSet + local dm = widgetState.dmHandle + if not (c and set and dm) then + return + end + -- tf_tileset converts to the chip's own storage (RGB, or HSV for the stops) + if set(dm.tsHgTarget, c[1], c[2], c[3]) then + playSound("click") + end + end, + onTsHgChannel = function(_event, ch) + if uiState.updatingFromCode or not WG.TilesetTerrain then + return + end + -- same deferred-echo guard as onTilesetKnob: a programmatic restamp of + -- the trio raises change events frames later + if uiState.tsStampFrame and (Spring.GetDrawFrame() - uiState.tsStampFrame) < 3 then + return + end + local get, set = widgetState.tsHgGet, widgetState.tsHgSet + local dm = widgetState.dmHandle + if not (get and set and dm) then + return + end + local k = WG.TilesetTerrain.getKnobs and WG.TilesetTerrain.getKnobs() + if not k then + return + end + ch = tostring(ch):lower() + local val = _elemSliderVal("ts-hg-slider-" .. ch, nil) + if val == nil then + return + end + -- one slider moved: rebuild the colour in that slider's space from the + -- chip's current value and write it back through tf_tileset, which + -- converts to the chip's own storage (RGB, or HSV for the stops) + local r, g, b, h, s, v = get(k, dm.tsHgTarget) + if r == nil then + return + end + if ch == "r" or ch == "g" or ch == "b" then + if ch == "r" then + r = val + elseif ch == "g" then + g = val + else + b = val + end + set(dm.tsHgTarget, r, g, b) + else + if ch == "h" then + h = val + elseif ch == "s" then + s = val + else + v = val + end + set(dm.tsHgTarget, nil, nil, nil, h, s, v) + end + end, + onTsStrataMask = function(_event, bit) + local T = WG.TilesetTerrain + if not (T and T.getKnobs and T.setKnob) then + return + end + local k = T.getKnobs() or {} + local m = math.floor((k.strataLayerMask or 0) + 0.5) + bit = tonumber(bit) or 0 + if bit <= 0 then + return + end + local has = (m % (bit * 2)) >= bit + T.setKnob("strataLayerMask", has and (m - bit) or (m + bit)) + playSound(has and "toggleOff" or "toggleOn") + end, + onTsRampMode = function(_event, n) + if WG.TilesetTerrain and WG.TilesetTerrain.setKnob then + WG.TilesetTerrain.setKnob("rampMode", tonumber(n) or 0) end - local ok = WG.TilesetTerrain.setMetalStyle(key) - if ok then - playSound("click") - local dm = widgetState.dmHandle - if dm then - dm.tsMetalStyle = key - end + playSound("click") + end, + onTsStopsMode = function(_event, n) + if WG.TilesetTerrain and WG.TilesetTerrain.setKnob then + WG.TilesetTerrain.setKnob("stopsMode", tonumber(n) or 1) end + playSound("click") end, - -- Dim per-spot glow lights toggle (deferred point lights via lightsgl4). - onTsToggleMetalGlow = function(_event) - if not (WG.TilesetTerrain and WG.TilesetTerrain.setMetalLights) then - return + onTsRampRescan = function(_event) + if WG.TilesetTerrain and WG.TilesetTerrain.getRamps then + WG.TilesetTerrain.getRamps(true) end - local on = WG.TilesetTerrain.setMetalLights( - not (WG.TilesetTerrain.getMetalLights and WG.TilesetTerrain.getMetalLights()) - ) - playSound(on and "toggleOn" or "toggleOff") - local doc = widgetState.document - local el = doc and doc:GetElementById("btn-ts-metal-glow") - if el then - el:SetAttribute( - "src", - on and "/luaui/images/terraform_brush/check_on.png" or "/luaui/images/terraform_brush/check_off.png" - ) + widgetState.tsRampListSig = nil + playSound("click") + end, + onTsRampClear = function(_event) + if WG.TilesetTerrain and WG.TilesetTerrain.setRamp then + WG.TilesetTerrain.setRamp("") end + widgetState.tsRampListSig = nil + playSound("toggleOff") end, onTfSwitchLights = function(_event) playSound("toolSwitch") @@ -9942,6 +11656,130 @@ local initialModel = { end playSound(nv and "toggleOn" or "toggleOff") end, + onTbCyclePassability = function(_event) + local TT = WG.TilesetTerrain + if not (TT and TT.setKnob) then + Spring.Echo("[Terraform Brush] PASSABILITY needs the tileset shader (SHADER in the SCENE window)") + return + end + _tbPassIdx = (_tbPassIdx + 1) % (#_tbPassClasses + 1) + local entry = _tbPassClasses[_tbPassIdx] + TT.setKnob("passSlopeDeg", entry and _tbPassDeg(entry) or 0) + local dm = widgetState.dmHandle + if dm then + dm.tbPassActive = entry ~= nil + dm.tbPassLabelStr = entry and ("Pass: " .. entry.key) or "Passability" + end + playSound(entry and "toggleOn" or "toggleOff") + end, + -- ── IMAGE overlay (DISPLAY > Image; chips and window shared by every tool) ── + onTbImageOverlay = function(event) + -- Left click toggles the overlay once an image is loaded; before that, + -- and on right click, it opens the IMAGE OVERLAY window instead. + local p = event and event.parameters + local rightClick = p and p.button == 1 + if rightClick or not _imgOv.toggleShow() then + local dm = widgetState.dmHandle + local open = not (dm and dm.imgOvVisible) + _imgOv.setWindow(open) + playSound(open and "panelOpen" or "click") + end + end, + onImgOvOpen = function(_event) + local dm = widgetState.dmHandle + local open = not (dm and dm.imgOvVisible) + _imgOv.setWindow(open) + playSound(open and "panelOpen" or "click") + end, + onImgOvClose = function(_event) + _imgOv.setWindow(false) + playSound("click") + end, + onImgOvToggleShow = function(_event) + if not _imgOv.toggleShow() then + playSound("toggleOff") + end + end, + onImgOvRefresh = function(_event) + _imgOv.rebuildList(true) + playSound("tick") + end, + onImgOvSlider = function(_event, key) + ---@type table? + local IO = WG.TerraformImageOverlay + if not IO or uiState.updatingFromCode then + return + end + -- Drop the deferred echo of a programmatic restamp (see onTilesetKnob). + if uiState.imgOvStampFrame and (Spring.GetDrawFrame() - uiState.imgOvStampFrame) < 3 then + return + end + for _, row in ipairs(_imgOv.SLIDERS) do + if row[1] == key then + local v = _elemSliderVal("imgov-slider-" .. key, nil) + if v ~= nil then + row[3](v, IO) + local str = tostring(math.floor(v + 0.5)) + widgetState.imgOvLastVal = widgetState.imgOvLastVal or {} + widgetState.imgOvLastVal["imgov-slider-" .. key] = str + _imgOv.setNumbox(key, str) + end + return + end + end + end, + onImgOvFit = function(_event, mode) + ---@type table? + local IO = WG.TerraformImageOverlay + if IO then + IO.setFit(mode) + playSound("tick") + end + end, + onImgOvFlip = function(_event, axis) + ---@type table? + local IO = WG.TerraformImageOverlay + if not IO then + return + end + local s = IO.getState() or {} + if axis == "h" then + IO.setFlip(not s.flipH, nil) + else + IO.setFlip(nil, not s.flipV) + end + playSound("tick") + end, + onImgOvReset = function(_event) + ---@type table? + local IO = WG.TerraformImageOverlay + if IO then + IO.resetPlacement() + _imgOv.stamp(true) + playSound("apply") + end + end, + onImgOvClear = function(_event) + ---@type table? + local IO = WG.TerraformImageOverlay + if IO then + IO.clear() + _imgOv.rebuildList(false) + playSound("toggleOff") + end + end, + onTfFollowStroke = function(_event) + if not WG.TerraformBrush or not WG.TerraformBrush.setFollowStroke then + return + end + local nv = not (WG.TerraformBrush.getState() or {}).followStroke + WG.TerraformBrush.setFollowStroke(nv) + local dm = widgetState.dmHandle + if dm then + dm.tfFollowStroke = nv + end + playSound(nv and "toggleOn" or "toggleOff") + end, onTfPenIntensity = function(_event) if not WG.TerraformBrush then return @@ -10829,7 +12667,7 @@ local function setActiveClass(buttons, activeKey) end end -CLAY_UNAVAILABLE_MODES = { noise = true, restore = true, erode = true } +CLAY_UNAVAILABLE_MODES = { noise = true, restore = true, erode = true, autoramp = true } clearPassthrough = function() if widgetState.passthroughMode then @@ -10851,70 +12689,66 @@ clearPassthrough = function() end end -local function onRotateCW(event) - playSound("tick") - if WG.TerraformBrush then - WG.TerraformBrush.rotate(ROTATION_STEP) +-- FOCUS MODE (the eye button next to pause): the engine's /hideinterface with +-- the editor left alive. RmlUi documents are rendered by the engine outside +-- the hidden-interface gate (CGame::Draw calls RmlGui::RenderFrame +-- unconditionally, DrawInputReceivers is the only block hideInterface skips), +-- so the panel survives on its own. The brush widget reads isFocusMode() to +-- keep its ring, grid and water overlays drawing through it, and the deferred +-- applies (skybox picks included) drain from DrawScreenPost because DrawScreen +-- is the one call-in the widget handler gates on Spring.IsGUIHidden(). +-- widgetState field, not a chunk local: this chunk is near the 200-local cap. +widgetState.setFocusMode = function(on) + on = on and true or false + if widgetState.focusMode == on then + return end - - event:StopPropagation() -end - -local function onRotateCCW(event) - playSound("tick") - if WG.TerraformBrush then - WG.TerraformBrush.rotate(-ROTATION_STEP) + widgetState.focusMode = on + widgetState.focusSetTimer = Spring.GetTimer() + if widgetState.dmHandle then + widgetState.dmHandle.focusActive = on end - - event:StopPropagation() -end - -local function onCurveUp(event) - playSound("tick") - if WG.TerraformBrush then - local state = WG.TerraformBrush.getState() - WG.TerraformBrush.setCurve(state.curve + CURVE_STEP) + -- Explicit argument, never the bare toggle: the toggle would desync from the + -- flag the moment anything else touched the interface (F5, a map capture). + -- A running capture owns the interface; its restoreScene lands on this flag. + ---@type table? + local cap = WG.TerraformCapture + if not (cap and cap.isBusy and cap.isBusy()) then + Spring.SendCommands(on and "hideinterface 1" or "hideinterface 0") end - - event:StopPropagation() end -local function onCurveDown(event) - playSound("tick") - if WG.TerraformBrush then - local state = WG.TerraformBrush.getState() - WG.TerraformBrush.setCurve(state.curve - CURVE_STEP) +-- Update-side bookkeeping, called once per Update after the panel visibility +-- sync. Two exits besides the button: every tool gone (panel close, quit, tool +-- deactivation) hands the HUD back so nobody is left with no UI at all; and the +-- interface coming back from outside (F5, /hideinterface) drops the flag so the +-- eye reads right and the next click hides again. The T hotkey (panelHidden) is +-- deliberately not an exit: focus + hidden panel is the clean-screenshot setup. +widgetState.syncFocusMode = function(panelVisible, panelHidden) + if not widgetState.focusMode then + return end - - event:StopPropagation() -end - -local function onIntensityUp(event) - playSound("tick") - if WG.TerraformBrush then - local state = WG.TerraformBrush.getState() - local newI = state.intensity * 1.15 - if newI < state.intensity + 0.1 then - newI = state.intensity + 0.1 - end - WG.TerraformBrush.setIntensity(newI) + if not panelVisible and not panelHidden then + widgetState.setFocusMode(false) + return end - - event:StopPropagation() -end - -local function onIntensityDown(event) - playSound("tick") - if WG.TerraformBrush then - local state = WG.TerraformBrush.getState() - local newI = state.intensity / 1.15 - if newI > state.intensity - 0.1 then - newI = state.intensity - 0.1 + ---@type table? + local cap = WG.TerraformCapture + if cap and cap.isBusy and cap.isBusy() then + return + end + -- SendCommands may land a frame late; give a fresh toggle time to take. + -- (Member access, not a local copy: the analyzer types a copied dynamic + -- field as nil and calls the guard impossible.) + if widgetState.focusSetTimer and Spring.DiffTimers(Spring.GetTimer(), widgetState.focusSetTimer) < 0.5 then + return + end + if not Spring.IsGUIHidden() then + widgetState.focusMode = false + if widgetState.dmHandle then + widgetState.dmHandle.focusActive = false end - WG.TerraformBrush.setIntensity(newI) end - - event:StopPropagation() end capMinValue = 0 @@ -11087,7 +12921,18 @@ local guideHints = { ["btn-noise"] = "Apply procedural noise to the terrain. Opens the Noise Parameters window to choose the noise type and detail.", ["btn-erode"] = "Thermal erosion: slopes steeper than the repose angle shed material downhill while you hold LMB, weathering sharp cliffs into natural intermediate aprons.", ["slider-erode-repose"] = "Repose angle (10\xc2\xb0\xe2\x80\x9360\xc2\xb0): the steepest slope that survives erosion. Lower angles erode more aggressively into gentle scree; higher angles keep cliffs mostly intact.", + ["btn-ramp-auto"] = "Autoramp: click an existing cliff to rebuild it at a chosen angle, with wavy edges, erosion gullies and scree buildup at the base. One click per cliff; each click is one undo step.", + ["slider-ar-angle"] = "Target slope of the rebuilt cliff face (10\xc2\xb0\xe2\x80\x9385\xc2\xb0). Low values turn the cliff into a walkable ramp; high values keep it a sheer wall.", + ["slider-ar-falloff"] = "How softly the new face shoulders into the plateaus above and below. Low = hard crisp lips, high = wide rounded blend.", + ["slider-ar-edgenoise"] = "Waviness of the cliff line: perturbs the top and bottom lips so the face meanders instead of running straight.", + ["slider-ar-erosion"] = "Depth of ridged gullies cut down the face, like water-carved channels.", + ["slider-ar-talus"] = "Scree fan banked against the cliff base \xe2\x80\x94 ground buildup from washed-off material.", + ["btn-ar-preview"] = "WYSIWYG preview: while hovering, shows the exact resulting terrain as a translucent mesh \xe2\x80\x94 green where ground is added, orange where it is cut.", + ["btn-ar-start-extend"] = "Cliff start \xe2\x80\x94 Extend: the top lip stays where it is; the new face spills outward over the low ground, never biting into the mesa.", + ["btn-ar-start-subtract"] = "Cliff start \xe2\x80\x94 Subtract: the bottom lip stays where it is; the new face carves back into the mesa top.", + ["btn-ar-start-average"] = "Cliff start \xe2\x80\x94 Average: the face pivots on the cliff's mid line, biting half into the top and spilling half over the bottom.", ["btn-passthrough"] = "Pause all terraform tools and release keyboard/mouse controls back to the game. Click again or any mode button to resume.", + ["btn-focus"] = "Focus mode: hide the game interface (like F5) but keep the Terraformer alive \xe2\x80\x94 panel, brush preview, overlays and skybox switching all stay on. Click again, close the panel or press F5 to bring the interface back.", ["btn-features"] = "Place decorative props like trees, rocks and crystals using the Feature Placer sub-tool.", ["btn-weather"] = "Spawn persistent weather particle effects such as rain, snow or dust with configurable rate and lifetime.", ["btn-environment"] = "Change the skybox texture at runtime. Select from the skybox library or reset to the map default.", @@ -11111,6 +12956,9 @@ local guideHints = { ["btn-surf-preset-fill"] = "FILL: full strength with a hard edge, for blocking out variant areas fast.", ["btn-surf-erase"] = "Erase mode: strokes withdraw the painted claim so the ground returns to the shader's automatic choice. Right-click always erases. To force plain base instead, pick the BASE tile and paint.", ["surf-slider-spacing"] = "Photoshop-style brush spacing: 0 paints continuously, otherwise one stamp every N elmos of drag distance.", + ["surf-slider-scatter-pos"] = "Scatter position: each stamp is offset by up to this many brush radii in a random direction. With Spacing set, one drag lays a dot field instead of a band.", + ["surf-slider-scatter-size"] = "Scatter size: random size variation per stamp, as a fraction of the brush size.", + ["surf-slider-scatter-str"] = "Scatter strength: random strength variation per stamp, as a fraction of the brush strength.", ["btn-ts-cliff-protect"] = "Keep soft strokes (intermediate, plateau) off cliff bodies and foothills — a big brush sweeps around them instead of eating them. One-way: painting CLIFF forces cliff rock anywhere regardless, and the SURFACE brush never touches hard surfaces either way.", ["ts-slider-exposure"] = "Final gain on the lit ground. The shader takes all its light from the map ENVIRONMENT (sun and ground ambient), never from the skybox, and it draws raw albedo where the engine draws a pre-brightened baked texture — so a dark set on a dimly lit map can go nearly black. This lifts it. Run /tileset probe to see whether the map is actually dark before reaching for it; relighting the environment is the honest fix.", ["ts-slider-lumaTops"] = "Whether the brightness bias above also applies to the soft tops. 0 keeps it off them, so how much ground a top takes is authored rather than decided by which top is paler; 1 is the old behaviour. Expect a slightly wider intermediary at 0, since a pale sand no longer gets a free boost against it.", @@ -11257,6 +13105,8 @@ local guideHints = { ["fp-slider-size"] = "Radius of the feature placement area. Ctrl+Scroll to resize while painting.", ["fp-slider-rotation"] = "Base rotation angle for all placed features. Individual randomization is added on top of this value.", ["fp-slider-rot-random"] = "Randomizes each feature's orientation by ±this percentage. 100% = fully random; 0% = all face the same direction.", + ["fp-slider-scale-min"] = "Smallest scale a placed feature can roll; snaps to the nearest baked size variant (trees have them). Most features land near this end — natural stands are mostly small with a few large.", + ["fp-slider-scale-max"] = "Largest scale a placed feature can roll; snaps to the nearest baked size variant (trees have them). With Clustered distribution, large features gather at the clump cores and small ones at the fringes.", ["fp-slider-count"] = "Number of features placed per brush stroke — higher counts fill the area more densely.", ["fp-slider-cadence"] = "How fast features are placed while dragging — lower values produce more features per distance traveled.", -- Feature undo/save/load @@ -12410,7 +14260,7 @@ ctx.syncTBMirrorControls = function(doc, prefix) -- Warn chips on DISPLAY/INSTRUMENTS toggle headers: show when the section -- is collapsed AND at least one mirrored control is engaged. Missing chips -- (tools that never got a warn chip added in RML) silently no-op. - local dispActive = s.gridOverlay or s.heightColormap + local dispActive = s.gridOverlay or s.heightColormap or _imgOv.active() local instActive = s.gridSnap or s.angleSnap or s.measureActive or s.symmetryActive ctx.syncWarnChip(doc, "warn-chip-" .. P .. "-overlays", "section-" .. P .. "-overlays", dispActive) ctx.syncWarnChip(doc, "warn-chip-" .. P .. "-instruments", "section-" .. P .. "-instruments", instActive) @@ -12531,6 +14381,7 @@ local function attachDeclarativeHandlers(_ctx) { "fp-slider-grid-snap-size", "fp-grid-snap-size" }, { "gb-slider-grid-snap-size", "gb-grid-snap-size" }, { "mb-slider-grid-snap-size", "mb-grid-snap-size" }, + { "sf-slider-grid-snap-size", "sf-grid-snap-size" }, { "slider-angle-snap-step", "tf-angle-snap-step" }, { "st-slider-angle-snap-step", "st-angle-snap-step" }, { "cl-slider-angle-snap-step", "cl-angle-snap-step" }, @@ -12541,6 +14392,21 @@ local function attachDeclarativeHandlers(_ctx) { "fp-slider-angle-snap-step", "fp-angle-snap-step" }, { "gb-slider-angle-snap-step", "gb-angle-snap-step" }, { "mb-slider-angle-snap-step", "mb-angle-snap-step" }, + { "sf-slider-angle-snap-step", "sf-angle-snap-step" }, + -- MODIFY/ERODE submode sliders: same data-event-change pattern, same + -- requirement. The drag ids must match the per-sync restamp guards + -- (uiState.draggingSlider ~= id) or the restamp fights the drag. + { "slider-ar-angle", "ar-angle" }, + { "slider-ar-falloff", "ar-falloff" }, + { "slider-ar-edgenoise", "ar-edgenoise" }, + { "slider-ar-erosion", "ar-erosion" }, + { "slider-ar-talus", "ar-talus" }, + { "slider-erode-repose", "erode-repose" }, + -- IMAGE OVERLAY window sliders: same pattern, drag ids match _imgOv.stamp. + { "imgov-slider-opacity", "imgov-opacity" }, + { "imgov-slider-offx", "imgov-offx" }, + { "imgov-slider-offy", "imgov-offy" }, + { "imgov-slider-scale", "imgov-scale" }, } for i = 1, #SNAP_SLIDERS do local el = getCachedEl(doc, SNAP_SLIDERS[i][1]) @@ -13109,6 +14975,9 @@ local function attachEventListeners() if dm then dm.tfVelocityIntensity = false end + if dm then + dm.tfFollowStroke = false + end event:StopPropagation() end, false) end @@ -13122,33 +14991,22 @@ local function attachEventListeners() local lastFilter = "" if presetNameInput then - presetNameInput:AddEventListener("focus", function(event) - WG.TerraformBrushInputFocused = true - Spring.SDLStartTextInput() - widgetState.focusedRmlInput = presetNameInput - end, false) - presetNameInput:AddEventListener("blur", function(event) - WG.TerraformBrushInputFocused = false - Spring.SDLStopTextInput() - widgetState.focusedRmlInput = nil - end, false) + widgetState.wireTextInput(presetNameInput) end -- Save Project name input (FILE > Save Project): same SDL text-input capture -- as the preset input, plus a change listener mirroring into widgetState so -- the confirm handler has the value even if GetAttribute lags the keystroke. + -- The three search / name fields added later (Open Project filter, Light + -- Library filter and its preset name) shipped without the capture above and + -- could not be typed into at all. + widgetState.wireTextInput(getCachedEl(doc, "tf-project-search")) + widgetState.wireTextInput(getCachedEl(doc, "ll-search-input")) + widgetState.wireTextInput(getCachedEl(doc, "input-ll-preset-name")) + local projectNameInput = getCachedEl(doc, "input-project-name") if projectNameInput then - projectNameInput:AddEventListener("focus", function(event) - WG.TerraformBrushInputFocused = true - Spring.SDLStartTextInput() - widgetState.focusedRmlInput = projectNameInput - end, false) - projectNameInput:AddEventListener("blur", function(event) - WG.TerraformBrushInputFocused = false - Spring.SDLStopTextInput() - widgetState.focusedRmlInput = nil - end, false) + widgetState.wireTextInput(projectNameInput) projectNameInput:AddEventListener("change", function(event) widgetState.projectNameStr = projectNameInput:GetAttribute("value") or "" -- Editing the name retargets the save: any armed overwrite confirm @@ -13161,16 +15019,7 @@ local function attachEventListeners() -- game eats every keystroke and the field never types) + change mirror. local newMapNameInput = getCachedEl(doc, "newmap-name-input") if newMapNameInput then - newMapNameInput:AddEventListener("focus", function(event) - WG.TerraformBrushInputFocused = true - Spring.SDLStartTextInput() - widgetState.focusedRmlInput = newMapNameInput - end, false) - newMapNameInput:AddEventListener("blur", function(event) - WG.TerraformBrushInputFocused = false - Spring.SDLStopTextInput() - widgetState.focusedRmlInput = nil - end, false) + widgetState.wireTextInput(newMapNameInput) newMapNameInput:AddEventListener("change", function(event) widgetState.newMapNameStr = newMapNameInput:GetAttribute("value") or "" end, false) @@ -13338,20 +15187,92 @@ local function attachEventListeners() -- tileset preset is just a named snapshot of the knob table, stored in the write-dir -- widget via WG.TilesetTerrain.savePreset/loadPreset. Closures hang on widgetState so -- the model handlers (onTilesetPreset*) can drive them. + -- Sun & Shadows PRESETS dropdown: same shape as the tileset one below. The + -- catalog is rebuilt on every open (user files change on disk); a row click + -- applies with the panel's scope; user rows carry an X that deletes the + -- file. In a do-block: this function is near the Lua 5.1 local/upvalue caps. + do + local envPresetNameInput = getCachedEl(doc, "env-preset-name-input") + local envPresetDropdown = getCachedEl(doc, "env-preset-dropdown") + local envPresetToggleBtn = getCachedEl(doc, "btn-env-preset-toggle") + if envPresetNameInput then + widgetState.wireTextInput(envPresetNameInput) + end + widgetState.setEnvPresetDropdownOpen = function(open) + widgetState.envPresetDropdownOpen = open + if envPresetDropdown then + envPresetDropdown:SetClass("hidden", not open) + end + if envPresetToggleBtn then + envPresetToggleBtn:SetClass("open", open) + end + end + widgetState.rebuildEnvPresetList = function() + if not envPresetDropdown then + return + end + envPresetDropdown.inner_rml = "" + local entries = widgetState.listEnvPresets() + local kindLabel = { sun = "sun only", mood = "map mood", user = "saved" } + local lastKind + for _, entry in ipairs(entries) do + if entry.kind ~= lastKind then + lastKind = entry.kind + local head = doc:CreateElement("div") + head:SetClass("tf-preset-summary", true) + head.inner_rml = kindLabel[entry.kind] or entry.kind + envPresetDropdown:AppendChild(head) + end + local row = doc:CreateElement("div") + row:SetClass("tf-preset-row", true) + if widgetState.envPresetCurrent == entry.name then + row:SetClass("selected", true) + end + local topRow = doc:CreateElement("div") + topRow:SetClass("tf-preset-row-top", true) + local nameEl = doc:CreateElement("div") + nameEl:SetClass("tf-preset-name", true) + nameEl.inner_rml = entry.name:gsub("&", "&"):gsub("<", "<") + topRow:AppendChild(nameEl) + if entry.kind == "user" and entry.path then + local delEl = doc:CreateElement("div") + delEl:SetClass("tf-preset-delete", true) + delEl.inner_rml = "X" + delEl:AddEventListener("click", function(event) + playSound("reset") + os.remove(entry.path) + Spring.Echo("[Environ] deleted environment preset: " .. entry.path) + widgetState.rebuildEnvPresetList() + event:StopPropagation() + end, false) + topRow:AppendChild(delEl) + end + row:AppendChild(topRow) + row:AddEventListener("click", function(event) + playSound("click") + local ok = widgetState.applyEnvPreset(entry, widgetState.envPresetScope or "full") + ---@type table? + local d = widgetState.dmHandle + if d then + d.envPresetHint = ok and ("Applied " .. entry.name) + or ("Could not apply " .. entry.name .. " (see console)") + end + if ok and envPresetNameInput then + envPresetNameInput:SetAttribute("value", entry.name) + end + widgetState.setEnvPresetDropdownOpen(false) + event:StopPropagation() + end, false) + envPresetDropdown:AppendChild(row) + end + end + end + local tsPresetNameInput = getCachedEl(doc, "ts-preset-name-input") local tsPresetDropdown = getCachedEl(doc, "ts-preset-dropdown") local tsPresetToggleBtn = getCachedEl(doc, "btn-ts-preset-toggle") if tsPresetNameInput then - tsPresetNameInput:AddEventListener("focus", function(_e) - WG.TerraformBrushInputFocused = true - Spring.SDLStartTextInput() - widgetState.focusedRmlInput = tsPresetNameInput - end, false) - tsPresetNameInput:AddEventListener("blur", function(_e) - WG.TerraformBrushInputFocused = false - Spring.SDLStopTextInput() - widgetState.focusedRmlInput = nil - end, false) + widgetState.wireTextInput(tsPresetNameInput) end local function setTsDropdownOpen(open) widgetState.tsDropdownOpen = open @@ -13556,6 +15477,7 @@ local function attachEventListeners() makeWindowDraggable("tf-project-handle", getCachedEl(doc, "tf-project-root")) makeWindowDraggable("tf-project-open-handle", getCachedEl(doc, "tf-project-open-root")) makeWindowDraggable("tf-capture-handle", getCachedEl(doc, "tf-capture-root")) + makeWindowDraggable("tf-imgov-handle", getCachedEl(doc, "tf-imgov-root")) end -- ===== Transport (auto-scroll) button listeners ===== @@ -13610,6 +15532,23 @@ local function editorWantsPanel() return false end +-- Open the editor the way the terraformbrush action does: the brush in RAISE. +-- A fresh editor canvas is only ever started to edit it (requested by PtaQ +-- 2026-09-04), so a New Map opens it from its forcestart below and a project +-- load from cmd_map_project's finishLoad (WG.TerraformBrushUI.openEditor). +-- No-op while any tool already has the panel up, so it never yanks a user off +-- the tool they picked. widgetState field: this chunk is near the local cap. +widgetState.openEditor = function() + if editorWantsPanel() then + return + end + ---@type table? + local tf = WG.TerraformBrush + if tf and tf.setMode then + tf.setMode("raise") + end +end + -- Build the panel document on first use. -- -- The RML is ~6200 elements and ~1800 data bindings, and RmlUi carries that in @@ -13656,11 +15595,13 @@ local function ensureDocument() widgetState.rootElement:SetAttribute("style", buildRootStyle()) -- Pen pressure: suppress brush modulation when cursor is over the UI panel widgetState.rootElement:AddEventListener("mouseover", function() + widgetState.mouseOverPanel = true if WG.TerraformBrush then WG.TerraformBrush.setPenOverUI(true) end end, false) widgetState.rootElement:AddEventListener("mouseout", function() + widgetState.mouseOverPanel = false if WG.TerraformBrush then WG.TerraformBrush.setPenOverUI(false) end @@ -13689,7 +15630,8 @@ function widget:Initialize() -- both mean the keep-alive toggle is already effectively ON. do local allyCount = #Spring.GetAllyTeamList() - 1 -- minus gaia - if Spring.GetModOptions().deathmode == "neverend" or allyCount < 2 then + local mo = Spring.GetModOptions() + if mo.deathmode == "neverend" or tostring(mo.editor_sandbox or "") == "1" or allyCount < 2 then widgetState.keepAlive = { active = true } dm.keepAliveStr = "ON" dm.keepAliveActive = true @@ -13704,6 +15646,17 @@ function widget:Initialize() widgetState._pendingFogOff = 15 end + -- Editor canvases have no commander to place (editor_sandbox=1 makes + -- game_initial_spawn skip it), so pregame has nothing to wait for, and + -- pregame clips every ground ray at the flat canvas height (see finishLoad in + -- cmd_map_project.lua): raise terrain before starting and it turns unclickable. + -- Start the game a few draw frames in. Project loads keep their own + -- forcestart at the end of the load pipeline; the countdown consumer skips + -- while one is running. + if _isGeneratedBlankMap() and Spring.GetGameFrame() <= 0 then + widgetState._pendingForceStart = 15 + end + -- The document itself is deferred to ensureDocument(), called from Update the -- first time a tool engages. Everything below is document-independent and has -- to run at boot: prefs, the panel action, and the pending New Map preset all @@ -13713,6 +15666,7 @@ function widget:Initialize() if loadUiPrefs then loadUiPrefs() end + widgetState.pushPerfPrefs() if WG.TerraformBrush then local up = widgetState.uiPrefs local state = WG.TerraformBrush.getState and WG.TerraformBrush.getState() or nil @@ -13741,6 +15695,11 @@ function widget:Initialize() end return true end, nil, "t") + -- /tf_sunlog toggles a traceback on every sun write (see setSunLog). + widgetHandler:AddAction("tf_sunlog", function() + widgetState.setSunLog(not widgetState._sunLogOrig) + return true + end, nil, "t") -- New Map environment preset: if the last Create wrote a pending preset, resolve -- it from the catalog now and arm a short DrawScreen countdown to apply it once @@ -13771,8 +15730,17 @@ function widget:Initialize() end end else - -- New Map with Default environment selected: blank maps often have no - -- map-defined skybox, so apply the first available library skybox. + -- New Map with Default selected. Default is not "leave the engine + -- lighting alone" - that is the flat 0.5 ambient/diffuse placeholder + -- that makes a fresh map look like the shader is broken. It is the + -- canonical sun, applied on the same countdown a mood would use. + local envDef = widgetState.newMapDefaultEnv() + if envDef then + widgetState._pendingEnvApply = envDef + widgetState._pendingEnvCountdown = 15 + end + -- blank maps often have no map-defined skybox, so apply the first + -- available library skybox local first = widgetState.envSkyboxThumbs and widgetState.envSkyboxThumbs[1] if first and first.path then widgetState._pendingSkyboxPath = first.path @@ -13794,6 +15762,11 @@ function widget:Initialize() applyEnvConfig = function(d) return widgetState.applyEnvConfig(d) end, + -- Runtime skybox pick (library path, nil if untouched); the manifest + -- records its basename so reopening the project boots with the same sky. + getCurrentSkybox = function() + return widgetState.envCurrentSkybox + end, -- Start script for opening a map project (blank map at the manifest's -- size with project-local DNTS assets); called by WG.MapProject.open. buildProjectStartScript = function(manifest, slug) @@ -13833,6 +15806,17 @@ function widget:Initialize() isEngaged = function() return widgetState.panelEngaged == true end, + -- FOCUS MODE: the game interface is hidden on purpose and the editor keeps + -- drawing through it. cmd_terraform_brush and the capture widget read this + -- to tell it apart from a plain F5 (see setFocusMode). + isFocusMode = function() + return widgetState.focusMode == true + end, + -- Bring the editor up (brush in RAISE) unless a tool already has the + -- panel; cmd_map_project calls this when a project load completes. + openEditor = function() + widgetState.openEditor() + end, -- Returns the panel pixel bounds in Spring screen coords (Y=0 at bottom). -- Returns nil when the panel is hidden or not yet available. getPanelBounds = function() @@ -13856,102 +15840,42 @@ function widget:Initialize() end -- Spring screen Y: 0=bottom, vsy=top return { - left = leftPx, - right = leftPx + widthPx, - topY = vsy - topPx, - bottomY = vsy - topPx - heightPx, - } - end, - -- Returns bounds of the light library floating window, or nil if not visible. - getLightLibraryBounds = function() - if not widgetState.lightLibraryOpen then - return nil - end - local libRoot = widgetState.lightLibraryRootEl - if not libRoot then - return nil - end - local vsx, vsy = Spring.GetViewGeometry() - local leftPx = libRoot.absolute_left - local topPx = libRoot.absolute_top - local widthPx = libRoot.offset_width - local heightPx = libRoot.offset_height - if not leftPx or widthPx == 0 or heightPx == 0 then - return nil - end - return { - left = leftPx, - right = leftPx + widthPx, - topY = vsy - topPx, - bottomY = vsy - topPx - heightPx, - } - end, - } -end - -local lastUpdateClock = Spring.GetTimer() - -function widget:DrawScreen() - -- New Map environment preset: apply once, a few frames after a fresh-map reload - -- (gives the water renderer time to come up). Frame-counted rather than gated on - -- a game frame so it works while the editor is paused. - if widgetState._pendingEnvApply then - widgetState._pendingEnvCountdown = (widgetState._pendingEnvCountdown or 0) - 1 - if widgetState._pendingEnvCountdown <= 0 then - local p = widgetState._pendingEnvApply - widgetState._pendingEnvApply = nil - widgetState.applyEnvConfig(p) - Spring.Echo("[Terraform Brush] Applied environment preset: " .. (p.name or "?")) - end - end - - -- Placeholder-fog suppression: disable fog a few frames after (re)load. Separate - -- from the preset apply above so it also fires on a plain luaui reload (no preset). - if widgetState._pendingFogOff then - widgetState._pendingFogOff = widgetState._pendingFogOff - 1 - if widgetState._pendingFogOff <= 0 then - widgetState._pendingFogOff = nil - widgetState.disableFog() - end - end - - -- Deferred skybox apply: RmlUI click fires from Update, so gl.Texture must be - -- done here in DrawScreen. Register the DDS in the GL named-texture cache so - -- Spring.SetSkyBoxTexture (which calls CNamedTextures::GetInfo) can find it. - if widgetState._pendingSkyboxPath then - local rawTex = widgetState._pendingSkyboxPath - local tex = rawTex - widgetState._pendingSkyboxPath = nil - if tex ~= "" then - local bound = nil - local candidates = { - tex, - ":r:" .. tex, - ":l:" .. tex, - "maps/" .. tex, - ":r:maps/" .. tex, - ":l:maps/" .. tex, + left = leftPx, + right = leftPx + widthPx, + topY = vsy - topPx, + bottomY = vsy - topPx - heightPx, } - for _, name in ipairs(candidates) do - if gl.Texture(name) then - gl.Texture(false) - bound = name - break - end + end, + -- Returns bounds of the light library floating window, or nil if not visible. + getLightLibraryBounds = function() + if not widgetState.lightLibraryOpen then + return nil end - if not bound then - Spring.Echo("[Terraform Brush] Skybox bind failed: " .. tex) - else - tex = bound + local libRoot = widgetState.lightLibraryRootEl + if not libRoot then + return nil end - end - if widgetState.envFadeEnabled then - startSkyboxFade(tex, rawTex) - else - applySkyboxNow(tex, rawTex) - end - end + local vsx, vsy = Spring.GetViewGeometry() + local leftPx = libRoot.absolute_left + local topPx = libRoot.absolute_top + local widthPx = libRoot.offset_width + local heightPx = libRoot.offset_height + if not leftPx or widthPx == 0 or heightPx == 0 then + return nil + end + return { + left = leftPx, + right = leftPx + widthPx, + topY = vsy - topPx, + bottomY = vsy - topPx - heightPx, + } + end, + } +end + +local lastUpdateClock = Spring.GetTimer() +function widget:DrawScreen() -- NOTE: DDS skybox preloading removed. Spring.SetSkyBoxTexture() loads the -- DDS file directly via the engine; eagerly binding all cubemaps into GL -- exhausted the TexMemPool (512 MB) when many large skyboxes were present, @@ -14109,6 +16033,19 @@ local function drawSkyboxThumbnailPreviews() if not widgetState.skyboxLibraryOpen then return end + -- PANEL DOWN = NOTHING TO OVERLAY. dm.activeTool is only refreshed while the + -- panel is visible, so after closing the Terraformer it still reads as the + -- last tool, and these elements still report their last layout box - the + -- thumbs then hang in the world over the map (reported 2026-08-22). Both + -- checks are cheap: panelEngaged is what the sync itself uses, and the root + -- element carries the class the same sync sets. + if not widgetState.panelEngaged then + return + end + local rootEl = widgetState.rootElement + if rootEl and rootEl:IsClassSet("hidden") then + return + end local thumbs = widgetState.envSkyboxThumbs if not thumbs or #thumbs == 0 then return @@ -14181,7 +16118,7 @@ local function drawSkyboxThumbnailPreviews() local y = el.absolute_top local w = el.offset_width local h = el.offset_height - if w > 4 and h > 4 then + if w > 4 and h > 4 and not widgetState.underFileMenu(x, y, w, h) then local glY1 = vsy - y - h local glY2 = vsy - y -- gl.Texture returns true on success; cubemap DDS loads as TEXTURE_CUBE_MAP @@ -14216,6 +16153,19 @@ local function drawSurfPaletteThumbs() if widgetState.lobbyHidden then return end + -- PANEL DOWN = NOTHING TO OVERLAY. dm.activeTool is only refreshed while the + -- panel is visible, so after closing the Terraformer it still reads as the + -- last tool, and these elements still report their last layout box - the + -- thumbs then hang in the world over the map (reported 2026-08-22). Both + -- checks are cheap: panelEngaged is what the sync itself uses, and the root + -- element carries the class the same sync sets. + if not widgetState.panelEngaged then + return + end + local rootEl = widgetState.rootElement + if rootEl and rootEl:IsClassSet("hidden") then + return + end -- Draw call-ins do NOT auto-hide with RmlUi layout, and an element that is -- not laid out can still report a stale non-zero box (the splat-preview -- lesson), so every container that can hide these thumbs must be tested @@ -14253,9 +16203,74 @@ local function drawSurfPaletteThumbs() if w > 0 and h > 0 then local x = div.absolute_left local y = div.absolute_top - if gl.Texture(0, tex) then - -- centered crop: a full 4K tile at 52dp reads as noise, - -- a quarter-window shows the material's actual character + if not widgetState.underFileMenu(x, y, w, h) and gl.Texture(0, tex) then + -- centered crop: a full 4K tile at 52dp reads as noise, so a + -- quarter-window shows the material's actual character. + -- Entries may widen it (the picker's hover preview is big + -- enough to want the whole tile). + local u0 = els[i].u0 or 0.25 + local u1 = els[i].u1 or 0.75 + gl.TexRect(x, vsy - y - h, x + w, vsy - y, u0, u0, u1, u1) + gl.Texture(0, false) + end + end + end + end + if clipped then + gl.Scissor(false) + end + gl.Blending(false) + gl.Color(1, 1, 1, 1) +end + +-- GL albedo thumbnails for the EXTRA LAYER material tiles (tf_tileset.lua's +-- rebuildS4Palette). Same mechanism as drawSurfPaletteThumbs above, but gated +-- on the TILESET floating window, not the active tool — the window is +-- tool-independent by design. On widgetState, not a local: the main chunk sits +-- near Lua 5.1's 200-local ceiling. +widgetState.drawTs4PaletteThumbs = function() + local dm = widgetState.dmHandle + if not dm or not dm.envTilesetVisible then + return + end + -- OFF mode grays the row out via the disabled class; GL overdraw ignores + -- CSS opacity, so it has to skip explicitly. + if dm.tsSlot4Mode == "off" then + return + end + if widgetState.lobbyHidden or not widgetState.document then + return + end + local rootEl = widgetState.rootElement + if rootEl and rootEl:IsClassSet("hidden") then + return + end + local sec = widgetState.ts4SectionEl + if not sec or sec:IsClassSet("hidden") then + return + end + local els = widgetState.ts4PaletteEls + if not els or #els == 0 then + return + end + local _, vsy = Spring.GetViewGeometry() + gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + gl.Color(1, 1, 1, 1) + local clipped = widgetState.pushPanelClip(els[1].el) + for i = 1, #els do + local div = els[i].el + local tex = els[i].tex + if div and tex then + -- collapsed sections / hidden windows report zero size (the same + -- guard the surf palette relies on) + local w = div.offset_width + local h = div.offset_height + if w > 0 and h > 0 then + local x = div.absolute_left + local y = div.absolute_top + if not widgetState.underFileMenu(x, y, w, h) and gl.Texture(0, tex) then + -- centered quarter-window crop, like the surf tiles: a full + -- 4K tile at 52dp reads as noise gl.TexRect(x, vsy - y - h, x + w, vsy - y, 0.25, 0.25, 0.75, 0.75) gl.Texture(0, false) end @@ -14269,13 +16284,172 @@ local function drawSurfPaletteThumbs() gl.Color(1, 1, 1, 1) end +-- GL thumbnails for the BIOME LIBRARY tiles (tf_tileset.lua's rebuildBiomePalette): +-- the shipped biome_.png or a manifest `thumb` drawn whole, or the base +-- layer's albedo as a centered crop when a biome has neither. Same mechanism and +-- gates as drawTs4PaletteThumbs above. +widgetState.drawTsBiomeThumbs = function() + local dm = widgetState.dmHandle + if not dm or not dm.envTilesetVisible then + return + end + if widgetState.lobbyHidden or not widgetState.document then + return + end + local rootEl = widgetState.rootElement + if rootEl and rootEl:IsClassSet("hidden") then + return + end + local sec = widgetState.tsBiomeSectionEl + if not sec or sec:IsClassSet("hidden") then + return + end + local els = widgetState.tsBiomeTileEls + if not els or #els == 0 then + return + end + local _, vsy = Spring.GetViewGeometry() + gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + gl.Color(1, 1, 1, 1) + local clipped = widgetState.pushPanelClip(els[1].el) + for i = 1, #els do + local div = els[i].el + local tex = els[i].tex + if div and tex then + local w = div.offset_width + local h = div.offset_height + if w > 0 and h > 0 then + local x = div.absolute_left + local y = div.absolute_top + if not widgetState.underFileMenu(x, y, w, h) and gl.Texture(0, tex) then + if els[i].crop then + -- a 4K albedo at 60dp reads as noise: centered quarter crop + gl.TexRect(x, vsy - y - h, x + w, vsy - y, 0.25, 0.25, 0.75, 0.75) + else + gl.TexRect(x, vsy - y - h, x + w, vsy - y, 0, 1, 1, 0) + end + gl.Texture(0, false) + end + end + end + end + if clipped then + gl.Scissor(false) + end + gl.Blending(false) + gl.Color(1, 1, 1, 1) +end + +-- Deferred applies that need a draw call-in (gl.Texture) or a frame count after +-- a reload. Drained from DrawScreenPost, NOT DrawScreen: the widget handler +-- skips DrawScreen while the interface is hidden (barwidgets.lua, IsGUIHidden) +-- and FOCUS MODE hides it on purpose, which used to leave a skybox pick parked +-- until the HUD came back and would stall a New Map reload's env preset, +-- fog-off and forcestart the same way. DrawScreenPost runs right after +-- DrawScreen in the same frame, so nothing else moves. +widgetState.drainDeferredApplies = function() + -- New Map environment preset: apply once, a few frames after a fresh-map reload + -- (gives the water renderer time to come up). Frame-counted rather than gated on + -- a game frame so it works while the editor is paused. + if widgetState._pendingEnvApply then + widgetState._pendingEnvCountdown = (widgetState._pendingEnvCountdown or 0) - 1 + if widgetState._pendingEnvCountdown <= 0 then + local p = widgetState._pendingEnvApply + widgetState._pendingEnvApply = nil + widgetState.applyEnvConfig(p) + Spring.Echo("[Terraform Brush] Applied environment preset: " .. ((p and p.name) or "?")) + end + end + + -- Placeholder-fog suppression: disable fog a few frames after (re)load. Separate + -- from the preset apply above so it also fires on a plain luaui reload (no preset). + if widgetState._pendingFogOff then + widgetState._pendingFogOff = widgetState._pendingFogOff - 1 + if widgetState._pendingFogOff <= 0 then + widgetState._pendingFogOff = nil + widgetState.disableFog() + end + end + + -- Leave pregame on editor canvases (armed in Initialize). A project load + -- started from its pointer file owns the forcestart itself. + if widgetState._pendingForceStart then + widgetState._pendingForceStart = widgetState._pendingForceStart - 1 + if widgetState._pendingForceStart <= 0 then + widgetState._pendingForceStart = nil + ---@type table? + local mp = WG.MapProject + local loading = mp and mp.isLoading and mp.isLoading() + if not loading then + if Spring.GetGameFrame() <= 0 then + Spring.Echo( + "[Terraform Brush] starting the editor session: no commander to place, and pregame keeps terrain above the canvas base unclickable" + ) + Spring.SendCommands("forcestart") + end + -- New Map: the canvas is playable now, bring the editor up. + widgetState.openEditor() + end + end + end + + -- Deferred skybox apply: RmlUI click fires from Update, so gl.Texture must be + -- done from a draw call-in. Register the DDS in the GL named-texture cache so + -- Spring.SetSkyBoxTexture (which calls CNamedTextures::GetInfo) can find it. + if widgetState._pendingSkyboxPath then + local rawTex = widgetState._pendingSkyboxPath + local tex = rawTex + widgetState._pendingSkyboxPath = nil + if tex ~= "" then + local bound = nil + local candidates = { + tex, + ":r:" .. tex, + ":l:" .. tex, + "maps/" .. tex, + ":r:maps/" .. tex, + ":l:maps/" .. tex, + } + for _, name in ipairs(candidates) do + if gl.Texture(name) then + gl.Texture(false) + bound = name + break + end + end + if not bound then + Spring.Echo("[Terraform Brush] Skybox bind failed: " .. tex) + else + tex = bound + end + end + if widgetState.envFadeEnabled then + startSkyboxFade(tex, rawTex) + else + applySkyboxNow(tex, rawTex) + end + end +end + function widget:DrawScreenPost() + -- Skybox pick, New Map env preset, fog-off, forcestart (see the definition). + widgetState.drainDeferredApplies() + + -- FILE dropdown box, read once for every pass below to skip tiles under it. + widgetState.measureFileMenuBox() + -- GL-rendered cubemap previews for skybox tiles without a separate preview image. drawSkyboxThumbnailPreviews() -- SURFACE palette tile thumbnails (early-outs on its own tool check). drawSurfPaletteThumbs() + -- EXTRA LAYER material tile thumbnails (early-outs on its own window check). + widgetState.drawTs4PaletteThumbs() + + -- BIOME LIBRARY tile thumbnails (same gates). + widgetState.drawTsBiomeThumbs() + -- Render splat detail texture previews into the channel div elements. -- Only render when splat tool is active; avoids gl.* overlay leaking over other tools/panels. local dm = widgetState.dmHandle @@ -14290,6 +16464,11 @@ function widget:DrawScreenPost() if widgetState.lobbyHidden then return end + -- ...and the root element carries the hidden class the same sync sets + local rootEl = widgetState.rootElement + if rootEl and rootEl:IsClassSet("hidden") then + return + end -- The Channel section can be collapsed independently of the tool being -- active. Draw* call-ins don't auto-hide with the panel (the engine only @@ -14671,7 +16850,7 @@ function widget:DrawScreenPost() end end - local vsx, vsy = Spring.GetViewGeometry() + local vsx, vsy = GetViewGeometry() local shader = widgetState.spPreviewShader @@ -14738,7 +16917,7 @@ function widget:DrawScreenPost() gl.UniformInt(widgetState.spPreviewShaderChannelLoc, i - 1) end - local bound = gl.Texture(0, tex) + local bound = not widgetState.underFileMenu(x, y, w, h) and gl.Texture(0, tex) if logDraw then Spring.Echo("[TFBrush] gl.Texture(0, " .. tex .. ") = " .. tostring(bound)) @@ -14853,6 +17032,8 @@ local HEIGHT_BAND_SLIDERS = { "sp-slider-alt-max", "surf-hard-slider-alt-min", "surf-hard-slider-alt-max", + "surf-slider-inf-alt-min", + "surf-slider-inf-alt-max", } -- Widen those sliders to a padded envelope of the map's real height range, @@ -14900,6 +17081,12 @@ function widget:Update() end end + -- Performance / clay prefs reach the brush widget once it exists (it may + -- load after this panel). + if not widgetState.perfPrefsPushed and WG.TerraformBrush and WG.TerraformBrush.setPerfMode then + widgetState.pushPerfPrefs() + end + -- Keep-match-alive / remove-all-units pump (Settings > General). Both need -- /cheat OBSERVED on: "cheat" TOGGLES, so it is only (re)sent while observed -- off, with a resend gap and an attempt cap (same rule as the project load @@ -15086,7 +17273,7 @@ function widget:Update() end -- When game chat input is open, auto-blur any focused RmlUI text input so - -- keystrokes reach the chat widget instead of navigating RmlUI fields. + -- Tab reaches the chat widget for autocomplete instead of navigating RmlUI fields. if widgetState.focusedRmlInput and WG.chat and WG.chat.isInputActive() then widgetState.focusedRmlInput:Blur() widgetState.focusedRmlInput = nil @@ -15386,10 +17573,19 @@ function widget:Update() -- cmd_terraform_brush checks isEngaged() before tool-switch handling, so a -- dormant Terraformer leaves f/m/g/etc. to the engine's own keybinds. widgetState.panelEngaged = panelVisible and true or false + widgetState.syncFocusMode(panelVisible, widgetState.panelHidden) if widgetState.rootElement then widgetState.rootElement:SetClass("hidden", not panelVisible) end if not panelVisible then + -- The water level preview plane is drawn in the world by the other + -- widget, so hiding the panel has to take it down explicitly. + if widgetState.envWaterPreviewAt ~= nil then + widgetState.envWaterPreviewAt = nil + if WG.TerraformBrush and WG.TerraformBrush.setWaterLevelPreview then + WG.TerraformBrush.setWaterLevelPreview(nil) + end + end -- Clear any locked sliders when panel hides if next(widgetState.lockedSliders) then for id, element in pairs(widgetState.lockedSliders) do @@ -15550,6 +17746,57 @@ function widget:Update() setDm("envWaterVisible", widgetState.envWaterOpen or false) setDm("envDimensionsVisible", widgetState.envDimensionsOpen or false) setDm("envTilesetVisible", widgetState.envTilesetOpen or false) + -- IMAGE overlay: chip state on every DISPLAY row + the window readouts. + _imgOv.sync(setDm) + -- Dimensions window open edge: seed the HEIGHT RANGE sliders with + -- the range they are about to change. + if widgetState.envDimensionsOpen and not widgetState.envDimWasOpen then + widgetState.envDimWasOpen = true + if widgetState.envFillDimRangeInputs then + widgetState.envFillDimRangeInputs() + end + elseif not widgetState.envDimensionsOpen then + widgetState.envDimWasOpen = false + end + -- Shoreline machinery runs while EITHER window holding a track is + -- open: WATER LEVEL lives in Dimensions, its FLUID LEVEL mirror in + -- Water. The extremes/plane readouts poll here too — they are the + -- only feedback that a range or water edit landed, and the sim + -- applies it a frame or two after the click (GetGroundExtremes is + -- an engine-cached read). + if widgetState.envDimensionsOpen or widgetState.envWaterOpen then + if not widgetState.envWaterUIWasOpen then + widgetState.envWaterUIWasOpen = true + -- Seed on the open edge, but never over a live preview: the + -- other window may already be mid-adjustment on its track. + if widgetState.envWaterPreviewAt == nil and widgetState.envSeedWaterSlider then + widgetState.envSeedWaterSlider() + end + end + widgetState.envDimTick = (widgetState.envDimTick or 0) + 1 + if widgetState.envDimTick >= 10 and widgetState.envRefreshDimExtremes then + widgetState.envDimTick = 0 + widgetState.envRefreshDimExtremes() + end + -- Reseed after an apply, once the sim has moved the terrain the + -- slider's bounds were measured against. + if (widgetState.envWaterReseedTicks or 0) > 0 then + widgetState.envWaterReseedTicks = widgetState.envWaterReseedTicks - 1 + if widgetState.envWaterReseedTicks == 0 and widgetState.envSeedWaterSlider then + widgetState.envSeedWaterSlider() + end + end + if widgetState.envSyncWaterPreview then + widgetState.envSyncWaterPreview() + end + elseif widgetState.envWaterUIWasOpen then + widgetState.envWaterUIWasOpen = false + widgetState.envWaterReseedTicks = 0 + widgetState.envWaterPreviewAt = nil + if WG.TerraformBrush and WG.TerraformBrush.setWaterLevelPreview then + WG.TerraformBrush.setWaterLevelPreview(nil) + end + end -- light library already driven by dm.lpLibraryOpen in tf_lights; just reset widgetState when tool inactive if not lpActive and widgetState.lightLibraryOpen then widgetState.lightLibraryOpen = false @@ -15567,6 +17814,10 @@ function widget:Update() or widgetState.surfActive or widgetState.surfHardActive setDm("tfShapeRowVisible", not hideShape) + setDm( + "tfFollowVisible", + (not hideShape) and tfActive and tfState and _tbFollowModes[tfState.mode] and true or false + ) -- smooth submodes: visible only in smooth/level terraform mode local otherToolActive = fpActive or wbActive @@ -15579,7 +17830,9 @@ function widget:Update() or clActive or decalsActive or widgetState.surfActive - local inSmoothGroup = tfActive and tfState and (tfState.mode == "smooth" or tfState.mode == "level") + local inSmoothGroup = tfActive + and tfState + and (tfState.mode == "smooth" or tfState.mode == "level" or tfState.mode == "smudge") setDm("tfSmoothSubmodesVisible", not otherToolActive and inSmoothGroup and true or false) -- erode controls: visible only in erode terraform mode local inErode = tfActive and tfState and tfState.mode == "erode" @@ -15601,8 +17854,6 @@ function widget:Update() end end -- if panelVisible - local dcActive = widgetState.decalsActive - -- Toggle noise floating window local noiseActive = tfActive and tfState.mode == "noise" if noiseActive and not widgetState.lastNoiseActive then @@ -15776,6 +18027,14 @@ function widget:Update() if widgetState.dmHandle.tfShapeRowVisible ~= not hideShape2 then widgetState.dmHandle.tfShapeRowVisible = not hideShape2 end + -- Same predicate as the shape row plus the modes whose drag runs the stroke + -- resampler: this reset block re-opens the shape row every frame, so the + -- FOLLOW chip has to be recomputed alongside it. + local followVis = not hideShape2 and tfActive and tfState and _tbFollowModes[tfState.mode] and true + or false + if widgetState.dmHandle.tfFollowVisible ~= followVis then + widgetState.dmHandle.tfFollowVisible = followVis + end end end @@ -15880,6 +18139,8 @@ function widget:Update() elseif widgetState.surfActive then if tfSurface then tfSurface.sync(doc, ctx, WG.SurfacePainter and WG.SurfacePainter.getState(), setSummary) + -- AUTOMATIC DEPOSIT rows under FILL AND SEED are tileset knobs (ts-* ids) + tfTileset.syncDeposit(doc, ctx) end elseif wbState and wbState.active then -- Weather Brush has no M.sync; drive mirror chips directly here. @@ -16028,8 +18289,9 @@ function widget:Update() "btn-wb-persist-up", }, remove) end - elseif tfActive then + elseif tfActive and not widgetState.mirrorStrided(tfState) then -- ===== Terraform mode: update terraform controls ===== + -- (skipped on strided frames mid-drag, see widgetState.mirrorStrided) local state = tfState local effectiveMaxIntensity = getEffectiveMaxIntensity() @@ -16223,12 +18485,12 @@ function widget:Update() local sliderCapMax = getCachedEl(doc, "slider-cap-max") if sliderCapMax and ds ~= "capmax" then - sliderCapMax:SetAttribute("value", tostring(capMaxValue)) + setAttrValueIfChanged(sliderCapMax, "slider-cap-max", tostring(capMaxValue)) end local sliderCapMin = getCachedEl(doc, "slider-cap-min") if sliderCapMin and ds ~= "capmin" then - sliderCapMin:SetAttribute("value", tostring(capMinValue)) + setAttrValueIfChanged(sliderCapMin, "slider-cap-min", tostring(capMinValue)) end local dm = widgetState.dmHandle if dm then @@ -16248,7 +18510,7 @@ function widget:Update() maxVal = 1 end sliderHistory:SetAttribute("max", tostring(maxVal)) - sliderHistory:SetAttribute("value", tostring(state.undoCount or 0)) + setAttrValueIfChanged(sliderHistory, "slider-history", tostring(state.undoCount or 0)) end local clayImg = getCachedEl(doc, "btn-clay-mode") @@ -16275,7 +18537,11 @@ function widget:Update() end local sliderSnapSizeSync = getCachedEl(doc, "slider-grid-snap-size") if sliderSnapSizeSync and uiState.draggingSlider ~= "tf-grid-snap-size" then - sliderSnapSizeSync:SetAttribute("value", tostring(state.gridSnapSize or 48)) + setAttrValueIfChanged( + sliderSnapSizeSync, + "slider-grid-snap-size", + tostring(state.gridSnapSize or 48) + ) end if widgetState.dmHandle then local v = tostring(state.gridSnapSize or 48) @@ -16285,7 +18551,11 @@ function widget:Update() end local snapSizeNb = getCachedEl(doc, "slider-grid-snap-size-numbox") if snapSizeNb then - snapSizeNb:SetAttribute("value", tostring(state.gridSnapSize or 48)) + setAttrValueIfChanged( + snapSizeNb, + "slider-grid-snap-size-numbox", + tostring(state.gridSnapSize or 48) + ) end -- Protractor state sync @@ -16316,7 +18586,7 @@ function widget:Update() local curStr = (curStep == math.floor(curStep)) and tostring(math.floor(curStep)) or tostring(curStep) local sliderAngleStepSync = getCachedEl(doc, "slider-angle-snap-step") if sliderAngleStepSync and uiState.draggingSlider ~= "tf-angle-snap-step" then - sliderAngleStepSync:SetAttribute("value", tostring(curIdx - 1)) + setAttrValueIfChanged(sliderAngleStepSync, "slider-angle-snap-step", tostring(curIdx - 1)) end if widgetState.dmHandle then if widgetState.dmHandle.tbAngleSnapStepStr ~= curStr then @@ -16325,7 +18595,7 @@ function widget:Update() end local angleStepNb = getCachedEl(doc, "slider-angle-snap-step-numbox") if angleStepNb then - angleStepNb:SetAttribute("value", curStr) + setAttrValueIfChanged(angleStepNb, "slider-angle-snap-step-numbox", curStr) end -- Autosnap toggle + manual spoke sync @@ -16428,7 +18698,11 @@ function widget:Update() end local symCountSlider = getCachedEl(doc, "slider-symmetry-radial-count") if symCountSlider then - symCountSlider:SetAttribute("value", tostring(state.symmetryRadialCount or 2)) + setAttrValueIfChanged( + symCountSlider, + "slider-symmetry-radial-count", + tostring(state.symmetryRadialCount or 2) + ) end if widgetState.dmHandle then local v = tostring(math.floor(state.symmetryMirrorAngle or 0)) @@ -16438,7 +18712,11 @@ function widget:Update() end local mirrorAngleSlider = getCachedEl(doc, "slider-symmetry-mirror-angle") if mirrorAngleSlider then - mirrorAngleSlider:SetAttribute("value", tostring(state.symmetryMirrorAngle or 0)) + setAttrValueIfChanged( + mirrorAngleSlider, + "slider-symmetry-mirror-angle", + tostring(state.symmetryMirrorAngle or 0) + ) end local hasAxial = state.symmetryMirrorX or state.symmetryMirrorY if widgetState.dmHandle then @@ -16499,6 +18777,10 @@ function widget:Update() dm.tfVelocityIntensity = state.velocityIntensity == true end + if dm then + dm.tfFollowStroke = state.followStroke == true + end + do local penEnabled = state.penPressureEnabled == true local pm = state.penPressureMapped or state.penPressure or 0 @@ -16583,10 +18865,18 @@ function widget:Update() ctx.setDisabled(doc, "param-rotation-row", rotationIrrelevant) -- Length irrelevant for circle/fill shapes (no directional footprint to stretch) ctx.setDisabled(doc, "param-length-row", (tShape == "circle") or (tShape == "fill")) - -- Intensity meaningful for raise/lower/smooth/noise/ramp/restore; irrelevant only for level - ctx.setDisabled(doc, "param-intensity-row", tMode == "level") - -- Height cap (min/max) irrelevant for ramp and restore modes - ctx.setDisabled(doc, "section-heightcap", tMode == "ramp" or tMode == "restore") + -- Intensity meaningful for raise/lower/smooth/noise/ramp/restore; + -- irrelevant for level and for autoramp (one-shot region op) + ctx.setDisabled(doc, "param-intensity-row", tMode == "level" or tMode == "autoramp") + -- Autoramp has its own Falloff knob in the AUTORAMP block; the + -- global FALL-OFF curve does not feed it + ctx.setDisabled(doc, "param-falloff-row", tMode == "autoramp") + -- Height cap (min/max) irrelevant for ramp, restore and autoramp modes + ctx.setDisabled( + doc, + "section-heightcap", + tMode == "ramp" or tMode == "restore" or tMode == "autoramp" + ) end uiState.updatingFromCode = false @@ -16594,7 +18884,9 @@ function widget:Update() local dm = widgetState.dmHandle do - local primaryKey = (state.mode == "level") and "smooth" or state.mode + local primaryKey = (state.mode == "level" or state.mode == "smudge") and "smooth" + or (state.mode == "autoramp") and "ramp" + or state.mode if dm and dm.activeMode ~= primaryKey then dm.activeMode = primaryKey end @@ -16605,17 +18897,26 @@ function widget:Update() -- Smooth/Level submode active chip sync (visibility handled below, after tool-active checks) do - local inSmoothGroup = state.mode == "smooth" or state.mode == "level" + local inSmoothGroup = state.mode == "smooth" or state.mode == "level" or state.mode == "smudge" local v = (inSmoothGroup and state.mode) or "" if dm and dm.activeSmoothMode ~= v then dm.activeSmoothMode = v end end - -- Show ramp-type-row when in ramp mode; hide normal shape row + -- Show ramp-type-row when in a ramp mode (incl. autoramp); hide normal shape row do - local isRamp = state.mode == "ramp" + local isRamp = state.mode == "ramp" or state.mode == "autoramp" + local rampType = "" + if state.mode == "autoramp" then + rampType = "auto" + elseif state.mode == "ramp" then + rampType = (state.shape == "circle") and "spline" or "straight" + end if widgetState.dmHandle then + if widgetState.dmHandle.tfRampType ~= rampType then + widgetState.dmHandle.tfRampType = rampType + end if widgetState.dmHandle.tfRampMode ~= isRamp then widgetState.dmHandle.tfRampMode = isRamp end @@ -16624,7 +18925,7 @@ function widget:Update() end end end - -- Ramp type active state driven by dm.activeShape (data-class-active in RML) + -- Ramp type active state driven by dm.tfRampType (data-class-active in RML) -- D4: Update contextual status summary line do @@ -16635,10 +18936,12 @@ function widget:Update() lower = "#ef4444", level = "#fdc04c", smooth = "#fdc04c", + smudge = "#fdc04c", ramp = "#fdc04c", restore = "#fdc04c", noise = "#fdc04c", erode = "#fdc04c", + autoramp = "#fdc04c", } local m = state.mode or "---" local mc = modeColors[m] or "#9ca3af" @@ -16692,7 +18995,7 @@ function widget:Update() local noiseSliderScale = getCachedEl(doc, "slider-noise-scale") if noiseSliderScale and ds ~= "noise-scale" then - noiseSliderScale:SetAttribute("value", tostring(state.noiseScale)) + setAttrValueIfChanged(noiseSliderScale, "slider-noise-scale", tostring(state.noiseScale)) end if dm then local v = tostring(state.noiseScale) @@ -16703,7 +19006,7 @@ function widget:Update() local noiseSliderOctaves = getCachedEl(doc, "slider-noise-octaves") if noiseSliderOctaves and ds ~= "noise-octaves" then - noiseSliderOctaves:SetAttribute("value", tostring(state.noiseOctaves)) + setAttrValueIfChanged(noiseSliderOctaves, "slider-noise-octaves", tostring(state.noiseOctaves)) end if dm then local v = tostring(state.noiseOctaves) @@ -16714,7 +19017,11 @@ function widget:Update() local noiseSliderPersist = getCachedEl(doc, "slider-noise-persistence") if noiseSliderPersist and ds ~= "noise-persistence" then - noiseSliderPersist:SetAttribute("value", tostring(math.floor(state.noisePersistence * 100 + 0.5))) + setAttrValueIfChanged( + noiseSliderPersist, + "slider-noise-persistence", + tostring(math.floor(state.noisePersistence * 100 + 0.5)) + ) end if dm then local v = string.format("%.2f", state.noisePersistence) @@ -16725,7 +19032,11 @@ function widget:Update() local noiseSliderLacun = getCachedEl(doc, "slider-noise-lacunarity") if noiseSliderLacun and ds ~= "noise-lacunarity" then - noiseSliderLacun:SetAttribute("value", tostring(math.floor(state.noiseLacunarity * 10 + 0.5))) + setAttrValueIfChanged( + noiseSliderLacun, + "slider-noise-lacunarity", + tostring(math.floor(state.noiseLacunarity * 10 + 0.5)) + ) end if dm then local v = string.format("%.1f", state.noiseLacunarity) @@ -16736,7 +19047,7 @@ function widget:Update() local noiseSliderSeed = getCachedEl(doc, "slider-noise-seed") if noiseSliderSeed and ds ~= "noise-seed" then - noiseSliderSeed:SetAttribute("value", tostring(state.noiseSeed)) + setAttrValueIfChanged(noiseSliderSeed, "slider-noise-seed", tostring(state.noiseSeed)) end if dm then local v = tostring(state.noiseSeed) @@ -16754,7 +19065,10 @@ function widget:Update() uiState.updatingFromCode = true local erodeSlider = getCachedEl(doc, "slider-erode-repose") if erodeSlider and uiState.draggingSlider ~= "erode-repose" then - erodeSlider:SetAttribute("value", tostring(state.erodeReposeDeg)) + -- Dirty-checked: an unconditional stamp raises a deferred change + -- event every sync pass (after updatingFromCode is already + -- cleared), re-entering the slider handler each frame. + setAttrValueIfChanged(erodeSlider, "slider-erode-repose", tostring(state.erodeReposeDeg)) end if dm then local v = tostring(state.erodeReposeDeg) .. "\xc2\xb0" @@ -16765,6 +19079,40 @@ function widget:Update() uiState.updatingFromCode = false end + -- Sync the autoramp sliders from state when in autoramp mode; the + -- percent knobs are stored 0–1 widget-side, shown 0–100 here. + if state.mode == "autoramp" and state.autorampAngleDeg then + uiState.updatingFromCode = true + local arSync = { + { "ar-angle", state.autorampAngleDeg }, + { "ar-falloff", (state.autorampFalloff or 0.5) * 100 }, + { "ar-edgenoise", (state.autorampEdgeNoise or 0.35) * 100 }, + { "ar-erosion", (state.autorampErosion or 0.35) * 100 }, + { "ar-talus", (state.autorampTalus or 0.4) * 100 }, + } + for i = 1, #arSync do + local id, val = arSync[i][1], arSync[i][2] + local sl = getCachedEl(doc, "slider-" .. id) + if sl and uiState.draggingSlider ~= id then + -- Dirty-checked (cache keyed by element id, which is what + -- trackSliderDrag invalidates on mouseup): an unconditional + -- stamp raises a deferred change event every sync pass. + setAttrValueIfChanged(sl, "slider-" .. id, tostring(math.floor(val + 0.5))) + end + end + if dm then + local st = state.autorampStart or "average" + if dm.arStart ~= st then + dm.arStart = st + end + local pv = state.autorampPreview and true or false + if dm.arPreview ~= pv then + dm.arPreview = pv + end + end + uiState.updatingFromCode = false + end + -- Clear feature mode highlights local featuresBtn = doc and getCachedEl(doc, "btn-features") if featuresBtn then @@ -16850,12 +19198,12 @@ function widget:Update() local exportMinInput = doc and getCachedEl(doc, "input-tf-export-min") if exportMinInput and widgetState.focusedRmlInput ~= exportMinInput then local minStr = string.format("%.2f", state.exportCustomMin or 0) - exportMinInput:SetAttribute("value", minStr) + setAttrValueIfChanged(exportMinInput, "input-tf-export-min", minStr) end local exportMaxInput = doc and getCachedEl(doc, "input-tf-export-max") if exportMaxInput and widgetState.focusedRmlInput ~= exportMaxInput then local maxStr = string.format("%.2f", state.exportCustomMax or 0) - exportMaxInput:SetAttribute("value", maxStr) + setAttrValueIfChanged(exportMaxInput, "input-tf-export-max", maxStr) end end -- Slider wheel-lock pulse animation @@ -17252,6 +19600,16 @@ end function widget:Shutdown() WG.TerraformBrushUI = nil + -- Hand the game interface back before anything else: a /luaui reload with + -- focus mode on must not leave the user with no UI at all. + widgetState.setFocusMode(false) + + -- The water level preview plane is drawn by the other widget, so a shutdown + -- with the Dimensions window open would strand it on screen. + if WG.TerraformBrush and WG.TerraformBrush.setWaterLevelPreview then + WG.TerraformBrush.setWaterLevelPreview(nil) + end + if WG.TerraformerShared then -- Hand the mouse wheel back before leaving: a slider locked at shutdown -- would otherwise leave every sibling panel unable to scroll. @@ -17391,4 +19749,8 @@ function widget:Shutdown() skyFade.phase = "idle" widgetHandler:RemoveAction("terraformpanel") + widgetHandler:RemoveAction("tf_sunlog") + if widgetState.setSunLog then + widgetState.setSunLog(false) + end end diff --git a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rcss b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rcss index a8b31900361..cf60b5452a9 100644 --- a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rcss +++ b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rcss @@ -597,6 +597,43 @@ body { border-width: 1dp; } +/* HEIGHT TINT colour chips (TILESET window): the chip IS the colour, painted + from the knob table by tf_tileset.sync; the selected chip is what the shared + palette + R/G/B trio edits. The label sits on a dark pill so it reads on any + colour. */ +.ts-hg-chip { + flex: 1; + height: 18dp; + display: flex; + align-items: center; + justify-content: center; + border: 1dp #33333380; + border-radius: 3dp; + cursor: pointer; + background-color: #808080; +} +.ts-hg-chip:hover { + border-color: #fdc04c80; +} +.ts-hg-chip.active { + border: 2dp #fdc04c; +} +.ts-hg-chip-label { + font-size: 0.75rem; + color: #ffffff; + background-color: #00000070; + padding: 0 4dp; + border-radius: 2dp; +} +/* HEIGHT TINT ramp file list (same row markup as the feature-map browser) */ +.ts-ramp-list { + max-height: 160dp; +} +.tf-hm-row.ts-ramp-current { + background-color: #1d2a3a; + border: 1dp #2ba5eaa0; +} + /* Environment skybox grid */ .env-skybox-grid { display: flex; @@ -1543,6 +1580,25 @@ body { opacity: 0.7; } +/* === Focus Mode (Eye) Button: game HUD hidden, editor alive === */ +.tf-focus-btn.active { + background-color: #0d2c24; + border-color: #40e0c0; +} + +.tf-focus-btn.active:hover { + background-color: #133a30; + border-color: #7cecd6; +} + +.tf-focus-btn.active img { + image-color: #40e0c0; +} + +.tf-focus-btn.active:hover img { + image-color: #7cecd6; +} + /* === Guide Floating Tooltip === */ .tf-guide-floating-tip { position: absolute; @@ -1820,13 +1876,38 @@ body { background-color: #161620; border: 1dp #40404a60; border-radius: 6dp; - max-width: 100dp; } .tf-dim-input:focus { border-color: #6a6a80cc; } +/* Dimensions window: the height-range mode switch. Text-only and short, so the + 54dp icon body of .tf-mode-btn is dropped down to a single centred line. */ +.tf-dim-mode-btn { + height: 30dp; + justify-content: center; + gap: 0dp; + padding-bottom: 0dp; +} +.tf-dim-mode-btn .tf-btn-text { + font-size: 1.05rem; +} +.tf-dim-mode-btn.active { + border-color: #fdc04c; + background-color: #2b2113; +} +.tf-dim-mode-btn.active:hover { + background-color: #3b2d17; + border-color: #ffd47a; +} + +/* One-line explanation of the selected mode. */ +.tf-dim-desc { + font-size: 1.0rem; + color: #b9bfca; +} + .tf-preset-btn { height: 28dp; padding: 0 10dp; @@ -2097,8 +2178,7 @@ body { } /* Open Project rows: clicking one selects it; LOAD and DELETE sit at the - bottom of the dialog and act on the selection. The window keeps the shared - .tf-newmap-window width — no override — so it matches New Map / Save Project. */ + bottom of the dialog and act on the selection. */ .tf-proj-row.selected { background-color: #1f3a4d; border: 1dp #2ba5ea; @@ -2108,6 +2188,100 @@ body { color: #ffffff; } +/* The Open Project window is wider than the shared .tf-newmap-window and its + rows are set larger: project names and the folder tree need the room, and + the base row sizes (kept by the heightmap browser) read too small in a list + that is browsed rather than glanced at. Scoped to .tf-proj-row so the Save + Project list gets the same type. */ +#tf-project-open-root { + width: 440dp; +} + +.tf-proj-row { + padding: 7dp 10dp; +} + +.tf-proj-row .tf-hm-date { + font-size: 1.05rem; + min-width: 92dp; +} + +.tf-proj-row .tf-hm-mapname { + font-size: 1.3rem; +} + +.tf-proj-row .tf-hm-badge { + font-size: 0.95rem; +} + +/* Project tree folders: a disclosure glyph, the folder name and how many + projects sit under it. Children indent one step per depth (the browser caps + folder depth at 4). */ +.tf-proj-folder { + display: flex; + flex-direction: row; + align-items: center; + gap: 8dp; + flex-shrink: 0; + padding: 6dp 10dp; + margin-top: 4dp; + border-radius: 4dp; + cursor: pointer; + color: #d9c28a; + font-size: 1.1rem; + font-weight: bold; + border: 1dp transparent; +} + +.tf-proj-folder:hover { + background-color: #232a38; + border: 1dp #d9c28a60; +} + +.tf-proj-folder-glyph { + width: 14dp; + color: #94a3b8; + font-size: 0.9rem; +} + +.tf-proj-folder-name { + flex: 1; + white-space: nowrap; + overflow: hidden; +} + +.tf-proj-folder-count { + color: #94a3b8; + font-size: 0.9rem; + font-weight: normal; + white-space: nowrap; +} + +.tf-proj-depth-1 { + margin-left: 14dp; +} + +.tf-proj-depth-2 { + margin-left: 28dp; +} + +.tf-proj-depth-3 { + margin-left: 42dp; +} + +.tf-proj-depth-4 { + margin-left: 56dp; +} + +/* A project's folder path, shown after the name while a search filter is on + (the tree is flattened then, so the folder must travel with the row). */ +.tf-proj-path { + color: #94a3b8; + font-size: 0.85rem; + font-weight: normal; + white-space: nowrap; +} + /* === Absolute Height Cap Toggle === */ .tf-abs-toggle { width: 20dp; @@ -2277,6 +2451,7 @@ body { font-size: 1.05rem; font-weight: bold; color: #dce0e8; + white-space: nowrap; } .tf-save-btn { @@ -2419,6 +2594,11 @@ body { background-color: #2b2113; } +.tf-sm-btn-smudge.active { + border-color: #fdc04c; + background-color: #2b2113; +} + .tf-ramp-btn-straight.active { border-color: #fdc04c; background-color: #2b2113; @@ -2429,6 +2609,11 @@ body { background-color: #2b2113; } +.tf-ramp-btn-auto.active { + border-color: #fdc04c; + background-color: #2b2113; +} + /* === Feature Placer Distribution Buttons === */ .tf-fp-dist-btn { flex: 1; @@ -2918,6 +3103,14 @@ body { decorator: vertical-gradient(#3a3a46 #1e1e28); box-shadow: 0dp 2dp 3dp 0dp #00000050, inset 0dp 1dp 0dp 0dp #ffffff14; } +/* Chip rows (DISPLAY, INSTRUMENTS) wrap onto a second line instead of + squeezing: five chips in one row crushed their labels onto two lines each. */ +.tf-chip-row { + flex-wrap: wrap; +} +.tf-chip-row .tf-overlay-chip { + flex-shrink: 0; +} .tf-overlay-chip:hover { background-color: #3a3a46; border-color: #5a5a6a; @@ -2936,6 +3129,10 @@ body { decorator: vertical-gradient(#18463a #0d2c24); box-shadow: 0dp 2dp 5dp 0dp #00000080, inset 0dp 1dp 0dp 0dp #40e0c050; } +/* chip whose action has no target right now (e.g. FLIP with an empty slot selected) */ +.tf-overlay-chip.unavailable { + opacity: 0.4; +} .tf-overlay-chip-label { font-size: 1.0rem; color: #9ca3af; @@ -2985,6 +3182,22 @@ body { border-color: #55556080; color: #9ca3af; } +/* A chip that carries a whole phrase (FOLLOW STROKE) rather than a word. */ +.tf-inline-chip-lg { + padding: 4dp 10dp; + font-size: 0.92rem; +} +.tf-inline-chip.disabled { + color: #4a4a52; + background-color: #202028; + border-color: #2c2c3440; + cursor: default; +} +.tf-inline-chip.disabled:hover { + color: #4a4a52; + background-color: #202028; + border-color: #2c2c3440; +} .tf-inline-chip.active { background-color: #0d2c24; border-color: #40e0c060; @@ -3344,6 +3557,7 @@ body { font-size: 1.05rem; font-weight: bold; color: #c8d0d8; + white-space: nowrap; } /* Compact chip for section title rows. @@ -3563,6 +3777,32 @@ margin-top: 3dp; border-color: #ffd47a; } +/* === EXTRA LAYER (slot 4) ================================================ */ +/* One-row segmented mode switch: compact buttons, text sized up from the */ +/* stock 0.9rem tf-btn-text so the labels carry the row, not the chrome. */ +.tf-s4-btn { + height: 30dp; + justify-content: center; + gap: 0dp; + padding-bottom: 0dp; +} +.tf-s4-btn .tf-btn-text { + font-size: 1.05rem; +} +.tf-s4-btn.active { + border-color: #fdc04c; + background-color: #2b2113; +} +.tf-s4-btn.active:hover { + background-color: #3b2d17; + border-color: #ffd47a; +} +/* Per-mode explanation under the switch: full-strength text, no opacity dim + (the old text-base + 0.7 opacity combo was the unreadable part). */ +.tf-s4-desc { + font-size: 1.05rem; + color: #b9bfca; +} /* === BIOME LIBRARY picker (TILESET tool) ================================== */ /* Clickable thumbnail tiles; active tile driven by data-class-active="tsBiome */ /* == ''". Thumbnails at /luaui/images/terraform_brush/biome_.png. */ @@ -3587,6 +3827,13 @@ margin-top: 3dp; width: 60dp; height: 60dp; border-radius: 3dp; + /* an empty rect the widget overdraws with the thumbnail via gl.TexRect + (DrawScreenPost); the tint shows until the texture is resident */ + background-color: #26263699; +} +/* invisible filler so a short last row keeps the tile width */ +.tf-biome-tile.tf-biome-pad { + visibility: hidden; } .tf-biome-name { margin-top: 2dp; @@ -3613,6 +3860,12 @@ margin-top: 3dp; user can track "which channel am I on" across every surface. */ .surf-text-c1 { color: #38bdf8; } .surf-text-c2 { color: #e879f9; } +.surf-text-c3 { color: #a3e635; } +.surf-text-c4 { color: #a78bfa; } +/* slots 4-7 live in the second mask */ +.surf-text-c5 { color: #2dd4bf; } +.surf-text-c6 { color: #fb7185; } +.surf-text-c7 { color: #fbbf24; } /* NOW PAINTING strip: persistent readout of what the next stroke does */ .surf-now-strip { @@ -3629,20 +3882,43 @@ margin-top: 3dp; border-color: #ff8c3baa; } .surf-now-thumb { - width: 34dp; - height: 34dp; + width: 46dp; + height: 46dp; border-radius: 3dp; background-color: #26263699; } .surf-now-text { flex: 1 1 0; } -/* Slot rail chips */ +/* SURFACE brush modes: the stock mode-button body, with the amber active state + the terrain modes and the EXTRA LAYER row already use. ERASE lights orange + instead — it is a toggle over whichever preset is selected, not a fifth + preset, and the brush ring is orange while it is on. */ +.tf-surf-mode-btn.active { + border-color: #fdc04c; + background-color: #2b2113; +} +.tf-surf-mode-btn.active:hover { + background-color: #3b2d17; + border-color: #ffd47a; +} +#btn-surf-erase.active { + border-color: #ff8c3b; + background-color: #2e1c10; +} +#btn-surf-erase.active:hover { + background-color: #3d2614; + border-color: #ffa563; +} + +/* Slot rail tiles. Thumbnail-first: a 26dp square told you nothing about the + material, so the texture now takes the full tile width and the caption sits + under it. */ .surf-slot-chip { flex: 1 1 0; display: flex; - flex-direction: row; + flex-direction: column; align-items: center; - gap: 4dp; + gap: 1dp; padding: 3dp; border: 1dp #4a4a6899; border-radius: 4dp; @@ -3654,38 +3930,116 @@ margin-top: 3dp; .surf-slot-chip.active { border-color: #fdc04c; background-color: #2b2113; } .surf-slot-c1.active { border-color: #38bdf8; background-color: #10222e; } .surf-slot-c2.active { border-color: #e879f9; background-color: #291029; } +.surf-slot-c3.active { border-color: #a3e635; background-color: #1c2410; } +.surf-slot-c4.active { border-color: #a78bfa; background-color: #1d1830; } +.surf-slot-c5.active { border-color: #2dd4bf; background-color: #0f2422; } +.surf-slot-c6.active { border-color: #fb7185; background-color: #2b1319; } +.surf-slot-c7.active { border-color: #fbbf24; background-color: #2b2011; } .surf-slot-chip.surf-slot-empty { opacity: 0.55; } .surf-slot-thumb { - width: 26dp; - height: 26dp; + width: 100%; + height: 54dp; border-radius: 3dp; background-color: #26263699; flex-shrink: 0; } -.surf-slot-info { flex: 1 1 0; overflow: hidden; white-space: nowrap; } -.surf-slot-free { - position: absolute; - top: -2dp; - right: 0; - font-size: 1.6rem; - color: #9ca3af; +/* caption + action row under the thumb — NOT floating over it, which the GL + thumbnail pass paints after RmlUi and would cover */ +.surf-slot-cap { + font-size: 1.0rem; + font-weight: bold; + white-space: nowrap; + overflow: hidden; +} +/* PICK / X: real buttons, because a grey glyph beside the name was invisible + and because selecting a slot and choosing its texture are different actions. + (FLIP is NOT per tile: a third button here clipped into its neighbours, so + it lives in the NOW PAINTING strip and acts on the selected slot.) */ +.surf-slot-actions { + display: flex; + flex-direction: row; + align-items: center; + gap: 2dp; + width: 100%; +} +.surf-slot-btn { + flex: 1 1 0; + padding: 4dp 0; + border: 1dp #5a5a7099; + border-radius: 3dp; + background-color: #2c2c3c99; + color: #c8d0d8; + font-size: 1.05rem; + font-weight: bold; + text-align: center; cursor: pointer; - padding: 0 4dp; } +.surf-slot-btn:hover { + border-color: #fdc04c; + background-color: #3a3320; + color: #fdc04c; +} +/* the slot whose library is open — so PICK reads as a toggle, not a one-shot */ +.surf-slot-btn.active { + border-color: #fdc04c; + background-color: #4a3f21; + color: #ffdb8a; +} +.surf-slot-btn.surf-slot-x { + flex: 0 0 26dp; + color: #9ca3af; + font-size: 1.25rem; +} +.surf-slot-btn.surf-slot-x:hover { + border-color: #f87171; + background-color: #3a1f1f; + color: #f87171; +} +/* the BASE tile has nothing to pick or clear: its action slot just labels what + clicking the tile does, so the row keeps the same height as its neighbours */ +.surf-slot-btn.surf-slot-btn-mute { + border-color: #3a3a4899; + background-color: #22222e99; + color: #6b7280; +} +.surf-slot-btn.surf-slot-btn-mute:hover { + border-color: #3a3a4899; + background-color: #22222e99; + color: #6b7280; +} +.surf-picker-x { flex: 0 0 26dp; } /* Caret that opens the per-slot variant picker */ /* Per-slot variant picker: the texture library only takes space while an artist is actually choosing, instead of a permanently open grid. */ +/* Picker hover preview: big enough to actually read a material's character */ +.surf-preview-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 6dp; + margin-bottom: 5dp; +} +.surf-preview-thumb { + width: 104dp; + height: 104dp; + border-radius: 4dp; + border: 1dp #4a4a6899; + background-color: #26263699; + flex-shrink: 0; +} +.surf-preview-text { flex: 1 1 0; overflow: hidden; } + +/* No scroller of its own: the picker sizes to its content and the panel body + scrolls if the window runs out of room. A nested scroll box meant two + scrollbars in one column and a grid you had to sweep twice. */ .surf-picker { margin-bottom: 6dp; padding: 5dp; border: 1dp #6a6a8899; border-radius: 4dp; background-color: #16161f; - max-height: 320dp; - overflow-y: auto; } -.surf-slot-free:hover { color: #f87171; } /* Palette tiles that cannot be assigned while both slots are painted */ .tf-biome-tile.surf-unassignable { opacity: 0.35; } @@ -3697,6 +4051,11 @@ margin-top: 3dp; /* Selected tile ring in the CHANNEL color — matches rail, bar, brush ring */ .tf-biome-tile.surf-sel-c1.active { border-color: #38bdf8; } .tf-biome-tile.surf-sel-c2.active { border-color: #e879f9; } +.tf-biome-tile.surf-sel-c3.active { border-color: #a3e635; } +.tf-biome-tile.surf-sel-c4.active { border-color: #a78bfa; } +.tf-biome-tile.surf-sel-c5.active { border-color: #2dd4bf; } +.tf-biome-tile.surf-sel-c6.active { border-color: #fb7185; } +.tf-biome-tile.surf-sel-c7.active { border-color: #fbbf24; } /* Tile corner tags in slot colors (replace the label suffix) */ .surf-tile-tag { @@ -3711,30 +4070,12 @@ margin-top: 3dp; } .surf-tile-tag.surf-tag-c1 { background-color: #38bdf8dd; } .surf-tile-tag.surf-tag-c2 { background-color: #e879f9dd; } +.surf-tile-tag.surf-tag-c3 { background-color: #a3e635dd; } +.surf-tile-tag.surf-tag-c4 { background-color: #a78bfadd; } +.surf-tile-tag.surf-tag-c5 { background-color: #2dd4bfdd; } +.surf-tile-tag.surf-tag-c6 { background-color: #fb7185dd; } +.surf-tile-tag.surf-tag-c7 { background-color: #fbbf24dd; } -/* Coverage meter: split base/V1/V2 bar (amber base below 80%) */ -.surf-cov-track { - flex: 1 1 0; - height: 8dp; - border: 1dp #4a4a6899; - border-radius: 3dp; - background-color: #1c1c2899; - display: flex; - flex-direction: row; - overflow: hidden; -} -.surf-cov-fill { - height: 6dp; - background-color: #7fc97faa; -} -.surf-cov-fill.surf-cov-c1 { background-color: #38bdf8cc; } -.surf-cov-fill.surf-cov-c2 { background-color: #e879f9cc; } -.surf-cov-fill.surf-cov-amber { - background-color: #fdc04ccc; -} -.text-sm.surf-cov-amber, .text-base.surf-cov-amber { - color: #fdc04c; -} /* === HARD SURFACES section (LAYERS tool) === */ .tf-surf-ch-btn { @@ -3845,3 +4186,26 @@ margin-top: 3dp; .tf-capture-result-bad .tf-capture-result-head { color: #fdc04c; } + +/* IMAGE overlay (DISPLAY > Image): the gear chip next to the Image chip and the + file list in the IMAGE OVERLAY window. */ +/* The gear is a chip too (same gradient, border and active state as its + neighbours) and stretches to the row's chip height instead of the fixed + 18dp of tf-sm-btn, which sat visibly shorter than the labelled chips. */ +.tf-imgov-cfg { + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; + padding: 3dp 6dp; +} +.tf-imgov-cfg.active { + border: 1dp #2ba5ea; +} +.tf-imgov-list { + max-height: 160dp; +} +.tf-hm-row.imgov-current { + background-color: #1d2a3a; + border: 1dp #2ba5eaa0; +} diff --git a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rml b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rml index ca0a9513d77..6ca5fe3fc53 100644 --- a/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rml +++ b/luaui/RmlWidgets/gui_terraform_brush/gui_terraform_brush.rml @@ -17,13 +17,17 @@ BRUSH - 1.11 + 1.14
+
+ + +
@@ -233,13 +237,22 @@ @@ -531,6 +544,9 @@
LOAD
+
+
COPY
+
@@ -547,13 +563,22 @@ @@ -887,6 +912,10 @@
{{tfRestoreLabel2Str}}
+
+
FOLLOW STROKE
+
shape rides the drag direction
+
@@ -930,13 +959,22 @@
-
+
Straight
-
+
Spline
+
+ +
Auto
+
+
+
+
+
AUTORAMP
+
+
Preview
+
+
+
+
click a cliff to rebuild it
+
+
+
Cliff start
+
+
Extend
+
+
+
Subtract
+
+
+
Average
+
+
+
+
Angle
+ + +
+
+
Falloff
+ + +
+
+
Edge noise
+ + +
+
+
Erosion
+ + +
+
+
Buildup
+ + +
@@ -1937,6 +2036,10 @@
Level
+
+ +
Smudge
+
@@ -2081,13 +2184,22 @@ @@ -2631,13 +2743,22 @@ @@ -2973,6 +3094,35 @@ +
+
+
SCALE MIN: {{fpScaleMinStr}}x
+
+
+
+ +
+ +
+ +
+ +
+
+
SCALE MAX: {{fpScaleMaxStr}}x
+
+
+
+ +
+ +
+ +
+ +
+
+
COUNT: {{fpCountStr}}
@@ -3079,13 +3229,22 @@
@@ -3443,13 +3602,22 @@
+ +
+
+ +
DISPLAY
+ +
+ +
+ + +
+
+ +
INSTRUMENTS
+ +
+ +
+
@@ -5215,37 +5558,7 @@
AUTO paints the override away — the shader's slope-driven placement returns. INTERMEDIATE / CLIFF / PLATEAU force that material where painted.
- -
SMART FILTERS
-
-
-
Avoid Water
-
-
-
Avoid Cliffs
-
-
-
Alt Min
-
-
-
Alt Max
-
-
-
-
Max slope
- - -
-
-
Alt min
- - -
-
-
Alt max
- - -
+
UNDO & SAVE
@@ -5279,37 +5592,90 @@
-
{{surfNowName}}
+
{{surfNowName}}
{{surfNowDetail}}
{{surfNowMode}}
+ +
+
FLIP
+
- +
-
-
BASE
-
{{surfBaseShare}}
+
BASE
+
+
PAINT
-
-
1 · {{surfSlot1Name}}
-
{{surfSlot1Share}}
+
1 · {{surfSlot1Name}}
+
+
PICK
+
×
-
×
-
-
2 · {{surfSlot2Name}}
-
{{surfSlot2Share}}
+
2 · {{surfSlot2Name}}
+
+
PICK
+
×
+
+
+
+
+
3 · {{surfSlot3Name}}
+
+
PICK
+
×
+
+
+
+
+
+
+
4 · {{surfSlot4Name}}
+
+
PICK
+
×
+
+
+
+
+
5 · {{surfSlot5Name}}
+
+
PICK
+
×
+
+
+
+
+
6 · {{surfSlot6Name}}
+
+
PICK
+
×
+
+
+
+
+
7 · {{surfSlot7Name}}
+
+
PICK
+
×
-
×
+
+
+
+
{{surfPreviewName}}
+
{{surfPreviewHint}}
+
This slot already carries paint — picking re-skins those areas.
No top variants in this biome — pick Teizer-5 or Enborelde in the TILESET window (SCENE menu).
-
-
Coverage
-
-
-
-
-
-
{{surfCoverageStr}}
-
Dot lightly — the base stays dominant. Alt-click the map to eyedrop.
@@ -5343,18 +5712,27 @@
BRUSH
+
-
-
DOT
+
+ +
DOT
-
-
WASH
+
+ +
WASH
-
-
FILL
+
+ +
FILL
-
-
ERASE
+
+ +
ERASE
@@ -5379,56 +5757,277 @@
Spacing 0 paints continuously; otherwise one stamp every N elmos of drag.
+
+
Scatter
+ + +
+
+
Sc Size
+ + +
+
+
Sc Str
+ + +
+
Scatter jitters each stamp: position in brush radii, size and strength as a fraction. With Spacing near 1.5x the size, one drag lays a dot field.
Variants only ever retexture the soft top — hard surfaces ignore the brush by design. Cliff protection for the LAYERS overrides is a shader setting, in the TILESET window.
LMB paints the selected override channel, RMB erases it. Keys 1–4 pick the channel.
- -
-
- -
FILL AND SEED
-
-