From 741e18e21ca63999372a0e83f0a03bdb6bbdc526 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Thu, 4 Jun 2026 09:58:18 -0500 Subject: [PATCH 01/19] OFP community docs and contribution guidelines Add CODE_OF_CONDUCT, CONTRIBUTING, GOVERNANCE, COMMUNITY_QUEUE, issue/discussion templates, and Readme updates for the OFP fork. --- .github/CODE_OF_CONDUCT.md | 27 ++++++ .../DISCUSSION_TEMPLATE/feature_request.yml | 21 +++++ .github/ISSUE_TEMPLATE/bug_report.yml | 57 +++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 ++ COMMUNITY_QUEUE.md | 50 ++++++++++ CONTRIBUTING.md | 94 +++++++++++++++++++ GOVERNANCE.md | 50 ++++++++++ Readme.md | 44 +++++++-- 8 files changed, 344 insertions(+), 7 deletions(-) create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/DISCUSSION_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 COMMUNITY_QUEUE.md create mode 100644 CONTRIBUTING.md create mode 100644 GOVERNANCE.md diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..2d8cb1bccb6 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,27 @@ +# Code of Conduct + +## Our Standard + +OFP is a technical community. Participation is expected to be substantive, good-faith, and respectful. We are here to build software together. + +Specifically: + +- Engage with ideas, not people. Critique code and proposals, not contributors. +- Document your reasoning. "No" without explanation is not a useful contribution. +- Assume good faith until evidence says otherwise. +- Recuse yourself when you have a personal stake in a decision. + +## What is not acceptable + +- Personal attacks, harassment, or sustained hostility toward any contributor. +- Blocking contributions without documented technical reasoning. +- Using contributor access to serve personal or project interests at the expense of the community queue. +- Conduct that would make a reasonable person unwilling to participate. + +## Reporting + +Reports go to the active Maintainers via GitHub Discussion (public) or direct contact (private, if necessary). Maintainers respond with documented reasoning. A Maintainer who is the subject of a report recuses themselves from its handling. + +## Enforcement + +Consequences scale with severity and pattern: documented warning, temporary suspension of merge/approve authority, removal from the contributor ladder. All actions are documented publicly unless privacy requires otherwise. diff --git a/.github/DISCUSSION_TEMPLATE/feature_request.yml b/.github/DISCUSSION_TEMPLATE/feature_request.yml new file mode 100644 index 00000000000..be469709327 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/feature_request.yml @@ -0,0 +1,21 @@ +title: "[Feature Request] " +labels: ["feature-request"] +body: | + ## Problem Statement + + What problem does this solve? Describe the gap, limitation, or friction you or others are experiencing. + Be specific - who encounters this, and when? + + ## Proposed Solution + + What would you like to see implemented? Describe the behavior, not the implementation. + If you have implementation ideas, include them, but the problem statement is what gets the request accepted. + + ## Mod and Player Impact + + Which mods, campaigns, or players would benefit from this? + Links to existing discussions, forum threads, or community requests that support this are helpful here. + + ## Additional Context + + Anything else relevant - prior art in FSO or other engines, technical constraints you're aware of, related requests. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000000..a595ebaf4b5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,57 @@ +name: Bug Report +description: Report a bug in the OFP engine build. Feature requests belong in Discussions, not here. +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + **This tracker is for bugs only.** If something isn't broken (it's a feature request, a question, or a proposal) please open a [Discussion](../../discussions) instead. + + - type: textarea + id: description + attributes: + label: What happened? + description: A clear description of the bug. + placeholder: Describe what went wrong. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: How do we make this happen? + placeholder: | + 1. Launch with mod X + 2. Load mission Y + 3. ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should have happened? + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + description: OFP build version, OS, and any relevant mod or configuration details. + placeholder: | + OFP version: + OS: + Mods active: + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs or screenshots + description: Attach fs2_open.log or relevant screenshots if applicable. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..57d68a11cda --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Feature Request + url: https://github.com/ofp-fs2open/fs2open.github.com/discussions/new?category=feature-requests + about: Feature requests and proposals belong in Discussions, not Issues. + - name: General Discussion + url: https://github.com/ofp-fs2open/fs2open.github.com/discussions + about: Questions, roadmap discussion, and development conversation. diff --git a/COMMUNITY_QUEUE.md b/COMMUNITY_QUEUE.md new file mode 100644 index 00000000000..51a86866583 --- /dev/null +++ b/COMMUNITY_QUEUE.md @@ -0,0 +1,50 @@ +# Community Queue + +This is the canonical list of Queue-Accepted feature requests, ordered oldest-accepted first. + +Contributors implementing features should draw from this list, working from the top down. If you skip an item, document why in your PR description. + +--- + +## How to read this list + +| Position | Discussion | Accepted | Status | +|----------|------------|----------|--------| +| 1 | [Title](#) | YYYY-MM-DD | Open | + +**Status values:** +- **Open** — Available for any contributor to pick up +- **In Progress** — Being actively worked; link to PR in the Discussion thread +- **Merged** — Done. Will be removed from the active queue and noted in the relevant release milestone. + +--- + +## When skipping ahead is fine + +Working top-down is the default, not a rule. Picking from anywhere in the queue is welcome when you have a good reason — just note it briefly in your PR description. Common acceptable reasons: + +- **Scale matches your availability.** You have a focused window (e.g. a few free days this week) and a lower-priority item is the right size to finish in that window, where a higher-priority item is not. Better to land a smaller item cleanly than to start a multi-day effort you can't see through. +- **Mismatched skillset.** You're a graphics person and the top item is a netcode rewrite. Doing what you're good at lands faster and at higher quality than grinding through unfamiliar code. +- **Code locality.** You're already deep in a subsystem for another PR and a lower queue item touches the same files. Bundling avoids re-paging-in the context later. +- **Unblocks a higher-priority item.** A lower item is a real prerequisite for one above it. Doing it out of order is the correct order. +- **New-contributor warmup.** First-time contributors picking a smaller item to learn the build, codebase conventions, and PR process before tackling something larger. +- **External coordination.** A mod team or release is blocked on a specific item that isn't at the top. + +--- + +## Active Queue + +*The queue populates as feature requests are reviewed and accepted. Check [Feature Requests](../discussions?category=feature-requests) for pending requests under review.* + +--- + +## Notes for contributors + +- Pick up an Open item by commenting in its Discussion thread that you are working on it. +- Update the Discussion thread with a link to your PR when you open one. +- If you need to abandon an item, say so in the thread so it returns to Open status. +- Questions about queue ordering or item eligibility go in the Discussion thread for that item, or in [General Development](../discussions?category=general-development). + +--- + +*This list is maintained by OFP Maintainers. Last updated: see edit history.* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000000..7ac2090f697 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Contributing to the Open FreeSpace Project + +Welcome. OFP is a community-governed fork of FreeSpace Open. This document covers how to contribute code, how the Community Queue works, and what to expect from the review and merge process. + +--- + +## Before You Start + +- **Bug fixes** are always welcome. Open a PR, get it merged. No prerequisites. +- **Feature work** should draw from the Community Queue (see below). Personal features are allowed but subject to reviewer prioritization. +- All contributions must be compatible with FSO licensing terms. New OFP-originated contributions are released under the Unlicense. + +--- + +## The Community Queue + +Feature requests live in [GitHub Discussions](../../discussions). The Community Queue is the ordered list of accepted, active feature requests that contributors draw from when choosing what to implement. + +### How a request enters the Queue + +1. Open a Discussion using the Feature Request template (problem statement, proposed solution, mod/player impact). +2. The community engages — upvotes, comments, refinements. +3. A Reviewer or Maintainer marks it **Queue-Accepted** when it meets the bar: clearly defined, technically feasible, and demonstrably wanted by currently active community members. +4. It enters the Queue ordered by the date it was accepted. + +### Working the Queue + +Draw from the Queue **oldest-accepted first**. If you skip ahead, document why in your PR description. Acceptable reasons are narrow: you lack the technical background for the oldest items, or your expertise is specifically needed elsewhere. Maintainers may push back if skips become a pattern. + +### Stale requests + +A request can be marked **Stale** if the original author and supporters have been inactive for over a year with no meaningful recent engagement. Stale requests move to a Stale Discussions category and can be reactivated by any active community member. Reactivated requests re-enter the Queue at the back. + +--- + +## Code Review Matching + +OFP operates on community reciprocity. Review bandwidth is a shared resource. Contributors who consistently participate in reviews or Queue work may be prioritized when Maintainer bandwidth is limited. + +Community contribution means any combination of: +- Meaningful code reviews on other contributors' PRs +- Implementing features from the Community Queue + +There is no quota. Two Queue implementations and zero reviews is fine. Zero Queue implementations and several substantive reviews is also fine. Maintainers exercise judgment publicly and with documented reasoning. + +**Bug fixes are always exempt.** + +--- + +## Contributor Ladder + +### Contributor +**Eligibility:** One or more merged PRs. +**How:** Self-nominate or be nominated in a public GitHub Discussion. Approved by Maintainer consensus. +**Authority:** None beyond submitting PRs. + +### Reviewer +**Eligibility:** 5 or more merged PRs, plus meaningful participation in at least 3 code reviews on other contributors' work. +**How:** Self-nominate or be nominated in a public GitHub Discussion. Approved by Maintainer consensus. +**Authority:** Can approve PRs. Cannot merge. + +### Maintainer +**Eligibility:** Active Reviewer and code contributor for at least 6 months, demonstrated consistent good judgment in reviews, and active participation in RFC discussions. +**How:** Self-nominate or be nominated in a public GitHub Discussion. Approved by Maintainer consensus. +**Authority:** Can merge PRs. Responsible for Queue management and RFC rulings. + +Good judgment means: reviews that catch real issues, RFC feedback that is constructive and technically grounded, no pattern of blocking contributions without documented reasoning. The public record is the evidence. + +### Removal + +Any tier can be lost for inactivity (12 months with no activity, 30-day notice) or through a documented conduct or judgment process. The process is the same as promotion: public, reasoned, and on the record. + +### Recusal + +A Maintainer or Reviewer with a direct personal stake in a decision recuses themselves and says so publicly. Direct personal stake means: the PR or RFC primarily serves their own mod or project, there is documented ongoing conflict with the contributor involved, or they authored the code under review. + +A recused Maintainer may still comment technically but does not vote or merge. If recusal leaves fewer than two Maintainers able to rule, the decision opens to a community vote in the relevant Discussion thread. Any active Contributor or above may vote. + +--- + +## Submitting a PR + +1. Fork the repo and create a branch from `main`. +2. Write clear commit messages. +3. If implementing a Queue item, reference the Discussion in your PR description. +4. If skipping a Queue item, explain why in your PR description. +5. Ensure CI passes on Linux, Windows, and macOS before requesting review. +6. Request review from a Reviewer or Maintainer. + +--- + +## Code of Conduct + +See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 00000000000..b7baf0f8271 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,50 @@ +# OFP Governance + +This document describes how the Open FreeSpace Project makes decisions, manages its contributor ladder, and handles conflict. + +--- + +## Principles + +- Trust and authority are earned through demonstrated contribution, not granted socially. +- All decisions of consequence are made publicly and with documented reasoning. +- The Community Queue represents accumulated community need. It can be thought of as a roadmap. + +--- + +## Decision Making + +Most decisions happen in the open: PR reviews, Discussion threads, RFC discussions. The default process is lazy consensus. If no one objects with a documented reason within a reasonable window, the decision stands. + +Contested decisions escalate to a Maintainer vote in the relevant Discussion thread. Maintainers vote publicly. Majority rules. Ties go to community vote (any active Contributor or above may participate). + +--- + +## RFCs + +Significant architectural changes, governance amendments, or anything that would affect the mod ecosystem broadly requires an RFC (Request for Comments) opened as a GitHub Discussion. RFCs stay open for a minimum of two weeks before a ruling. Maintainers are expected to participate and document their reasoning. + +--- + +## Release Process + +OFP targets a regular release cadence. Proposed timelines are posted as GitHub Discussions at least six weeks before a feature freeze. Community members may comment on scope, timing, and what gets held versus shipped. Maintainers make final calls but explain decisions that go against stated community preference. + +Releases include: +- Features from the Community Queue that are merged and stable +- Bug fixes +- Upstream FSO merges that have passed integration testing + +--- + +## Upstream Sync + +OFP merges from FSO upstream weekly. Maintainers may defer a specific week's merge if upstream is mid-refactor, with a note posted in the sync thread. Deferred merges are picked up the following week. + +Changes that conflict with OFP's architectural decisions are evaluated case by case. The decision to accept, adapt, or exclude an upstream change is documented publicly in the sync thread. + +--- + +## Amending This Document + +Governance changes require an RFC with at least two weeks of open comment and consensus among active Maintainers. diff --git a/Readme.md b/Readme.md index 5126987f2b4..9e66e9bf398 100644 --- a/Readme.md +++ b/Readme.md @@ -1,9 +1,39 @@ -FreeSpace2 *S*ource *C*ode *P*roject -== -[![Coverity](https://img.shields.io/coverity/scan/870.svg)](https://scan.coverity.com/projects/870) +# Open FreeSpace Project -Building --- -Before you do anything, make sure you have updated your git submodules, either by running `git submodule update --init --recursive` or by cloning the repository with the `--recursive` flag.
+OFP is a community-governed fork of [FreeSpace Open](https://github.com/scp-fs2open/fs2open.github.com), maintained by and for the active FreeSpace modding and playing community. -The main instructions for building can be found at our github wiki, on the [Building](https://github.com/scp-fs2open/fs2open.github.com/wiki/Building) page. \ No newline at end of file +## What OFP is + +- A fork of FSO with weekly upstream merges, keeping compatibility with the broader mod ecosystem +- A transparent development platform where community needs drive the roadmap +- An alternative for contributors who want more direct participation in engine direction + +OFP is not a replacement for FSO. It is a parallel platform with different governance. + +## What is different + +- Feature requests are tracked in a public [Community Queue](https://github.com/ofp-fs2open/fs2open.github.com/discussions) worked oldest-first +- Contributor trust is earned through a documented, public ladder +- All decisions (merges, queue management, RFC rulings) are made publicly with documented reasoning +- Bug fixes are always merged without prerequisites + +## Getting started + +- **Found a bug?** [Open an issue](../../issues/new/choose) +- **Have a feature request?** [Start a Discussion](../../discussions/new?category=feature-requests) +- **Want to contribute code?** Read [CONTRIBUTING.md](CONTRIBUTING.md) +- **Want to understand governance?** Read [GOVERNANCE.md](GOVERNANCE.md) + +## Building + +Build instructions follow FSO's existing documentation. CI runs on Linux, Windows, and macOS. All three platforms are first-class. + +Builds are published to [GitHub Releases](../../releases) and available on Nebula as a registered alternate build dependency. + +## Licensing + +All FSO licensing terms are preserved. New OFP-originated contributions are released under the Unlicense. + +## Upstream relationship + +OFP merges from FSO upstream weekly. Sync activity is tracked in a dedicated Discussion thread. Changes that conflict with OFP's architectural decisions are evaluated case by case and documented publicly. From e7c367d20c69d51249ec39eb7582c9a77f61d955 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Mon, 13 Jul 2026 11:49:52 -0500 Subject: [PATCH 02/19] OFP release CI: enable release creation and prerelease marking - Add scoped `contents: write` to the create_release and *_zip jobs so a release_* tag can create the GitHub release and publish assets (OFP repo's default workflow token is read-only, which would 403 these steps). - Any tag with a '-' suffix (RCs, date-stamped nightlies like 26_1_0-20260713) publishes as a prerelease; a clean release_MM_mm_bb is a full release. --- .github/workflows/build-release.yaml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml index 8309bda26ce..ca367f63156 100644 --- a/.github/workflows/build-release.yaml +++ b/.github/workflows/build-release.yaml @@ -9,6 +9,8 @@ jobs: create_release: name: Create GitHub release runs-on: ubuntu-latest + permissions: + contents: write # required to create the GitHub release (repo default token is read-only) steps: - name: Create Release id: create_release @@ -30,7 +32,8 @@ jobs: tag_name: ${{ github.ref }} release_name: Release ${{ github.ref }} draft: false - prerelease: false + # Any tag with a - suffix (RCs, date-stamped nightlies like 26_1_0-20260713) is a prerelease; a clean release_MM_mm_bb is a full release + prerelease: ${{ contains(github.ref_name, '-') }} build_linux: strategy: @@ -98,6 +101,8 @@ jobs: needs: build_linux runs-on: ubuntu-latest container: ghcr.io/scp-fs2open/sftp_upload:sha-ab87451 + permissions: + contents: write # required to publish assets to the GitHub release strategy: matrix: arch: [x86_64, arm64] @@ -231,6 +236,8 @@ jobs: needs: build_windows runs-on: ubuntu-latest container: ghcr.io/scp-fs2open/sftp_upload:sha-ab87451 + permissions: + contents: write # required to publish assets to the GitHub release strategy: fail-fast: false # Run the other jobs in the matrix instead of failing them matrix: @@ -370,6 +377,8 @@ jobs: needs: build_mac runs-on: ubuntu-latest container: ghcr.io/scp-fs2open/sftp_upload:sha-ab87451 + permissions: + contents: write # required to publish assets to the GitHub release strategy: matrix: arch: [x86_64, arm64] From 009ca9e5d5d9f9553ee8245bfcd38f710b887ec1 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Mon, 13 Jul 2026 12:01:57 -0500 Subject: [PATCH 03/19] OFP build identity: brand version string and log header Add an FSO_BUILD_FORK_NAME define (empty on stock builds, "OFP" on OFP builds), driven by cmake/version.cmake through project.h.in. When set: - gameversion::get_version_string() reports "FreeSpace 2 Open (OFP) v", which brands the main hall, the player-menu copyright line, and Lua's ba.getVersionString() (single chokepoint for all three). - the startup log header (fs2_open.log / fred2_open.log / standalone / qtFRED, all via outwnd) gains a "This is an OFP build." line. The numeric version is unchanged, so mod required_fso_version checks and Nebula semver are unaffected. Lua scripts can detect an OFP build with ba.getVersionString():find("(OFP)", 1, true) which needs no new API and therefore never errors on stock SCP builds (accessing an unregistered ba member raises a LuaError). --- cmake/project.h.in | 4 ++++ cmake/version.cmake | 5 +++++ code/globalincs/version.cpp | 6 +++++- code/osapi/outwnd.cpp | 4 ++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/cmake/project.h.in b/cmake/project.h.in index c48e03ed14a..24f496b69dd 100644 --- a/cmake/project.h.in +++ b/cmake/project.h.in @@ -14,4 +14,8 @@ #define FS_VERSION_FULL "@FSO_FULL_VERSION_STRING@" #define FS_PRODUCT_VERSION "@FSO_PRODUCT_VERSION_STRING@" +// Fork identity: empty on stock builds, "OFP" on Origin FreeSpace Project builds. +// Kept separate from the numeric version so mod version checks and Nebula semver are unaffected. +#define FSO_BUILD_FORK_NAME "@FSO_BUILD_FORK_NAME@" + #endif // __PROJECT_H diff --git a/cmake/version.cmake b/cmake/version.cmake index fd7a4710242..9c9cf0e856f 100644 --- a/cmake/version.cmake +++ b/cmake/version.cmake @@ -16,6 +16,11 @@ set_if_not_defined(FSO_VERSION_REVISION 0) # A generic revision string, can be anything set_if_not_defined(FSO_VERSION_REVISION_STR "${FSO_VERSION_REVISION}") +# --- OFP fork identity (Origin FreeSpace Project) --- +# Brands builds as OFP in the version string and log header WITHOUT changing the numeric +# version, so mod required_fso_version checks and Nebula semver stay intact. Empty = stock build. +set_if_not_defined(FSO_BUILD_FORK_NAME "OFP") + if("${FSO_VERSION_REVISION}" STREQUAL "0" AND "${FSO_VERSION_REVISION_STR}" STREQUAL "${FSO_VERSION_REVISION}") set(FSO_VERSION_HAS_REVISION 0) else() diff --git a/code/globalincs/version.cpp b/code/globalincs/version.cpp index 648f3589bb7..3c7d506cf03 100644 --- a/code/globalincs/version.cpp +++ b/code/globalincs/version.cpp @@ -193,7 +193,11 @@ SCP_string get_version_string() { SCP_string str; - sprintf(str, "FreeSpace 2 Open v%s", FS_VERSION_FULL); + if (FSO_BUILD_FORK_NAME[0] != '\0') { + sprintf(str, "FreeSpace 2 Open (%s) v%s", FSO_BUILD_FORK_NAME, FS_VERSION_FULL); + } else { + sprintf(str, "FreeSpace 2 Open v%s", FS_VERSION_FULL); + } #ifndef NDEBUG str += " Debug"; diff --git a/code/osapi/outwnd.cpp b/code/osapi/outwnd.cpp index dfe6d4cebf4..cb2c4cdbf96 100644 --- a/code/osapi/outwnd.cpp +++ b/code/osapi/outwnd.cpp @@ -24,6 +24,7 @@ #include "globalincs/systemvars.h" #include "cfile/cfilesystem.h" #include "parse/parselo.h" +#include "project.h" // FSO_BUILD_FORK_NAME static const char *FILTERS_ENABLED_BY_DEFAULT[] = { @@ -300,6 +301,9 @@ void outwnd_init() strftime(datestr, sizeof(datestr) - 1, "%a %b %d %H:%M:%S %Y", localtime(&timedate)); outwnd_printf("General", "Opened log '%s', %s ...\n", logpath.c_str(), datestr); + if (FSO_BUILD_FORK_NAME[0] != '\0') { + outwnd_printf("General", "This is an %s build.\n", FSO_BUILD_FORK_NAME); + } mprintf(("Legacy config mode is %s.\nReason: %s\n", os_is_legacy_mode() ? "ENABLED" : "DISABLED", Osapi_legacy_mode_reason)); } From 00e04617a5a57e3700ae07010a2790896d6bbc16 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Mon, 13 Jul 2026 15:34:30 -0500 Subject: [PATCH 04/19] OFP Nebula publishing: point ci/post at the OFP engine entry - nebula.py: target the OFP engine (id/title/mod_flag = OFP; tile/banner by checksum, which works without Nebula's URLS_FOR allowlist); add a per-release `private` flag. - main.py: drop the Hard-Light forum post and indiegames FTP/mirror config while keeping the Nebula submit; read NEBULA_PRIVATE (default private); derive the release version from the git tag (OFP has no generated version_override.cmake); pick Nebula stability (stable/rc/nightly) from the version shape; stub the dead nightly branch. - Upload build archives into Nebula's own storage by default (no URLS_FOR allowlist needed); set NEBULA_USE_URLS=true to instead register GitHub Release URLs once the account is allowlisted. installer.py gains an after_hash hook so the upload reuses the one download. - file_list.py: map the Windows "ARM64" package name to the WinARM64 Nebula key. - post-build-release.yaml: drop INDIEGAMES_*/HLP_* env, keep NEBULA_*, add the NEBULA_PRIVATE and NEBULA_USE_URLS repo variables, and rename the job. Releases upload to Nebula privately until NEBULA_PRIVATE=false. --- .github/workflows/post-build-release.yaml | 14 +- ci/post/file_list.py | 3 +- ci/post/installer.py | 8 +- ci/post/main.py | 152 ++++++++-------------- ci/post/nebula.py | 109 ++++++++++++++-- 5 files changed, 164 insertions(+), 122 deletions(-) diff --git a/.github/workflows/post-build-release.yaml b/.github/workflows/post-build-release.yaml index abe064721fc..d860b6fc6bc 100644 --- a/.github/workflows/post-build-release.yaml +++ b/.github/workflows/post-build-release.yaml @@ -27,17 +27,15 @@ on: env: GITHUB_REPO: ${{ github.repository }} # repo this workflow was called from - INDIEGAMES_USER: ${{ secrets.INDIEGAMES_USER }} - INDIEGAMES_SSHPASS: ${{ secrets.INDIEGAMES_PASSWORD }} NEBULA_USER: ${{ secrets.NEBULA_USER }} NEBULA_PASSWORD: ${{ secrets.NEBULA_PASSWORD }} - HLP_API: ${{ secrets.HLP_API }} - HLP_KEY: ${{ secrets.HLP_KEY }} + NEBULA_PRIVATE: ${{ vars.NEBULA_PRIVATE }} # 'false' publishes publicly; unset/anything else = private + NEBULA_USE_URLS: ${{ vars.NEBULA_USE_URLS }} # 'true' registers GitHub URLs (needs URLS_FOR); else upload to Nebula RELEASE_TAG: ${{ inputs.releaseTag }} jobs: post_builds: - name: Post builds on Nebula and the forums + name: Post builds on Nebula runs-on: ubuntu-latest steps: - name: Caller @@ -62,12 +60,10 @@ jobs: WINDOWS_RESULT: ${{ inputs.windows_result }} MAC_RESULT: ${{ inputs.mac_result }} GITHUB_REPO: ${{ github.repository }} # repo this workflow was called from - INDIEGAMES_USER: ${{ secrets.INDIEGAMES_USER }} - INDIEGAMES_SSHPASS: ${{ secrets.INDIEGAMES_PASSWORD }} NEBULA_USER: ${{ secrets.NEBULA_USER }} NEBULA_PASSWORD: ${{ secrets.NEBULA_PASSWORD }} - HLP_API: ${{ secrets.HLP_API }} - HLP_KEY: ${{ secrets.HLP_KEY }} + NEBULA_PRIVATE: ${{ vars.NEBULA_PRIVATE }} + NEBULA_USE_URLS: ${{ vars.NEBULA_USE_URLS }} RELEASE_TAG: ${{ inputs.releaseTag }} run: python ci/post/main.py release diff --git a/ci/post/file_list.py b/ci/post/file_list.py index 980d0ea5fbf..c63e702abd2 100644 --- a/ci/post/file_list.py +++ b/ci/post/file_list.py @@ -149,7 +149,8 @@ def execute_request(path): # x64 is the Visual Studio name but for consistency we need Win64 if platform == "x64": platform = "Win64" - elif platform == "arm64": + elif platform.lower() == "arm64": + # Windows ARM packages are named "ARM64" (from the build arch); map to the Nebula key platform = "WinARM64" binary_files.append(ReleaseFile(name, url, platform, group_match.group(3))) diff --git a/ci/post/installer.py b/ci/post/installer.py index 5473f980731..95053b9019a 100644 --- a/ci/post/installer.py +++ b/ci/post/installer.py @@ -37,7 +37,7 @@ def _gen_hash(fileobj, hash_alg): return h.hexdigest() -def get_hashed_file_list(file: ReleaseFile, session: requests.Session = None, hash_alg: str = "sha256"): +def get_hashed_file_list(file: ReleaseFile, session: requests.Session = None, hash_alg: str = "sha256", after_hash=None): if session is None: session = requests.Session() @@ -79,6 +79,12 @@ def get_hashed_file_list(file: ReleaseFile, session: requests.Session = None, ha file.content_hashes = hash_list + # Reuse this single download for anything else that needs the bytes (e.g. uploading + # the archive into Nebula's storage). local_file is a seekable binary stream. + if after_hash is not None: + local_file.seek(0) + after_hash(file, local_file) + def render_installer_config(version, groups, config): template = Template(filename=config["templates"]["installer"], module_directory='/tmp/mako_modules') diff --git a/ci/post/main.py b/ci/post/main.py index fb7c0d7b7be..9bc2d64bd25 100644 --- a/ci/post/main.py +++ b/ci/post/main.py @@ -1,5 +1,5 @@ # Post-build python script -# Uploads build packages to Nebula and creates a forum post on https://www.hard-light.net +# Uploads build packages to Nebula # Uses the following environment variables (Set by the github action workflow before execution) # FSO versioning @@ -7,17 +7,10 @@ # FSO_VERSION_MINOR # FSO_VERSION_BUILD -# Credentials into IndieGames file host -# INDIEGAMES_USER: -# INDIEGAMES_SSHPASS: - # Credentials into Nebula Mod Database # NEBULA_USER: # NEBULA_PASSWORD: - -# Credentials into hard-light.net for forum post -# HLP_API: -# HLP_KEY: +# NEBULA_PRIVATE: "false" publishes publicly; anything else (or unset) uploads privately # LINUX_RESULT: bool, if the linux builds were successfully uploaded # WINDOWS_RESULT: bool, if the windows builds were successfully uploaded @@ -35,7 +28,6 @@ import file_list import installer import nebula -import forum # Compile regexes for extracting version components MAJOR_VERSION_PATTERN = re.compile("(?:set_if_not_defined|set)\(FSO_VERSION_MAJOR (\d+)\)") @@ -76,48 +68,28 @@ def _match_version_str(text, regex): def get_source_version(date_version: datetime, tag_name: str) -> semantic_version.Version: - """! Retrieves the version string from version_override.cmake (and version.cmake) + """! Derives the release version from the git tag. - @param[in] `date_version` Date this build is published for release - @param[in] `tag_name` Tag of this release, used to determine if release, rc, or nightly + @param[in] `date_version` Unused; kept for signature compatibility. + @param[in] `tag_name` Release tag, e.g. release_26_1_0-20260713 - @return If release: `FSO_VERSION_MAJOR.FSO_VERSION_MINOR.FSO_VERSION_BUILD` - @return If rc: `FSO_VERSION_MAJOR.FSO_VERSION_MINOR.FSO_VERSION_BUILD-FSO_VERSION_REVISION_STR` - @return If nightly: `FSO_VERSION_MAJOR.FSO_VERSION_MINOR.FSO_VERSION_BUILD-FSO_VERSION_REVISION_STR` (uses both version_override.cmake and version.cmake) + @return The version parsed from the tag: release_MAJOR_MINOR_BUILD[-SUFFIX] becomes + MAJOR.MINOR.BUILD[-SUFFIX] (e.g. release_26_1_0-20260713 -> 26.1.0-20260713). - @note This is the version used to identify in Nebula and the forums. version_override.cmake is used for the builds - made by CI tools and version.cmake is used for builds made by devs. + @note OFP derives the Nebula version from the tag rather than from a generated + version_override.cmake (which OFP's manual tagging does not produce). """ - - with open(os.path.join("..", "..", "version_override.cmake"), "r") as f: - filetext = f.read() - major = _match_version_number(filetext, MAJOR_VERSION_PATTERN) - minor = _match_version_number(filetext, MINOR_VERSION_PATTERN) - build = _match_version_number(filetext, BUILD_VERSION_PATTERN) - # revision = _match_version_number(filetext, REVISION_VERSION_PATTERN) - revision_str = _match_version_str(filetext, REVISION_STR_VERSION_PATTERN) - - print("Parsing version_override.cmake...") - if "rc" in tag_name.lower(): - version = semantic_version.Version("{}.{}.{}-{}".format(major, minor, build, revision_str)) - - elif "release" in tag_name.lower(): - version = semantic_version.Version("{}.{}.{}".format(major, minor, build)) - - elif "nightly" in tag_name.lower(): - # Nightly's version_override.cmake only has FSO_VERSION_REVISION and FSO_VERSION_REVISION_STR set - # So, we have to pull the rest of the version string from version.cmake - print("Parsing version.cmake...") - with open(os.path.join("..", "..", "cmake", "version.cmake"), "r") as f: - major = _match_version_number(filetext, MAJOR_VERSION_PATTERN) - minor = _match_version_number(filetext, MINOR_VERSION_PATTERN) - build = _match_version_number(filetext, BUILD_VERSION_PATTERN) - version = semantic_version.Version("{}.{}.{}-{}".format(major, minor, build, revision_str)) - - else: + m = re.match(r"^(?:release|nightly)_(.+)$", tag_name) + if not m: print(" ERROR: malformed tag_name %s" % tag_name) sys.exit(1) + numeric, _, suffix = m.group(1).partition("-") + ver = numeric.replace("_", ".") + if suffix: + ver = "{}-{}".format(ver, suffix) + + version = semantic_version.Version(ver) print(" version: {}".format(version)) print(" version.prerelease: {}".format(version.prerelease)) return version @@ -131,33 +103,16 @@ def main(): "github": { "repo": os.environ["GITHUB_REPO"], }, - "ftp": { - "host": "scp.indiegames.us", - "user": os.environ["INDIEGAMES_USER"], - "pass": os.environ["INDIEGAMES_SSHPASS"], - "path": "public_html/builds/{type}/{version}/", - "mirrors": ( - "https://perses.feralhosting.com/datacorder/builds/{type}/{version}/{file}", - "https://scp.indiegames.us/builds/{type}/{version}/{file}", - ), - }, "nebula": { "user": os.environ["NEBULA_USER"], "password": os.environ["NEBULA_PASSWORD"], - }, - "hlp": { - "api": os.environ["HLP_API"], - "key": os.environ["HLP_KEY"], - }, - "templates": { - "nightly": "nightly.mako", - "release": "release.mako", - }, - "nightly": { - "hlp_board": 173, - }, - "release": { - "hlp_board": 50, + # OFP: releases upload to Nebula privately by default; set repo variable + # NEBULA_PRIVATE=false to publish publicly (which also fires Nebula's announcement). + "private": os.environ.get("NEBULA_PRIVATE", "true").strip().lower() != "false", + # Option A (register GitHub URLs) needs Nebula's URLS_FOR allowlist. Default off: + # upload the archives into Nebula storage instead (Option B). Set NEBULA_USE_URLS=true + # once the account is allowlisted. + "use_urls": os.environ.get("NEBULA_USE_URLS", "false").strip().lower() == "true", }, } @@ -230,8 +185,26 @@ def main(): files, sources = file_list.get_release_files(tag_name, config) print("Hashing files...") - for file in files: - installer.get_hashed_file_list(file) + if config["nebula"].get("use_urls"): + # Option A: archives stay on GitHub; we only register their URLs (needs URLS_FOR). + for file in files: + installer.get_hashed_file_list(file) + else: + # Option B: upload each archive into Nebula storage (no URLS_FOR needed), reusing + # the single download that content-hashing already performs. + print("Uploading build archives to Nebula...") + upload_session, upload_token = nebula.login_session(config) + if not upload_token: + print("ERROR: Nebula login failed; cannot upload archives.") + sys.exit(1) + + def _upload_after_hash(f, local_file): + if not nebula.upload_chunked(upload_session, upload_token, local_file, f.hash, f.size): + print("ERROR: failed to upload %s to Nebula." % f.name) + sys.exit(1) + + for file in files: + installer.get_hashed_file_list(file, after_hash=_upload_after_hash) # Construct the file groups # split the file list into a dictionary of {group: files} pairs using `groupby()` @@ -270,37 +243,20 @@ def main(): # Publish release builds to Nebula print("Publishing to Nebula...") + if not version.prerelease: + stability = "stable" + elif any("rc" in str(part).lower() for part in version.prerelease): + stability = "rc" + else: + stability = "nightly" nebula.submit_release( - nebula.render_nebula_release(version, "rc" if version.prerelease else "stable", files, config), + nebula.render_nebula_release(version, stability, files, config), config) - # Publish forum post, using the release.mako template - print("Publishing to Hard-light.net...") - fapi = forum.ForumAPI(config) - fapi.post_release(date.strftime(DATEFORMAT_FORUM), version, groups, sources) - else: - # Dead for now, nightly publish is done elsewhere - # Nightly specific action - print("Retrieving file list...") - files = file_list.get_ftp_files("nightly", tag_name, config) - - print("Hashing files...") - for file in files: - installer.get_hashed_file_list(file) - - print("Publishing to Nebula...") - nebula.submit_release( - nebula.render_nebula_release(version, "nightly", files, config), - config) - - # Compile commit messages into a log with all commits between this nightly and the previous nightly - log = check_output(("git", "log", "%s^..%s^" % (previous_tag, tag_name), "--no-merges", "--stat", "--pretty=format:\"%s\"" % LOG_FORMAT.strip())) - - # Publish forum post, using the nighty.mako template - print("Publishing to Hard-light.net...") - fapi = forum.ForumAPI(config) - fapi.post_nightly(date.strftime(DATEFORMAT_FORUM), os.environ["GITHUB_SHA"], files, log, (linux_success and windows_success)) + # OFP does not publish nightlies through this script. Nightly builds are handled + # separately, and this path previously relied on SCP-specific FTP/forum infra. + print("Nightly publishing via ci/post is not used by OFP; nothing to do.") print("Done!") diff --git a/ci/post/nebula.py b/ci/post/nebula.py index 792fc33f576..1fa7188c975 100644 --- a/ci/post/nebula.py +++ b/ci/post/nebula.py @@ -1,5 +1,6 @@ import os.path import sys +import hashlib import traceback import requests @@ -19,19 +20,19 @@ metadata = { 'type': 'engine', - 'title': 'FSO', + 'title': 'OFP', 'notes': '', - 'banner': 'https://fsnebula.org/storage/0d/e7/bf64bcdea9a9c115969cfb784e1ca457d24a7c2da4fc6f213521c3bb6abb.png', + 'banner': '17c6709138d5a8959c5605a1d6f465d6c1a0dada9881f5b905c01336848bb8de', 'screenshots': [], 'videos': [], - 'first_release': '2017-09-24', + 'first_release': '2026-07-12', 'cmdline': '', - 'mod_flag': ['FSO'], - 'tile': 'https://fsnebula.org/storage/ec/cc/0bf23e028c26d5175ff52d003bff85b0a17b0ddfc1130d65bdf6d36f6324.png', + 'mod_flag': ['OFP'], + 'tile': '12372e28d4654659b3f3a38f52b627a4ad37fb0af4fef63fa9dafa6afe5a27db', 'logo': None, 'release_thread': None, 'description': '', - 'id': 'FSO', + 'id': 'OFP', 'packages': [], } @@ -71,6 +72,7 @@ def render_nebula_release(version, stability, files, config): meta = metadata.copy() meta['version'] = str(version) meta['stability'] = stability # This can be one of ('stable', 'rc', 'nightly') + meta['private'] = config['nebula'].get('private', True) # OFP: default private until explicitly published for file in files: if file.content_hashes is None: @@ -83,17 +85,23 @@ def render_nebula_release(version, stability, files, config): if file.subgroup: group += '-' + file.subgroup + file_entry = { + 'dest': platforms[group] + '/' + subdirs.get(group, ''), + 'filesize': file.size, + 'checksum': ['sha256', file.hash], + 'filename': file.name + } + if config['nebula'].get('use_urls'): + # Option A: register externally-hosted (GitHub) URLs. Requires the account to be + # in Nebula's URLS_FOR allowlist. When off, the archive is uploaded to Nebula + # (Option B) and matched here by checksum instead. + file_entry['urls'] = [file.url] + file.mirrors + pkg = { 'name': group, 'notes': '', 'is_vp': False, - 'files': [{ - 'dest': platforms[group] + '/' + subdirs.get(group, ''), - 'filesize': file.size, - 'checksum': ['sha256', file.hash], - 'urls': [file.url] + file.mirrors, - 'filename': file.name - }], + 'files': [file_entry], 'environment': envs.get(group), 'filelist': [], 'status': 'required', @@ -220,6 +228,81 @@ def nebula_request(session, kind, path, **kwargs): return session.request(kind, uri, **request_args) +# Size of each chunk sent to Nebula's multiupload endpoint (Option B uploads). +UPLOAD_CHUNK_SIZE = 10 * 1024 * 1024 + + +def login_session(config): + """! Opens a requests Session and logs into Nebula. + + @returns (session, token). token is None if the login failed. + """ + session = requests.Session() + result = nebula_request(session, 'post', 'login', data={ + 'user': config['nebula']['user'], + 'password': config['nebula']['password'] + }) + if result.status_code != 200 or not result.json().get('result'): + return session, None + return session, result.json()['token'] + + +def upload_chunked(session, token, fileobj, checksum, filesize): + """! Uploads a file into Nebula's own storage via the multiupload API (Option B). + + No-op if a file with this checksum is already present. `fileobj` must be a seekable + binary stream; `checksum` is its sha256 (also used as the upload id). + + @returns True on success, False otherwise. + """ + headers = {'X-KN-TOKEN': token} + + # Skip if Nebula already has this exact file (re-runs, or shared across releases) + chk = nebula_request(session, 'post', 'upload/check', headers=headers, + data={'checksum': checksum}) + if chk.status_code == 200 and chk.json().get('result'): + print(" already on Nebula, skipping upload: {}".format(checksum)) + return True + + parts = max(1, (filesize + UPLOAD_CHUNK_SIZE - 1) // UPLOAD_CHUNK_SIZE) + + start = nebula_request(session, 'post', 'multiupload/start', headers=headers, + data={'id': checksum, 'size': str(filesize), 'parts': str(parts)}) + if start.status_code != 200 or not start.json().get('result'): + print(" ERROR: multiupload/start failed for {}".format(checksum)) + return False + finished = set(start.json().get('finished_parts', [])) + + fileobj.seek(0) + for idx in range(parts): + chunk = fileobj.read(UPLOAD_CHUNK_SIZE) + if idx in finished: + continue + + part = nebula_request(session, 'post', 'multiupload/part', headers=headers, + data={'id': checksum, 'part': str(idx)}, + files={'file': ('chunk', chunk)}) + if part.status_code != 200: + print(" ERROR: multiupload/part {} failed for {}".format(idx, checksum)) + return False + + verify = nebula_request(session, 'post', 'multiupload/verify_part', headers=headers, + data={'id': checksum, 'part': str(idx), + 'checksum': hashlib.sha256(chunk).hexdigest()}) + if verify.status_code != 200 or not verify.json().get('result'): + print(" ERROR: multiupload/verify_part {} failed for {}".format(idx, checksum)) + return False + + finish = nebula_request(session, 'post', 'multiupload/finish', headers=headers, + data={'id': checksum, 'checksum': checksum}) + if finish.status_code != 200 or not finish.json().get('result'): + print(" ERROR: multiupload/finish failed for {}: {}".format(checksum, finish.text[:200])) + return False + + print(" uploaded to Nebula: {}".format(checksum)) + return True + + def submit_release(meta, config): try: with requests.Session() as session: From 79c85193a903a4caeab58566078dec4f1cd74e16 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Tue, 26 May 2026 11:10:41 -0500 Subject: [PATCH 05/19] Allow models for intel entries (#3) * allow models for intel entries * address feedback --- code/menuui/techmenu.cpp | 88 +++++++++- code/menuui/techmenu.h | 3 + code/scripting/api/libs/ui.cpp | 4 +- code/scripting/api/objs/intelentry.cpp | 78 +++++++++ code/scripting/api/objs/propclass.cpp | 4 +- code/scripting/api/objs/shipclass.cpp | 4 +- code/scripting/api/objs/weaponclass.cpp | 4 +- test/src/menuui/test_intel_parse.cpp | 164 ------------------ test/src/source_groups.cmake | 4 - .../intel_parse/data/tables/tstrings.tbl | 30 ---- .../empty_file/data/tables/species.tbl | 0 .../invalid_start/data/tables/species.tbl | 8 - .../missing_name/data/tables/species.tbl | 28 --- .../only_white_space/data/tables/species.tbl | 5 - .../single/data/tables/species.tbl | 7 - .../single_translate/data/tables/species.tbl | 6 - .../intel_parse/three/data/tables/species.tbl | 27 --- 17 files changed, 175 insertions(+), 289 deletions(-) delete mode 100644 test/src/menuui/test_intel_parse.cpp delete mode 100644 test/test_data/menuui/intel_parse/data/tables/tstrings.tbl delete mode 100644 test/test_data/menuui/intel_parse/empty_file/data/tables/species.tbl delete mode 100644 test/test_data/menuui/intel_parse/invalid_start/data/tables/species.tbl delete mode 100644 test/test_data/menuui/intel_parse/missing_name/data/tables/species.tbl delete mode 100644 test/test_data/menuui/intel_parse/only_white_space/data/tables/species.tbl delete mode 100644 test/test_data/menuui/intel_parse/single/data/tables/species.tbl delete mode 100644 test/test_data/menuui/intel_parse/single_translate/data/tables/species.tbl delete mode 100644 test/test_data/menuui/intel_parse/three/data/tables/species.tbl diff --git a/code/menuui/techmenu.cpp b/code/menuui/techmenu.cpp index 407cd8c63db..b9a91f8dde9 100644 --- a/code/menuui/techmenu.cpp +++ b/code/menuui/techmenu.cpp @@ -322,7 +322,7 @@ void techroom_select_new_entry() if ((i < Cur_entry + 5) && (i > Cur_entry - 5) ) continue; - mprintf(("TECH ROOM: Dumping excess ship textures...\n")); + mprintf(("TECH ROOM: Dumping excess textures...\n")); model_page_out_textures(list_entry.model_num); @@ -346,6 +346,44 @@ void techroom_select_new_entry() Trackball_mode = 0; + // Intel entries can specify a tech model and, if present, it takes priority over the animation + if (Tab == INTEL_DATA_TAB) { + intel_data* iip = &Intel_info[Cur_entry_index]; + + if (VALID_FNAME(iip->tech_model)) { + int i = 0; + + for (auto& list_entry : *Current_list) { + if ((list_entry.model_num > -1) && (list_entry.textures_loaded)) { + if ((i < Cur_entry + 5) && (i > Cur_entry - 5)) + continue; + + mprintf(("TECH ROOM: Dumping excess textures...\n")); + + model_page_out_textures(list_entry.model_num); + + list_entry.textures_loaded = 0; + } + i++; + } + + Techroom_modelnum = model_load(iip->tech_model, nullptr, ErrorType::WARNING); + + if (Techroom_modelnum >= 0) { + Current_list->at(Cur_entry).model_num = Techroom_modelnum; + + // intel index isn't a ship_info index; pass -1 so we skip ship-specific paging + model_page_in_textures(Techroom_modelnum); + + Current_list->at(Cur_entry).textures_loaded = 1; + + techroom_init_desc(Current_list->at(Cur_entry).desc, Tech_desc_coords[gr_screen.res][SHIP_W_COORD]); + fsspeech_play(FSSPEECH_FROM_TECHROOM, Current_list->at(Cur_entry).desc); + return; + } + } + } + // load animation here, we now only have one loaded int stream_result = generic_anim_init_and_stream(&Current_list->at(Cur_entry).animation, Current_list->at(Cur_entry).tech_anim_filename, bm_get_type(Tech_background_bitmap), true); @@ -544,6 +582,12 @@ void techroom_ships_render(float frametime) if (sip->flags[Ship::Info_Flags::No_lighting]) noLighting = true; + } else if (Tab == INTEL_DATA_TAB) { + intel_data* iip = &Intel_info[Cur_entry_index]; + + rev_rate *= 3.0f; + closeup_pos = iip->closeup_pos; + closeup_zoom = iip->closeup_zoom; } else { weapon_info* wip = &Weapon_info[Cur_entry_index]; @@ -1075,6 +1119,9 @@ static void intel_info_init(intel_data* inteli) inteli->name[0] = '\0'; inteli->desc = ""; inteli->anim_filename[0] = '\0'; + inteli->tech_model[0] = '\0'; + inteli->closeup_pos = vmd_zero_vector; + inteli->closeup_zoom = 0.5f; inteli->flags = IIF_DEFAULT_VALUE; inteli->custom_data = {}; } @@ -1132,6 +1179,39 @@ void parse_intel_table(const char* filename) stuff_string(intel_p->anim_filename, F_NAME, NAME_LENGTH); } + if (optional_string("$Tech Model:")) { + stuff_string(intel_p->tech_model, F_NAME, NAME_LENGTH); + + if (optional_string("+Closeup_pos:")) { + stuff_vec3d(&intel_p->closeup_pos); + } else if (VALID_FNAME(intel_p->tech_model)) { + // auto-calculate the closeup position from the model + int model_idx = model_load(intel_p->tech_model, nullptr, ErrorType::WARNING); + if (model_idx >= 0) { + polymodel *pm = model_get(model_idx); + + intel_p->closeup_pos.xyz.z = fabsf(pm->maxs.xyz.z); + + float temp = fabsf(pm->mins.xyz.z); + if (temp > intel_p->closeup_pos.xyz.z) + intel_p->closeup_pos.xyz.z = temp; + + intel_p->closeup_pos.xyz.z *= -2.0f; + + model_unload(model_idx); + } + } + + if (optional_string("+Closeup_zoom:")) { + stuff_float(&intel_p->closeup_zoom); + + if (intel_p->closeup_zoom <= 0.0f) { + mprintf(("Warning! Intel entry '%s' has a +Closeup_zoom value that is less than or equal to 0 (%f). Setting to default value.\n", intel_p->name, intel_p->closeup_zoom)); + intel_p->closeup_zoom = 0.5f; + } + } + } + if (optional_string("$AlwaysInTechRoom:")) { //Change this from stuff_int to stuff_boolean because it can only ever be 1 or 0 here - Mjn int temp; @@ -1481,7 +1561,11 @@ void techroom_do_frame(float frametime) break; case INTEL_DATA_TAB: - techroom_anim_render(frametime); + if (Techroom_modelnum >= 0) { + techroom_ships_render(frametime); + } else { + techroom_anim_render(frametime); + } break; } diff --git a/code/menuui/techmenu.h b/code/menuui/techmenu.h index 9cff6e9d273..5c9c6c74e8f 100644 --- a/code/menuui/techmenu.h +++ b/code/menuui/techmenu.h @@ -21,6 +21,9 @@ typedef struct intel_data { char anim_filename[NAME_LENGTH]; int flags; SCP_map custom_data; + char tech_model[NAME_LENGTH]; + vec3d closeup_pos; + float closeup_zoom; } intel_data; // flags by Goober5000 diff --git a/code/scripting/api/libs/ui.cpp b/code/scripting/api/libs/ui.cpp index 5f97d18499b..06de12e1323 100644 --- a/code/scripting/api/libs/ui.cpp +++ b/code/scripting/api/libs/ui.cpp @@ -876,9 +876,9 @@ ADE_FUNC(commitToMission, ADE_FUNC(renderBriefingModel, l_UserInterface_Brief, - "string PofName, number CloseupZoom, vector CloseupPos, number X1, number Y1, number X2, number Y2, [number RotationPercent =0, number PitchPercent =0, " + "string PofName, number CloseupZoom, vector CloseupPos, number X1, number Y1, number X2, number Y2, [number RotationPercent =40, number PitchPercent =0, " "number " - "BankPercent=40, number Zoom=1.3, boolean Lighting=true, boolean Jumpnode=false]", + "BankPercent=0, number Zoom=1.3, boolean Lighting=true, boolean Jumpnode=false]", "Draws a pof. True for regular lighting, false for flat lighting.", "boolean", "Whether pof was rendered") diff --git a/code/scripting/api/objs/intelentry.cpp b/code/scripting/api/objs/intelentry.cpp index 80ba5794f92..b1fe08cb478 100644 --- a/code/scripting/api/objs/intelentry.cpp +++ b/code/scripting/api/objs/intelentry.cpp @@ -3,6 +3,8 @@ #include "intelentry.h" #include "menuui/techmenu.h" +#include "model/modelrender.h" +#include "scripting/api/objs/vecmath.h" namespace scripting { namespace api { @@ -198,6 +200,82 @@ ADE_FUNC(getIntelEntryIndex, l_Intelentry, nullptr, "Gets the index value of the return ade_set_args(L, "i", idx + 1); // Lua is 1-based } +ADE_FUNC(renderTechModel, + l_Intelentry, + "number X1, number Y1, number X2, number Y2, [number RotationPercent =40, number PitchPercent =0, number " + "BankPercent=0, number Zoom=1.3, boolean Lighting=true]", + "Draws the intel entry's tech model. True for regular lighting, false for flat lighting. Returns false if the intel entry has no tech model defined.", + "boolean", + "Whether the intel entry was rendered") +{ + int x1, y1, x2, y2; + angles rot_angles = {0.0f, 0.0f, 40.0f}; + int idx; + float zoom = 1.3f; + bool lighting = true; + if (!ade_get_args(L, "oiiii|ffffb", l_Intelentry.Get(&idx), &x1, &y1, &x2, &y2, &rot_angles.h, &rot_angles.p, &rot_angles.b, &zoom, &lighting)) + return ade_set_error(L, "b", false); + + if (idx < 0 || idx >= intel_info_size()) + return ade_set_args(L, "b", false); + + if (x2 < x1 || y2 < y1) + return ade_set_args(L, "b", false); + + intel_data* iip = &Intel_info[idx]; + + if (!VALID_FNAME(iip->tech_model)) + return ade_set_args(L, "b", false); + + CLAMP(rot_angles.p, 0.0f, 100.0f); + CLAMP(rot_angles.b, 0.0f, 100.0f); + CLAMP(rot_angles.h, 0.0f, 100.0f); + + // Handle angles + matrix orient = vmd_identity_matrix; + angles view_angles = {-0.6f, 0.0f, 0.0f}; + vm_angles_2_matrix(&orient, &view_angles); + + rot_angles.p = (rot_angles.p * 0.01f) * PI2; + rot_angles.b = (rot_angles.b * 0.01f) * PI2; + rot_angles.h = (rot_angles.h * 0.01f) * PI2; + vm_rotate_matrix_by_angles(&orient, &rot_angles); + + return ade_set_args(L, "b", render_tech_model(TECH_POF, x1, y1, x2, y2, zoom, lighting, -1, &orient, iip->tech_model, iip->closeup_zoom, &iip->closeup_pos)); +} + +// Nuke's alternate tech model rendering function +ADE_FUNC(renderTechModel2, + l_Intelentry, + "number X1, number Y1, number X2, number Y2, [orientation Orientation=nil, number Zoom=1.3]", + "Draws the intel entry's tech model. Returns false if the intel entry has no tech model defined.", + "boolean", + "Whether the intel entry was rendered") +{ + int x1, y1, x2, y2; + int idx; + float zoom = 1.3f; + matrix_h* mh = nullptr; + if (!ade_get_args(L, "oiiii|of", l_Intelentry.Get(&idx), &x1, &y1, &x2, &y2, l_Matrix.GetPtr(&mh), &zoom)) + return ade_set_error(L, "b", false); + + if (idx < 0 || idx >= intel_info_size()) + return ade_set_args(L, "b", false); + + if (x2 < x1 || y2 < y1) + return ade_set_args(L, "b", false); + + intel_data* iip = &Intel_info[idx]; + + if (!VALID_FNAME(iip->tech_model)) + return ade_set_args(L, "b", false); + + matrix identity_orient = vmd_identity_matrix; + matrix* orient = mh != nullptr ? mh->GetMatrix() : &identity_orient; + + return ade_set_args(L, "b", render_tech_model(TECH_POF, x1, y1, x2, y2, zoom, true, -1, orient, iip->tech_model, iip->closeup_zoom, &iip->closeup_pos)); +} + } } diff --git a/code/scripting/api/objs/propclass.cpp b/code/scripting/api/objs/propclass.cpp index 00532d6c33e..d22c7cd1849 100644 --- a/code/scripting/api/objs/propclass.cpp +++ b/code/scripting/api/objs/propclass.cpp @@ -216,8 +216,8 @@ ADE_FUNC(isValid, ADE_FUNC(renderTechModel, l_Propclass, - "number X1, number Y1, number X2, number Y2, [number RotationPercent =0, number PitchPercent =0, number " - "BankPercent=40, number Zoom=1.3, boolean Lighting=true]", + "number X1, number Y1, number X2, number Y2, [number RotationPercent =40, number PitchPercent =0, number " + "BankPercent=0, number Zoom=1.3, boolean Lighting=true]", "Draws prop model as if in techroom. True for regular lighting, false for flat lighting.", "boolean", "Whether prop was rendered") diff --git a/code/scripting/api/objs/shipclass.cpp b/code/scripting/api/objs/shipclass.cpp index b1482807e7d..1c5144e7290 100644 --- a/code/scripting/api/objs/shipclass.cpp +++ b/code/scripting/api/objs/shipclass.cpp @@ -1146,8 +1146,8 @@ ADE_FUNC(isInTechroom, l_Shipclass, NULL, "Gets whether or not the ship class is ADE_FUNC(renderTechModel, l_Shipclass, - "number X1, number Y1, number X2, number Y2, [number RotationPercent =0, number PitchPercent =0, number " - "BankPercent=40, number Zoom=1.3, boolean Lighting=true, teamcolor TeamColor=nil, string[] DestroyedSubsystems=nil]", + "number X1, number Y1, number X2, number Y2, [number RotationPercent =40, number PitchPercent =0, number " + "BankPercent=0, number Zoom=1.3, boolean Lighting=true, teamcolor TeamColor=nil, string[] DestroyedSubsystems=nil]", "Draws ship model as if in techroom. True for regular lighting, false for flat lighting.", "boolean", "Whether ship was rendered") diff --git a/code/scripting/api/objs/weaponclass.cpp b/code/scripting/api/objs/weaponclass.cpp index 86a69c0eb18..2f4141bef05 100644 --- a/code/scripting/api/objs/weaponclass.cpp +++ b/code/scripting/api/objs/weaponclass.cpp @@ -1093,8 +1093,8 @@ ADE_FUNC(isValid, l_Weaponclass, NULL, "Detects whether handle is valid", "boole ADE_FUNC(renderTechModel, l_Weaponclass, - "number X1, number Y1, number X2, number Y2, [number RotationPercent =0, number PitchPercent =0, number " - "BankPercent=40, number Zoom=1.3, boolean Lighting=true]", + "number X1, number Y1, number X2, number Y2, [number RotationPercent =40, number PitchPercent =0, number " + "BankPercent=0, number Zoom=1.3, boolean Lighting=true]", "Draws weapon tech model. True for regular lighting, false for flat lighting.", "boolean", "Whether weapon was rendered") diff --git a/test/src/menuui/test_intel_parse.cpp b/test/src/menuui/test_intel_parse.cpp deleted file mode 100644 index 2e33634e7cc..00000000000 --- a/test/src/menuui/test_intel_parse.cpp +++ /dev/null @@ -1,164 +0,0 @@ -#include - -#include "menuui/techmenu.h" -#include "localization/localize.h" - -#include "util/FSTestFixture.h" - -class IntelParseTest : public test::FSTestFixture { - public: - IntelParseTest() : test::FSTestFixture(INIT_CFILE) { - pushModDir("menuui"); - pushModDir("intel_parse"); - } - - protected: - void SetUp() override { - test::FSTestFixture::SetUp(); - - // techroom_intel_init() is not here so it is handled in - // the test if it throws. - } - void TearDown() override { - // this is here so that it is run even when a test fails - techroom_intel_reset(); - - test::FSTestFixture::TearDown(); - } -}; - -// Commonly used expected intel data entries. -static intel_data expected_foo = { - "Foo name", // XSTR id 3000 - "Foo desc", // XSTR id 3001 - "Foo anim", - IIF_IN_TECH_DATABASE | IIF_DEFAULT_IN_TECH_DATABASE, - SCP_map{} -}; -static intel_data expected_bar = { - "Bar name", // XSTR id 3002 - "Bar desc", // XSTR id 3003 - "Bar anim", - 0, - SCP_map{} -}; -static intel_data expected_baz = { - "Baz name", // XSTR id 3004 - "Baz desc", // XSTR id 3005 - "Baz anim", - IIF_IN_TECH_DATABASE | IIF_DEFAULT_IN_TECH_DATABASE, - SCP_map{} -}; - -static intel_data expected_qux = { - "Qux name", // No XSTR - "Qux desc", // No XSTR - "Qux anim", - IIF_IN_TECH_DATABASE | IIF_DEFAULT_IN_TECH_DATABASE, - SCP_map{} -}; - -// use SCOPED_TRACE("") before calling this -static void test_intel_data_equal(const intel_data& i1, const intel_data& i2) -{ - EXPECT_STREQ(i1.name, i2.name); - EXPECT_STREQ(i1.desc.c_str(), i2.desc.c_str()); - EXPECT_STREQ(i1.anim_filename, i2.anim_filename); - EXPECT_EQ(i1.flags, i2.flags); -} - -/* - * These tests assume that the raw file is preprocessed correctly, - * i.e. comments are removed correctly, necessary hacking on retail - * data is done, ... - * Also, XSTR("...", 123) parsing is assumed to be done correctly. - * Only parsing of the actual contents is tested. - */ - -// A missing file just means there are no entries. -TEST_F(IntelParseTest, missing_file) { - techroom_intel_init(); - - EXPECT_EQ(intel_info_size(), 0); -} - -// The same with an empty file. -TEST_F(IntelParseTest, empty_file) { - techroom_intel_init(); - - EXPECT_EQ(intel_info_size(), 0); -} - -// The same with only white space. -TEST_F(IntelParseTest, only_white_space) { - techroom_intel_init(); - - EXPECT_EQ(intel_info_size(), 0); -} - -// A single valid entry. -TEST_F(IntelParseTest, single) { - SCOPED_TRACE("single"); - - techroom_intel_init(); - - ASSERT_EQ(intel_info_size(), 1); - - test_intel_data_equal(expected_foo, Intel_info[0]); -} - -// The same, but with translation to German. -TEST_F(IntelParseTest, single_translate) { - SCOPED_TRACE("single_translate"); - - lcl_xstr_close(); - lcl_set_language(LCL_GERMAN); - lcl_xstr_init(); - - techroom_intel_init(); - - intel_data expected = { - "Foo name German", // XSTR id 3000 - "Foo desc German", // XSTR id 3001 - "Foo anim", - IIF_IN_TECH_DATABASE | IIF_DEFAULT_IN_TECH_DATABASE, - SCP_map{} - }; - - ASSERT_EQ(intel_info_size(), 1); - - test_intel_data_equal(expected, Intel_info[0]); -} - -// Rubbish followed by a valid entry. -// Parsing stops at the rubbish. -TEST_F(IntelParseTest, invalid_start) { - techroom_intel_init(); - - EXPECT_EQ(intel_info_size(), 0); -} - -// Three valid entries. -TEST_F(IntelParseTest, three) { - SCOPED_TRACE("three"); - - techroom_intel_init(); - - ASSERT_EQ(intel_info_size(), 3); - - test_intel_data_equal(expected_foo, Intel_info[0]); - test_intel_data_equal(expected_bar, Intel_info[1]); - test_intel_data_equal(expected_baz, Intel_info[2]); -} - -// A valid entry followed by one with a missing $Name: line. -// The second entry should be ignored. Parsing should stop there. -TEST_F(IntelParseTest, missing_name) { - SCOPED_TRACE("missing_name"); - - EXPECT_ANY_THROW(techroom_intel_init()); - - ASSERT_EQ(intel_info_size(), 1); - - test_intel_data_equal(expected_foo, Intel_info[0]); -} \ No newline at end of file diff --git a/test/src/source_groups.cmake b/test/src/source_groups.cmake index 08a5c1f8245..ffb33da0b77 100644 --- a/test/src/source_groups.cmake +++ b/test/src/source_groups.cmake @@ -31,10 +31,6 @@ add_file_folder("Math" math/test_vecmat.cpp ) -add_file_folder("menuui" - menuui/test_intel_parse.cpp -) - add_file_folder("mod" mod/test_mod_table.cpp ) diff --git a/test/test_data/menuui/intel_parse/data/tables/tstrings.tbl b/test/test_data/menuui/intel_parse/data/tables/tstrings.tbl deleted file mode 100644 index 0bd3842b9fc..00000000000 --- a/test/test_data/menuui/intel_parse/data/tables/tstrings.tbl +++ /dev/null @@ -1,30 +0,0 @@ -#default - -3000, "Foo name default" - -3001, "Foo desc default" - -3002, "Bar name default" - -3003, "Bar desc default" - -3004, "Baz name default" - -3005, "Baz desc default" - -#end -#German - -3000, "Foo name German" - -3001, "Foo desc German" - -3002, "Bar name German" - -3003, "Bar desc German" - -3004, "Baz name German" - -3005, "Baz desc German" - -#end diff --git a/test/test_data/menuui/intel_parse/empty_file/data/tables/species.tbl b/test/test_data/menuui/intel_parse/empty_file/data/tables/species.tbl deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/test/test_data/menuui/intel_parse/invalid_start/data/tables/species.tbl b/test/test_data/menuui/intel_parse/invalid_start/data/tables/species.tbl deleted file mode 100644 index 353bc6f26a6..00000000000 --- a/test/test_data/menuui/intel_parse/invalid_start/data/tables/species.tbl +++ /dev/null @@ -1,8 +0,0 @@ -; The line below should stop parsing. -this is not a valid table -$Entry: -$Name: XSTR("Foo name", 3000) -$Anim: Foo anim -$AlwaysInTechRoom: 1 -$Description: XSTR("Foo desc", 3001) -$end_multi_text diff --git a/test/test_data/menuui/intel_parse/missing_name/data/tables/species.tbl b/test/test_data/menuui/intel_parse/missing_name/data/tables/species.tbl deleted file mode 100644 index 3e973471076..00000000000 --- a/test/test_data/menuui/intel_parse/missing_name/data/tables/species.tbl +++ /dev/null @@ -1,28 +0,0 @@ -; A valid entry. -$Entry: -$Name: XSTR("Foo name",3000) -$Anim: Foo anim -$AlwaysInTechRoom: 1 -$Description: -XSTR( -"Foo desc", 3001) -$end_multi_text - -; An otherwise valid entry but missing the $Name: line. -$Entry: -$Anim: Bar anim -$AlwaysInTechRoom: 0 -$Description: -XSTR( -"Bar desc", 3003) -$end_multi_text - -; Another valid entry. -$Entry: -$Name: XSTR("Baz name",3004) -$Anim: Baz anim -$AlwaysInTechRoom: 1 -$Description: -XSTR( -"Baz desc", 3005) -$end_multi_text diff --git a/test/test_data/menuui/intel_parse/only_white_space/data/tables/species.tbl b/test/test_data/menuui/intel_parse/only_white_space/data/tables/species.tbl deleted file mode 100644 index 2dc8a45b90d..00000000000 --- a/test/test_data/menuui/intel_parse/only_white_space/data/tables/species.tbl +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/test/test_data/menuui/intel_parse/single/data/tables/species.tbl b/test/test_data/menuui/intel_parse/single/data/tables/species.tbl deleted file mode 100644 index 91f42b89e13..00000000000 --- a/test/test_data/menuui/intel_parse/single/data/tables/species.tbl +++ /dev/null @@ -1,7 +0,0 @@ -$Entry: -$Name: XSTR("Foo name", 3000) -$Anim: Foo anim -$AlwaysInTechRoom: 1 -$Description: -XSTR("Foo desc", 3001) -$end_multi_text diff --git a/test/test_data/menuui/intel_parse/single_translate/data/tables/species.tbl b/test/test_data/menuui/intel_parse/single_translate/data/tables/species.tbl deleted file mode 100644 index 177430174e5..00000000000 --- a/test/test_data/menuui/intel_parse/single_translate/data/tables/species.tbl +++ /dev/null @@ -1,6 +0,0 @@ -$Entry: -$Name: XSTR("Foo name", 3000) -$Anim: Foo anim -$AlwaysInTechRoom: 1 -$Description: XSTR("Foo desc", 3001) -$end_multi_text diff --git a/test/test_data/menuui/intel_parse/three/data/tables/species.tbl b/test/test_data/menuui/intel_parse/three/data/tables/species.tbl deleted file mode 100644 index 9b1ae1c4db5..00000000000 --- a/test/test_data/menuui/intel_parse/three/data/tables/species.tbl +++ /dev/null @@ -1,27 +0,0 @@ -; simple test with three valid entries -$Entry: -$Name: XSTR("Foo name",3000) -$Anim: Foo anim -$AlwaysInTechRoom: 1 -$Description: -XSTR( -"Foo desc", 3001) -$end_multi_text - -$Entry: -$Name: XSTR("Bar name", 3002) -$Anim: Bar anim -$AlwaysInTechRoom: 0 -$Description: -XSTR( -"Bar desc", 3003) -$end_multi_text - -$Entry: -$Name: XSTR("Baz name",3004) -$Anim: Baz anim -$AlwaysInTechRoom: 1 -$Description: -XSTR( -"Baz desc", 3005) -$end_multi_text From 2bd6703ac778a15327fe4477738f24cb29aaff1f Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Tue, 12 May 2026 19:22:00 -0500 Subject: [PATCH 06/19] special points object type --- code/coordinate_points/coordinate_point.cpp | 170 ++++++++++++++++++++ code/coordinate_points/coordinate_point.h | 67 ++++++++ code/hud/hudbrackets.cpp | 34 +++- code/hud/hudescort.cpp | 122 ++++++++++++-- code/hud/hudtargetbox.cpp | 39 +++++ code/hud/hudtargetbox.h | 1 + code/mission/missionparse.cpp | 64 +++++++- code/missioneditor/missionsave.cpp | 89 ++++++++++ code/missioneditor/missionsave.h | 2 + code/object/object.cpp | 18 ++- code/object/object.h | 3 +- code/parse/sexp.h | 3 +- code/scripting/ade.cpp | 3 + code/scripting/api/libs/mission.cpp | 47 ++++++ code/scripting/api/objs/coordinatepoint.cpp | 129 +++++++++++++++ code/scripting/api/objs/coordinatepoint.h | 13 ++ code/scripting/api/objs/object.cpp | 10 ++ code/scripting/api/objs/oswpt.cpp | 19 ++- code/source_groups.cmake | 8 + qtfred/src/mission/Editor.cpp | 9 ++ qtfred/src/mission/Editor.h | 2 + qtfred/src/mission/EditorViewport.cpp | 3 + qtfred/src/mission/EditorViewport.h | 1 + qtfred/src/ui/widgets/ObjectComboBox.cpp | 4 + 24 files changed, 832 insertions(+), 28 deletions(-) create mode 100644 code/coordinate_points/coordinate_point.cpp create mode 100644 code/coordinate_points/coordinate_point.h create mode 100644 code/scripting/api/objs/coordinatepoint.cpp create mode 100644 code/scripting/api/objs/coordinatepoint.h diff --git a/code/coordinate_points/coordinate_point.cpp b/code/coordinate_points/coordinate_point.cpp new file mode 100644 index 00000000000..8da9935ae02 --- /dev/null +++ b/code/coordinate_points/coordinate_point.cpp @@ -0,0 +1,170 @@ +#include "coordinate_points/coordinate_point.h" + +#include "object/object.h" +#include "math/vecmat.h" +#include "parse/parselo.h" + +SCP_list Coordinate_points; +SCP_vector Parse_coordinate_points; + +static constexpr float COORDINATE_POINT_DEFAULT_RADIUS = 1.0f; + +static const char* Coordinate_point_shape_names[] = { + "Triangle", + "Square", + "Diamond", + "Pentagon", + "Hexagon", + "Cross", +}; + +static_assert(sizeof(Coordinate_point_shape_names) / sizeof(Coordinate_point_shape_names[0]) + == static_cast(CoordinatePointShape::NUM_SHAPES), + "Coordinate_point_shape_names is out of sync with CoordinatePointShape"); + +const char* coordinate_point_shape_to_string(CoordinatePointShape s) +{ + auto idx = static_cast(s); + if (idx >= static_cast(CoordinatePointShape::NUM_SHAPES)) { + return Coordinate_point_shape_names[0]; + } + return Coordinate_point_shape_names[idx]; +} + +CoordinatePointShape coordinate_point_shape_from_string(const char* s) +{ + if (s != nullptr) { + for (size_t i = 0; i < static_cast(CoordinatePointShape::NUM_SHAPES); ++i) { + if (!stricmp(Coordinate_point_shape_names[i], s)) { + return static_cast(i); + } + } + } + return CoordinatePointShape::Triangle; +} + +mission_coordinate_point::mission_coordinate_point() +{ + gr_init_alphacolor(&display_color, 255, 255, 255, 255); +} + +parsed_coordinate_point::parsed_coordinate_point() +{ + position = vmd_zero_vector; + gr_init_alphacolor(&display_color, 255, 255, 255, 255); +} + +void coordinate_points_level_close() +{ + for (auto& cp : Coordinate_points) { + if (cp.objnum >= 0 && Objects[cp.objnum].type != OBJ_NONE) { + obj_delete(cp.objnum); + } + } + Coordinate_points.clear(); +} + +static bool coordinate_point_name_in_use(const char* name) +{ + for (const auto& cp : Coordinate_points) { + if (!stricmp(cp.name.c_str(), name)) { + return true; + } + } + return false; +} + +static SCP_string make_unique_default_name() +{ + for (int i = 1;; ++i) { + SCP_string candidate; + sprintf(candidate, "Coordinate Point %d", i); + if (!coordinate_point_name_in_use(candidate.c_str())) { + return candidate; + } + } +} + +int coordinate_point_create(const vec3d* pos, const char* name) +{ + Assertion(pos != nullptr, "coordinate_point_create requires a non-null position"); + + flagset flags; + flags.set(Object::Object_Flags::Renders); + int objnum = obj_create(OBJ_COORDINATE_POINT, -1, -1, nullptr, pos, COORDINATE_POINT_DEFAULT_RADIUS, flags); + if (objnum < 0) { + return -1; + } + + Coordinate_points.emplace_back(); + auto& cp = Coordinate_points.back(); + if (name != nullptr && *name != '\0' && !coordinate_point_name_in_use(name)) { + cp.name = name; + } else { + cp.name = make_unique_default_name(); + } + cp.objnum = objnum; + + return objnum; +} + +void coordinate_point_delete(int objnum) +{ + for (auto it = Coordinate_points.begin(); it != Coordinate_points.end(); ++it) { + if (it->objnum == objnum) { + it->objnum = -1; // mark detached so destructor / cleanup doesn't double-free + Coordinate_points.erase(it); + return; + } + } +} + +mission_coordinate_point* find_coordinate_point_by_name(const char* name) +{ + if (name == nullptr) { + return nullptr; + } + for (auto& cp : Coordinate_points) { + if (!stricmp(cp.name.c_str(), name)) { + return &cp; + } + } + return nullptr; +} + +mission_coordinate_point* find_coordinate_point_by_objnum(int objnum) +{ + if (objnum < 0) { + return nullptr; + } + for (auto& cp : Coordinate_points) { + if (cp.objnum == objnum) { + return &cp; + } + } + return nullptr; +} + +void post_process_mission_coordinate_points() +{ + for (const auto& parsed : Parse_coordinate_points) { + int objnum = coordinate_point_create(&parsed.position, + parsed.name.empty() ? nullptr : parsed.name.c_str()); + if (objnum < 0) { + continue; + } + + auto* cp = find_coordinate_point_by_objnum(objnum); + if (cp == nullptr) { + continue; + } + + cp->category = parsed.category; + cp->display_color = parsed.display_color; + cp->shape = parsed.shape; + cp->size_scale = parsed.size_scale; + cp->escort_priority = parsed.escort_priority; + } + + Parse_coordinate_points.clear(); +} diff --git a/code/coordinate_points/coordinate_point.h b/code/coordinate_points/coordinate_point.h new file mode 100644 index 00000000000..98011e025b4 --- /dev/null +++ b/code/coordinate_points/coordinate_point.h @@ -0,0 +1,67 @@ +#pragma once + +#include "globalincs/pstypes.h" +#include "graphics/2d.h" + +struct vec3d; + +enum class CoordinatePointShape { + Triangle = 0, + Square, + Diamond, + Pentagon, + Hexagon, + Cross, + NUM_SHAPES +}; + +constexpr float COORDINATE_POINT_SIZE_MIN = 0.25f; +constexpr float COORDINATE_POINT_SIZE_MAX = 8.0f; + +const char* coordinate_point_shape_to_string(CoordinatePointShape s); +CoordinatePointShape coordinate_point_shape_from_string(const char* s); // returns Triangle on miss + +struct mission_coordinate_point +{ + SCP_string name; + SCP_string category; + color display_color; + CoordinatePointShape shape = CoordinatePointShape::Triangle; + float size_scale = 1.0f; + int escort_priority = 0; // 0 = not on escort list; >0 = on the list (and sort key) + int objnum = -1; + + mission_coordinate_point(); +}; + +extern SCP_list Coordinate_points; + +struct parsed_coordinate_point +{ + SCP_string name; + SCP_string category; + vec3d position; + color display_color; + CoordinatePointShape shape = CoordinatePointShape::Triangle; + float size_scale = 1.0f; + int escort_priority = 0; + + parsed_coordinate_point(); +}; + +extern SCP_vector Parse_coordinate_points; + +void coordinate_points_level_close(); + +void post_process_mission_coordinate_points(); + +// Creates a coordinate point at the given position, allocating an Objects[] slot. +// If name is null or empty, an auto-name "Coordinate Point N" is generated (unique in mission). +// Returns the objnum on success, or -1 on failure. +int coordinate_point_create(const vec3d* pos, const char* name = nullptr); + +// Removes the coordinate point whose Objects[] slot is objnum. +void coordinate_point_delete(int objnum); + +mission_coordinate_point* find_coordinate_point_by_name(const char* name); +mission_coordinate_point* find_coordinate_point_by_objnum(int objnum); diff --git a/code/hud/hudbrackets.cpp b/code/hud/hudbrackets.cpp index 0337ad4845b..eff306a6963 100644 --- a/code/hud/hudbrackets.cpp +++ b/code/hud/hudbrackets.cpp @@ -15,6 +15,7 @@ #include "hud/hudbrackets.h" #include "hud/hudtarget.h" #include "iff_defs/iff_defs.h" +#include "coordinate_points/coordinate_point.h" #include "jumpnode/jumpnode.h" #include "mission/missionparse.h" #include "object/object.h" @@ -455,6 +456,17 @@ void HudGaugeBrackets::renderObjectBrackets(object *targetp, color *clr, int w_c } break; + case OBJ_COORDINATE_POINT: + { + // Coordinate points have no model; project the world position to screen and draw a small bracket. + vertex vtx; + g3_rotate_vertex(&vtx, &targetp->pos); + g3_project_vertex(&vtx); + x1 = x2 = (int)vtx.screen.xyw.x; + y1 = y2 = (int)vtx.screen.xyw.y; + } + break; + default: Int3(); // should never happen return; @@ -470,7 +482,15 @@ void HudGaugeBrackets::renderObjectBrackets(object *targetp, color *clr, int w_c Hud_target_h = gr_screen.clip_height; } - if(clr->red && clr->green && clr->blue ) { + if (targetp->type == OBJ_COORDINATE_POINT) { + // Coordinate points always use their own display color, even if a component is zero. + auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(targetp)); + if (cp != nullptr) { + gr_set_color_fast(&cp->display_color); + } else { + gr_set_color_fast(clr); + } + } else if(clr->red && clr->green && clr->blue ) { gr_set_color_fast(clr); } else { // if no specific color defined, use the IFF color. @@ -671,10 +691,20 @@ void HudGaugeBrackets::renderBoundingBrackets(int x1, int y1, int x2, int y2, in tinfo_name = XSTR("debris", 348); } break; - case OBJ_JUMP_NODE: + case OBJ_JUMP_NODE: { auto jnp = jumpnode_get_by_objnum(target_objnum); tinfo_name = jnp ? jnp->GetDisplayName() : ""; break; + } + case OBJ_COORDINATE_POINT: { + auto* cp = find_coordinate_point_by_objnum(target_objnum); + if (cp != nullptr) { + tinfo_name = cp->name.c_str(); + if (!cp->category.empty()) + tinfo_class = cp->category.c_str(); + } + break; + } } if(tinfo_name && (flags & TARGET_DISPLAY_NAME)) { diff --git a/code/hud/hudescort.cpp b/code/hud/hudescort.cpp index 579c254f59d..fc283abcb4e 100644 --- a/code/hud/hudescort.cpp +++ b/code/hud/hudescort.cpp @@ -11,6 +11,7 @@ +#include "coordinate_points/coordinate_point.h" #include "gamesnd/gamesnd.h" #include "globalincs/alphacolors.h" #include "globalincs/linklist.h" @@ -331,6 +332,36 @@ void HudGaugeEscort::renderIcon(int x, int y, int index, float scale, bool confi object* objp = nullptr; ship* sp = nullptr; + // Coordinate-point entries render very differently: name + category, no D, no hull bar. + if (!config && Objects[objnum].type == OBJ_COORDINATE_POINT) { + auto* cp = find_coordinate_point_by_objnum(objnum); + if (cp == nullptr) { + return; + } + + gr_set_color_fast(&cp->display_color); + + char buf[256]; + if (!cp->category.empty()) { + snprintf(buf, sizeof(buf), "%s (%s)", cp->name.c_str(), cp->category.c_str()); + } else { + snprintf(buf, sizeof(buf), "%s", cp->name.c_str()); + } + + const int w = font::force_fit_string(buf, 255, fl2i(ship_name_max_width * scale), scale); + if (right_align_names) { + renderString(x + fl2i(ship_name_offsets[0] * scale) + fl2i(ship_name_max_width * scale) - w, + y + fl2i(ship_name_offsets[1] * scale), EG_ESCORT1 + index, buf, scale, config); + } else { + renderString(x + fl2i(ship_name_offsets[0] * scale), y + fl2i(ship_name_offsets[1] * scale), + EG_ESCORT1 + index, buf, scale, config); + } + + // No 'D', no hull/shield integrity. Leave the bar region empty. + setGaugeColor(HUD_C_NONE, config); + return; + } + if (!config) { objp = &Objects[objnum]; sp = &Ships[objp->instance]; @@ -588,14 +619,27 @@ static bool escort_compare(const escort_info &escort1, const escort_info &escort if (escort1.priority != escort2.priority) { return (escort1.priority > escort2.priority); - } else if ( (escort1.objnum >= 0) && (Objects[escort1.objnum].instance >= 0) && - (escort2.objnum >= 0) && (Objects[escort2.objnum].instance >= 0) ) - { - const char *name1 = Ships[Objects[escort1.objnum].instance].ship_name; - const char *name2 = Ships[Objects[escort2.objnum].instance].ship_name; - - return (stricmp(name1, name2) > 0); } + + // Equal priority: alphabetical by display name. Resolve names per type so + // coordinate points (which have no Ships[] entry) don't blow up the sort. + auto get_sort_name = [](const escort_info& es) -> const char* { + if (es.objnum < 0) + return ""; + const object& obj = Objects[es.objnum]; + if (obj.type == OBJ_SHIP && obj.instance >= 0) { + return Ships[obj.instance].ship_name; + } + if (obj.type == OBJ_COORDINATE_POINT) { + auto* cp = find_coordinate_point_by_objnum(es.objnum); + return (cp != nullptr) ? cp->name.c_str() : ""; + } + return ""; + }; + + const char* name1 = get_sort_name(escort1); + const char* name2 = get_sort_name(escort2); + return (stricmp(name1, name2) > 0); } return false; @@ -678,6 +722,22 @@ void hud_create_complete_escort_list() Escort_ships.push_back(einfo); } + + // Auto-add coordinate points whose escort priority is non-zero. + for (const auto& cp : Coordinate_points) { + if (cp.objnum < 0) + continue; + if (cp.escort_priority <= 0) + continue; + if (Objects[cp.objnum].flags[Object::Object_Flags::Should_be_dead]) + continue; + + escort_info einfo; + einfo.objnum = cp.objnum; + einfo.obj_signature = Objects[cp.objnum].signature; + einfo.priority = cp.escort_priority; + Escort_ships.push_back(einfo); + } } } @@ -780,6 +840,12 @@ void hud_escort_cull_list() remove = true; } } + } else if (Objects[objnum].type == OBJ_COORDINATE_POINT) { + // Drop if deleted or had escort priority cleared. + auto* cp = find_coordinate_point_by_objnum(objnum); + if (cp == nullptr || cp->escort_priority <= 0) { + remove = true; + } } if (remove) { @@ -817,7 +883,11 @@ void hud_add_ship_to_escort(int objnum, int supress_feedback) return; } - if (Objects[objnum].type != OBJ_SHIP) { + const auto& obj = Objects[objnum]; + const bool is_ship = (obj.type == OBJ_SHIP); + const bool is_coord_point = (obj.type == OBJ_COORDINATE_POINT); + + if (!is_ship && !is_coord_point) { if ( !supress_feedback ) { snd_play( gamesnd_get_game_sound(GameSounds::TARGET_FAIL)); } @@ -825,29 +895,42 @@ void hud_add_ship_to_escort(int objnum, int supress_feedback) return; } - // check if ship is already on complete escort list + // Coordinate points need a positive escort_priority to be added. + const mission_coordinate_point* cp_ptr = nullptr; + if (is_coord_point) { + cp_ptr = find_coordinate_point_by_objnum(objnum); + if (cp_ptr == nullptr || cp_ptr->escort_priority <= 0) { + if (!supress_feedback) { + snd_play(gamesnd_get_game_sound(GameSounds::TARGET_FAIL)); + } + return; + } + } + + // check if entry is already on complete escort list bool found = false; for (auto &es : Escort_ships) { - if (es.obj_signature == Objects[objnum].signature) { + if (es.obj_signature == obj.signature) { found = true; break; } } - // add new ship into complete list + // add new entry into complete list if ( !found ) { escort_info einfo; einfo.objnum = objnum; - einfo.obj_signature = Objects[objnum].signature; - einfo.priority = Ships[Objects[objnum].instance].escort_priority; + einfo.obj_signature = obj.signature; + einfo.priority = is_ship ? Ships[obj.instance].escort_priority : cp_ptr->escort_priority; - // add him to escort list and re-sort Escort_ships.push_back(einfo); Escort_ships.sort(escort_compare); - Ships[Objects[objnum].instance].flags.set(Ship::Ship_Flags::Escort); + if (is_ship) { + Ships[obj.instance].flags.set(Ship::Ship_Flags::Escort); + } } if (hud_escort_num_ships_on_list() > MAX_COMPLETE_ESCORT_LIST) { @@ -891,7 +974,8 @@ void hud_add_remove_ship_escort(int objnum, int supress_feedback) return; } - if ( Objects[objnum].type != OBJ_SHIP ) { + const auto otype = Objects[objnum].type; + if (otype != OBJ_SHIP && otype != OBJ_COORDINATE_POINT) { if ( !supress_feedback ) { snd_play( gamesnd_get_game_sound(GameSounds::TARGET_FAIL)); } @@ -994,7 +1078,11 @@ void hud_escort_target_next() objnum = eship->objnum; set_target_objnum( Player_ai, objnum); - hud_restore_subsystem_target(&Ships[Objects[objnum].instance]); + + // hud_restore_subsystem_target is ship-only; skip for non-ship entries (coordinate points etc.). + if (Objects[objnum].type == OBJ_SHIP && Objects[objnum].instance >= 0) { + hud_restore_subsystem_target(&Ships[Objects[objnum].instance]); + } } // return the number of ships currently on the escort list diff --git a/code/hud/hudtargetbox.cpp b/code/hud/hudtargetbox.cpp index fb1237cecf1..4a227ecbd88 100644 --- a/code/hud/hudtargetbox.cpp +++ b/code/hud/hudtargetbox.cpp @@ -19,6 +19,7 @@ #include "hud/hudtargetbox.h" #include "iff_defs/iff_defs.h" #include "io/timer.h" +#include "coordinate_points/coordinate_point.h" #include "jumpnode/jumpnode.h" #include "localization/localize.h" #include "mission/missionparse.h" @@ -451,6 +452,10 @@ void HudGaugeTargetBox::render(float frametime, bool config) renderTargetJumpNode(target_objp); break; + case OBJ_COORDINATE_POINT: + renderTargetCoordinatePoint(target_objp); + break; + default: hud_cease_targeting(); break; @@ -1385,6 +1390,39 @@ void HudGaugeTargetBox::renderTargetJumpNode(object *target_objp) } } +void HudGaugeTargetBox::renderTargetCoordinatePoint(object *target_objp) +{ + auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(target_objp)); + if (cp == nullptr) { + set_target_objnum(Player_ai, -1); + return; + } + + // Coordinate points have no model and no integrity; just draw the frame, name, category, distance. + renderTargetForeground(); + renderTargetIntegrity(1); + setGaugeColor(); + + renderString(position[0] + Name_offsets[0], position[1] + Name_offsets[1], EG_TBOX_NAME, cp->name.c_str()); + + if (!cp->category.empty()) { + renderString(position[0] + Class_offsets[0], position[1] + Class_offsets[1], EG_TBOX_CLASS, cp->category.c_str()); + } + + float dist = Player_ai->current_target_distance; + if (Hud_unit_multiplier > 0.0f) { + dist = dist * Hud_unit_multiplier; + } + + char outstr[256]; + sprintf(outstr, XSTR("d: %.0f", 340), dist); + hud_num_make_mono(outstr, font_num); + + int w, h; + gr_get_string_size(&w, &h, outstr); + renderPrintfWithGauge(position[0] + Dist_offsets[0], position[1] + Dist_offsets[1], EG_TBOX_DIST, 1.0f, false, "%s", outstr); +} + /** * Toggle through the valid targetbox modes * @@ -2124,6 +2162,7 @@ void HudGaugeTargetBox::showTargetData(float /*frametime*/, bool config) break; case OBJ_JUMP_NODE: + case OBJ_COORDINATE_POINT: return; default: diff --git a/code/hud/hudtargetbox.h b/code/hud/hudtargetbox.h index 323942a9570..cecbfe024ba 100644 --- a/code/hud/hudtargetbox.h +++ b/code/hud/hudtargetbox.h @@ -128,6 +128,7 @@ class HudGaugeTargetBox: public HudGauge // HUD_TARGET_MONITOR void renderTargetDebris(object *target_objp); void renderTargetAsteroid(object *target_objp); void renderTargetJumpNode(object *target_objp); + void renderTargetCoordinatePoint(object *target_objp); void renderTargetSetup(vec3d *camera_eye, matrix *camera_orient, fov_t zoom); void renderTargetClose(); void renderTargetForeground(bool config = false); diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index b5090b26fd0..c376f44c11d 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -66,6 +66,7 @@ #include "parse/parselo.h" #include "parse/sexp_container.h" #include "prop/prop.h" +#include "coordinate_points/coordinate_point.h" #include "scripting/global_hooks.h" #include "scripting/hook_api.h" #include "scripting/hook_conditions.h" @@ -5435,12 +5436,69 @@ void parse_wings(mission* pm) void parse_props(mission* pm) { if (optional_string("#Props")) { - while (required_string_either("#Events", "$Name:")) { + while (true) { + int which = required_string_one_of(3, "#Coordinate Points", "#Events", "$Name:"); + if (which == -1 || which == 0 || which == 1) { // #Coordinate Points or #Events + break; + } parse_prop(pm); } } } +void parse_coordinate_point(mission* /*pm*/) +{ + parsed_coordinate_point cp; + + required_string("$Name:"); + stuff_string(cp.name, F_NAME); + + required_string("$Location:"); + stuff_vec3d(&cp.position); + + if (optional_string("+Category:")) { + stuff_string(cp.category, F_NAME); + } + + if (optional_string("+Color:")) { + int rgb[3] = { 255, 255, 255 }; + stuff_int_list(rgb, 3, ParseLookupType::RAW_INTEGER_TYPE); + for (int& c : rgb) { + CLAMP(c, 0, 255); + } + gr_init_alphacolor(&cp.display_color, rgb[0], rgb[1], rgb[2], 255); + } + + if (optional_string("+Shape:")) { + SCP_string shape_name; + stuff_string(shape_name, F_NAME); + cp.shape = coordinate_point_shape_from_string(shape_name.c_str()); + } + + if (optional_string("+Size:")) { + stuff_float(&cp.size_scale); + CLAMP(cp.size_scale, COORDINATE_POINT_SIZE_MIN, COORDINATE_POINT_SIZE_MAX); + } + + if (optional_string("+Escort Priority:")) { + stuff_int(&cp.escort_priority); + if (cp.escort_priority < 0) { + cp.escort_priority = 0; + } + } + + Parse_coordinate_points.emplace_back(std::move(cp)); +} + +void parse_coordinate_points(mission* pm) +{ + if (optional_string("#Coordinate Points")) { + while (required_string_either("#Events", "$Name:")) { + parse_coordinate_point(pm); + } + } +} + // Goober5000 void resolve_and_check_anchor(bool check_for_hangar, SCP_set &anchors_checked, anchor_t &anchor, const char *other_name, bool other_is_ship, bool is_arrival) { @@ -6921,6 +6979,7 @@ bool parse_mission(mission *pm, int flags) parse_objects(pm, flags); parse_wings(pm); parse_props(pm); + parse_coordinate_points(pm); parse_events(pm); parse_goals(pm); parse_waypoints_and_jumpnodes(pm); @@ -7011,6 +7070,7 @@ bool post_process_mission(mission *pm) ship_obj *so; post_process_mission_props(); + post_process_mission_coordinate_points(); // Goober5000 - this must be done even before post_process_ships_wings because it is a prerequisite ship_clear_ship_type_counts(); @@ -7482,6 +7542,7 @@ void mission_init(mission *pm, bool quick_init) jumpnode_level_close(); waypoint_level_close(); props_level_close(); + coordinate_points_level_close(); red_alert_invalidate_timestamp(); event_music_reset_choices(); @@ -7514,6 +7575,7 @@ void mission_init(mission *pm, bool quick_init) Reinforcements.clear(); Parse_props.clear(); + Parse_coordinate_points.clear(); Asteroid_field.num_initial_asteroids = 0; diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index 7941def3103..31aa796b04b 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -43,6 +43,7 @@ #include "parse/sexp.h" #include "parse/sexp_container.h" #include "prop/prop.h" +#include "coordinate_points/coordinate_point.h" #include "sound/ds.h" #include "sound/sound.h" #include "starfield/nebula.h" @@ -3232,6 +3233,8 @@ void Fred_mission_save::save_mission_internal(const char* pathname) err = -8; } else if (save_props()) { err = -18; + } else if (save_coordinate_points()) { + err = -19; } else if (save_events()) { err = -9; } else if (save_goals()) { @@ -5427,3 +5430,89 @@ int Fred_mission_save::save_props() fso_comment_pop(true); return err; } + +int Fred_mission_save::save_coordinate_points() +{ + if (save_config.save_format == MissionFormat::RETAIL || Coordinate_points.empty()) { + return err; + } + + fred_parse_flag = 0; + size_t count = 0; + + if (optional_string_fred("#Coordinate Points", "#End")) { + parse_comments(2); + } else { + fout("\n\n#Coordinate Points"); + } + fout("\t\t;! " SIZE_T_ARG " total\n", Coordinate_points.size()); + + for (const auto& cp : Coordinate_points) { + if (cp.objnum < 0) { + continue; + } + + required_string_either_fred("$Name:", "#Events"); + required_string_fred("$Name:"); + parse_comments(count ? 2 : 1); + fout(" %s", cp.name.c_str()); + + count++; + + required_string_fred("\n$Location:"); + parse_comments(0); + save_vector(Objects[cp.objnum].pos); + + if (!cp.category.empty()) { + if (optional_string_fred("+Category:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Category:"); + } + fout(" %s", cp.category.c_str()); + } + + const bool color_is_default = + (cp.display_color.red == 255 && cp.display_color.green == 255 && cp.display_color.blue == 255); + if (!color_is_default) { + if (optional_string_fred("+Color:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Color:"); + } + fout(" ( %d %d %d )", cp.display_color.red, cp.display_color.green, cp.display_color.blue); + } + + if (cp.shape != CoordinatePointShape::Triangle) { + if (optional_string_fred("+Shape:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Shape:"); + } + fout(" %s", coordinate_point_shape_to_string(cp.shape)); + } + + if (cp.size_scale != 1.0f) { + if (optional_string_fred("+Size:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Size:"); + } + fout(" %f", cp.size_scale); + } + + if (cp.escort_priority > 0) { + if (optional_string_fred("+Escort Priority:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Escort Priority:"); + } + fout(" %d", cp.escort_priority); + } + + fso_comment_pop(); + } + + fso_comment_pop(true); + return err; +} diff --git a/code/missioneditor/missionsave.h b/code/missioneditor/missionsave.h index 7d8f1558344..b641d9cecb2 100644 --- a/code/missioneditor/missionsave.h +++ b/code/missioneditor/missionsave.h @@ -566,6 +566,8 @@ class Fred_mission_save { */ int save_props(); + int save_coordinate_points(); + /** * @brief Utility function to save a raw comment, the start of which precedes the current raw_ptr, to a file while * handling newlines properly diff --git a/code/object/object.cpp b/code/object/object.cpp index 06bbf7cb240..42c8969e57f 100644 --- a/code/object/object.cpp +++ b/code/object/object.cpp @@ -142,7 +142,9 @@ const char *Object_type_names[MAX_OBJECT_TYPES] = { "Asteroid", "Jump Node", "Beam", - "Raw Pof" + "Raw Pof", + "Prop", + "Coordinate Point" //XSTR:ON }; @@ -284,6 +286,7 @@ int free_object_slots(int target_num_used) case OBJ_BEAM: case OBJ_RAW_POF: case OBJ_PROP: + case OBJ_COORDINATE_POINT: break; default: Int3(); // Hey, what kind of object is this? Unknown! @@ -768,6 +771,8 @@ void obj_delete(int objnum) case OBJ_PROP: prop_delete(objp); break; + case OBJ_COORDINATE_POINT: + break; // requires no further action, handled by coordinate_point_delete / level close. case OBJ_NONE: Int3(); break; @@ -1279,6 +1284,8 @@ void obj_move_all_pre(object *objp, float frametime) break; case OBJ_PROP: break; + case OBJ_COORDINATE_POINT: + break; // coordinate points don't move. case OBJ_NONE: Int3(); break; @@ -1544,6 +1551,9 @@ void obj_move_all_post(object *objp, float frametime) case OBJ_PROP: break; + case OBJ_COORDINATE_POINT: + break; // coordinate points don't move. + case OBJ_NONE: Int3(); break; @@ -1965,6 +1975,9 @@ void obj_queue_render(object* obj, model_draw_list* scene) case OBJ_PROP: prop_render(obj, scene); break; + case OBJ_COORDINATE_POINT: + // Coordinate Points never render in-game; the editor draws them via its own pass. + break; default: Error( LOCATION, "Unhandled obj type %d in obj_render", obj->type ); } @@ -2068,10 +2081,11 @@ int obj_team(object *objp) case OBJ_START: case OBJ_NONE: case OBJ_GHOST: - case OBJ_SHOCKWAVE: + case OBJ_SHOCKWAVE: case OBJ_BEAM: case OBJ_RAW_POF: case OBJ_PROP: + case OBJ_COORDINATE_POINT: team = -1; break; diff --git a/code/object/object.h b/code/object/object.h index 95e6e77995b..2125537e876 100644 --- a/code/object/object.h +++ b/code/object/object.h @@ -52,9 +52,10 @@ #define OBJ_BEAM 15 // beam weapons. we have to roll them into the object system to get the benefits of the collision pairs #define OBJ_RAW_POF 16 // A raw pof file. has no physics, ai or anything. Currently only used in the Lab to render tech models #define OBJ_PROP 17 // A prop object like a landscape. Similar to ships but is entirely non-interactable with the exception of collisions +#define OBJ_COORDINATE_POINT 18 // A named point in space; editor-only render. Referenced by SEXPs to read its coordinates. //Make sure to change Object_type_names in Object.c when adding another type! -#define MAX_OBJECT_TYPES 17 +#define MAX_OBJECT_TYPES 19 #define UNUSED_OBJNUM (-MAX_OBJECTS*2) // Newer systems use this instead of -1 for invalid object. diff --git a/code/parse/sexp.h b/code/parse/sexp.h index 68299082412..0007d57a057 100644 --- a/code/parse/sexp.h +++ b/code/parse/sexp.h @@ -1622,7 +1622,8 @@ enum class oswpt_type : uint8_t WHOLE_TEAM, // e.g. Friendly PARSE_OBJECT, // a "ship" that hasn't arrived yet EXITED, - WING_NOT_PRESENT // a wing that hasn't arrived yet or is between waves + WING_NOT_PRESENT, // a wing that hasn't arrived yet or is between waves + COORDINATE_POINT // a named point in space defined per-mission }; namespace scripting { diff --git a/code/scripting/ade.cpp b/code/scripting/ade.cpp index 44bff2bb35a..c641f3a5dff 100644 --- a/code/scripting/ade.cpp +++ b/code/scripting/ade.cpp @@ -22,6 +22,7 @@ #include "scripting/api/objs/debris.h" #include "scripting/api/objs/object.h" #include "scripting/api/objs/ship.h" +#include "scripting/api/objs/coordinatepoint.h" #include "scripting/api/objs/waypoint.h" #include "scripting/api/objs/weapon.h" #include "scripting/lua/LuaFunction.h" @@ -997,6 +998,8 @@ ade_odata_setter ade_object_to_odata(int obj_idx) return l_Weapon.Set(object_h(objp)); case OBJ_BEAM: return l_Beam.Set(object_h(objp)); + case OBJ_COORDINATE_POINT: + return l_CoordinatePoint.Set(object_h(objp)); default: return l_Object.Set(object_h(objp)); } diff --git a/code/scripting/api/libs/mission.cpp b/code/scripting/api/libs/mission.cpp index 14df3af0562..69897afd6dd 100644 --- a/code/scripting/api/libs/mission.cpp +++ b/code/scripting/api/libs/mission.cpp @@ -37,6 +37,7 @@ #include "parse/sexp/sexp_lookup.h" #include "playerman/player.h" #include "prop/prop.h" +#include "coordinate_points/coordinate_point.h" #include "scripting/api/LuaPromise.h" #include "scripting/api/objs/LuaEnum.h" #include "scripting/api/objs/LuaSEXP.h" @@ -65,6 +66,7 @@ #include "scripting/api/objs/ship.h" #include "scripting/api/objs/shipclass.h" #include "scripting/api/objs/sound.h" +#include "scripting/api/objs/coordinatepoint.h" #include "scripting/api/objs/support_rearm_pool.h" #include "scripting/api/objs/team.h" #include "scripting/api/objs/vecmath.h" @@ -665,6 +667,51 @@ ADE_FUNC(__len, l_Mission_Props, nullptr, return ade_set_args(L, "i", object_subclass_count(Props)); } +//****SUBLIBRARY: Mission/CoordinatePoints +ADE_LIB_DERIV(l_Mission_CoordinatePoints, "CoordinatePoints", nullptr, "Coordinate points in the mission", l_Mission); + +ADE_INDEXER(l_Mission_CoordinatePoints, "number/string IndexOrName", "Gets coordinate point by 1-based index or by name", "coordinatepoint", "Coordinate point handle, or invalid handle if the index or name was not found") +{ + const char* name; + if (!ade_get_args(L, "*s", &name)) + return ade_set_error(L, "o", l_CoordinatePoint.Set(object_h())); + + // Try name lookup first + auto* cp = find_coordinate_point_by_name(name); + if (cp != nullptr && cp->objnum >= 0) { + return ade_set_args(L, "o", l_CoordinatePoint.Set(object_h(cp->objnum))); + } + + // Fall back to numeric index (1-based, Lua style) + char* end_ptr = nullptr; + long idx = strtol(name, &end_ptr, 10); + if (end_ptr != name && idx >= 1) { + long count = 0; + for (auto& entry : Coordinate_points) { + if (entry.objnum < 0) + continue; + if (++count == idx) { + return ade_set_args(L, "o", l_CoordinatePoint.Set(object_h(entry.objnum))); + } + } + } + + return ade_set_error(L, "o", l_CoordinatePoint.Set(object_h())); +} + +ADE_FUNC(__len, l_Mission_CoordinatePoints, nullptr, + "Number of coordinate points in the mission.", + "number", + "Number of coordinate points in the mission") +{ + int count = 0; + for (const auto& cp : Coordinate_points) { + if (cp.objnum >= 0) + ++count; + } + return ade_set_args(L, "i", count); +} + //****SUBLIBRARY: Mission/Waypoints ADE_LIB_DERIV(l_Mission_Waypoints, "Waypoints", NULL, NULL, l_Mission); diff --git a/code/scripting/api/objs/coordinatepoint.cpp b/code/scripting/api/objs/coordinatepoint.cpp new file mode 100644 index 00000000000..957551a8c92 --- /dev/null +++ b/code/scripting/api/objs/coordinatepoint.cpp @@ -0,0 +1,129 @@ +// +// + +#include "coordinatepoint.h" +#include "object.h" + +#include "coordinate_points/coordinate_point.h" +#include "hud/hudescort.h" + +namespace scripting::api { + +//**********HANDLE: CoordinatePoint +ADE_OBJ_DERIV(l_CoordinatePoint, object_h, "coordinatepoint", "Coordinate point handle", l_Object); + +ADE_VIRTVAR(Name, + l_CoordinatePoint, + "string", + "Coordinate point name. Used to reference the point from SEXPs.", + "string", + "Coordinate point name, or empty string if handle is invalid") +{ + object_h* objh; + const char* s = nullptr; + if (!ade_get_args(L, "o|s", l_CoordinatePoint.GetPtr(&objh), &s)) + return ade_set_error(L, "s", ""); + + if (!objh->isValid() || objh->objp()->type != OBJ_COORDINATE_POINT) + return ade_set_error(L, "s", ""); + + auto* cp = find_coordinate_point_by_objnum(objh->objnum); + if (cp == nullptr) + return ade_set_error(L, "s", ""); + + if (ADE_SETTING_VAR && s != nullptr) { + cp->name = s; + } + + return ade_set_args(L, "s", cp->name.c_str()); +} + +ADE_VIRTVAR(Category, + l_CoordinatePoint, + "string", + "Designer-defined category string (may be empty).", + "string", + "Coordinate point category, or empty string if handle is invalid") +{ + object_h* objh; + const char* s = nullptr; + if (!ade_get_args(L, "o|s", l_CoordinatePoint.GetPtr(&objh), &s)) + return ade_set_error(L, "s", ""); + + if (!objh->isValid() || objh->objp()->type != OBJ_COORDINATE_POINT) + return ade_set_error(L, "s", ""); + + auto* cp = find_coordinate_point_by_objnum(objh->objnum); + if (cp == nullptr) + return ade_set_error(L, "s", ""); + + if (ADE_SETTING_VAR && s != nullptr) { + cp->category = s; + } + + return ade_set_args(L, "s", cp->category.c_str()); +} + +ADE_VIRTVAR(EscortPriority, + l_CoordinatePoint, + "number", + "Escort-list priority. 0 (default) means the point is not on the escort list. Any value >0 means the point is on the list, with this number used as the sort key (higher = higher on the list).", + "number", + "Escort priority, or 0 if handle is invalid") +{ + object_h* objh; + int val = 0; + if (!ade_get_args(L, "o|i", l_CoordinatePoint.GetPtr(&objh), &val)) + return ade_set_error(L, "i", 0); + + if (!objh->isValid() || objh->objp()->type != OBJ_COORDINATE_POINT) + return ade_set_error(L, "i", 0); + + auto* cp = find_coordinate_point_by_objnum(objh->objnum); + if (cp == nullptr) + return ade_set_error(L, "i", 0); + + if (ADE_SETTING_VAR) { + cp->escort_priority = (val < 0) ? 0 : val; + } + + return ade_set_args(L, "i", cp->escort_priority); +} + +ADE_FUNC(addToEscortList, l_CoordinatePoint, nullptr, + "Adds this coordinate point to the player's escort list. Requires EscortPriority > 0; otherwise a no-op.", + "boolean", + "true if the point was added (or already on the list), false if EscortPriority is 0 / handle invalid") +{ + object_h* objh; + if (!ade_get_args(L, "o", l_CoordinatePoint.GetPtr(&objh))) + return ADE_RETURN_FALSE; + + if (!objh->isValid() || objh->objp()->type != OBJ_COORDINATE_POINT) + return ADE_RETURN_FALSE; + + auto* cp = find_coordinate_point_by_objnum(objh->objnum); + if (cp == nullptr || cp->escort_priority <= 0) + return ADE_RETURN_FALSE; + + hud_add_ship_to_escort(objh->objnum, 1); + return ADE_RETURN_TRUE; +} + +ADE_FUNC(removeFromEscortList, l_CoordinatePoint, nullptr, + "Removes this coordinate point from the player's escort list (no-op if it isn't on it).", + "boolean", + "true if a removal was issued, false if handle invalid") +{ + object_h* objh; + if (!ade_get_args(L, "o", l_CoordinatePoint.GetPtr(&objh))) + return ADE_RETURN_FALSE; + + if (!objh->isValid() || objh->objp()->type != OBJ_COORDINATE_POINT) + return ADE_RETURN_FALSE; + + hud_remove_ship_from_escort(objh->objnum); + return ADE_RETURN_TRUE; +} + +} // namespace scripting::api diff --git a/code/scripting/api/objs/coordinatepoint.h b/code/scripting/api/objs/coordinatepoint.h new file mode 100644 index 00000000000..1fe801aa9c9 --- /dev/null +++ b/code/scripting/api/objs/coordinatepoint.h @@ -0,0 +1,13 @@ +#pragma once + +#include "scripting/ade_api.h" + +#include "object/object.h" + +namespace scripting { +namespace api { + +DECLARE_ADE_OBJ(l_CoordinatePoint, object_h); + +} +} diff --git a/code/scripting/api/objs/object.cpp b/code/scripting/api/objs/object.cpp index 6e2634bf376..dc083be3146 100644 --- a/code/scripting/api/objs/object.cpp +++ b/code/scripting/api/objs/object.cpp @@ -19,6 +19,7 @@ #include "object/objectshield.h" #include "object/objectsnd.h" #include "prop/prop.h" +#include "coordinate_points/coordinate_point.h" #include "scripting/api/LuaEventCallback.h" #include "scripting/api/objs/color.h" #include "scripting/lua/LuaFunction.h" @@ -109,6 +110,15 @@ ADE_FUNC(__tostring, l_Object, NULL, "Returns name of object (if any)", "string" case OBJ_PROP: sprintf(buf, "%s prop", Props[Objects[objh->objnum].instance]->prop_name); break; + case OBJ_COORDINATE_POINT: { + auto* cp = find_coordinate_point_by_objnum(objh->objnum); + if (cp != nullptr) { + sprintf(buf, "%s coordinate point", cp->name.c_str()); + } else { + sprintf(buf, "coordinate point"); + } + break; + } default: sprintf(buf, "object num=%d sig=%d type=%d instance=%d", objh->objnum, objh->sig, objh->objp()->type, objh->objp()->instance); break; diff --git a/code/scripting/api/objs/oswpt.cpp b/code/scripting/api/objs/oswpt.cpp index 3981a0308fe..2d29c93b195 100644 --- a/code/scripting/api/objs/oswpt.cpp +++ b/code/scripting/api/objs/oswpt.cpp @@ -1,9 +1,11 @@ // // +#include "object.h" #include "oswpt.h" #include "parse_object.h" #include "ship.h" +#include "coordinatepoint.h" #include "team.h" #include "waypoint.h" #include "wing.h" @@ -22,6 +24,7 @@ void scripting::internal::ade_serializable_external switch (oswpt.type) { case oswpt_type::SHIP: case oswpt_type::WAYPOINT: + case oswpt_type::COORDINATE_POINT: ADD_USHORT(oswpt.objp()->net_signature); break; case oswpt_type::PARSE_OBJECT: @@ -48,7 +51,8 @@ void scripting::internal::ade_serializable_external GET_DATA(oswpttype); switch (static_cast(oswpttype)) { case oswpt_type::SHIP: - case oswpt_type::WAYPOINT: { + case oswpt_type::WAYPOINT: + case oswpt_type::COORDINATE_POINT: { ushort net_signature; GET_USHORT(net_signature); object_ship_wing_point_team oswpt; @@ -108,6 +112,7 @@ void scripting::internal::ade_serializable_external #define OSWPT_TYPE_PARSE_OBJECT oswpt_type::PARSE_OBJECT #define OSWPT_TYPE_EXITED oswpt_type::EXITED #define OSWPT_TYPE_WING_NOT_PRESENT oswpt_type::WING_NOT_PRESENT +#define OSWPT_TYPE_COORDINATE_POINT oswpt_type::COORDINATE_POINT namespace scripting { @@ -116,7 +121,7 @@ namespace api { //**********HANDLE: Wing ADE_OBJ(l_OSWPT, object_ship_wing_point_team, "oswpt", "Handle for LuaSEXP arguments that can hold different types (Object/Ship/Wing/Waypoint/Team)"); -ADE_FUNC(getType, l_OSWPT, nullptr, "The data-type this OSWPT yields on the get method.", "string", "The name of the data type. Either 'ship', 'parseobject' (a yet-to-spawn ship), 'wing' (can include yet-to-arrive wings with 0 current ships), 'team' (both explicit and ship-on-team), 'waypoint', or 'none' (either explicitly specified, a ship that doesn't exist anymore, or invalid OSWPT object).") +ADE_FUNC(getType, l_OSWPT, nullptr, "The data-type this OSWPT yields on the get method.", "string", "The name of the data type. Either 'ship', 'parseobject' (a yet-to-spawn ship), 'wing' (can include yet-to-arrive wings with 0 current ships), 'team' (both explicit and ship-on-team), 'waypoint', 'coordinatepoint', or 'none' (either explicitly specified, a ship that doesn't exist anymore, or invalid OSWPT object).") { object_ship_wing_point_team oswpt; if (!ade_get_args(L, "o", l_OSWPT.Get(&oswpt))) @@ -135,6 +140,8 @@ ADE_FUNC(getType, l_OSWPT, nullptr, "The data-type this OSWPT yields on the get return ade_set_args(L, "s", "team"); case OSWPT_TYPE_WAYPOINT: return ade_set_args(L, "s", "waypoint"); + case OSWPT_TYPE_COORDINATE_POINT: + return ade_set_args(L, "s", "coordinatepoint"); case OSWPT_TYPE_NONE: case OSWPT_TYPE_EXITED: default: @@ -142,7 +149,7 @@ ADE_FUNC(getType, l_OSWPT, nullptr, "The data-type this OSWPT yields on the get } } -ADE_FUNC(get, l_OSWPT, nullptr, "Returns the data held by this OSWPT.", "ship | parse_object | wing | team | waypoint | nil", "Returns the data held by this OSWPT, nil if type is 'none'.") +ADE_FUNC(get, l_OSWPT, nullptr, "Returns the data held by this OSWPT.", "ship | parse_object | wing | team | waypoint | coordinatepoint | nil", "Returns the data held by this OSWPT, nil if type is 'none'.") { object_ship_wing_point_team oswpt; if (!ade_get_args(L, "o", l_OSWPT.Get(&oswpt))) @@ -161,6 +168,8 @@ ADE_FUNC(get, l_OSWPT, nullptr, "Returns the data held by this OSWPT.", "ship | return ade_set_args(L, "o", l_Team.Set(oswpt.team)); case OSWPT_TYPE_WAYPOINT: return ade_set_args(L, "o", l_Waypoint.Set(object_h(oswpt.objnum))); + case OSWPT_TYPE_COORDINATE_POINT: + return ade_set_args(L, "o", l_CoordinatePoint.Set(object_h(oswpt.objnum))); case OSWPT_TYPE_NONE: case OSWPT_TYPE_EXITED: default: @@ -215,12 +224,13 @@ ADE_FUNC(forAllShips, l_OSWPT, "function(ship ship) => void body", "Applies this case OSWPT_TYPE_PARSE_OBJECT: case OSWPT_TYPE_WING_NOT_PRESENT: case OSWPT_TYPE_WAYPOINT: + case OSWPT_TYPE_COORDINATE_POINT: case OSWPT_TYPE_NONE: case OSWPT_TYPE_EXITED: default: break; } - + return ADE_RETURN_NIL; } @@ -273,6 +283,7 @@ ADE_FUNC(forAllParseObjects, l_OSWPT, "function(parse_object po) => void body", case OSWPT_TYPE_SHIP: case OSWPT_TYPE_WING: case OSWPT_TYPE_WAYPOINT: + case OSWPT_TYPE_COORDINATE_POINT: case OSWPT_TYPE_NONE: case OSWPT_TYPE_EXITED: default: diff --git a/code/source_groups.cmake b/code/source_groups.cmake index 84701cd107d..8e05a0a9157 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -1243,6 +1243,12 @@ add_file_folder("Prop" prop/prop_flags.h ) +# Coordinate Points files +add_file_folder("Coordinate Points" + coordinate_points/coordinate_point.cpp + coordinate_points/coordinate_point.h +) + # Radar files add_file_folder("Radar" radar/radar.cpp @@ -1535,6 +1541,8 @@ add_file_folder("Scripting\\\\Api\\\\Objs" scripting/api/objs/shipwepselect.h scripting/api/objs/sound.cpp scripting/api/objs/sound.h + scripting/api/objs/coordinatepoint.cpp + scripting/api/objs/coordinatepoint.h scripting/api/objs/species.cpp scripting/api/objs/species.h scripting/api/objs/support_rearm_pool.cpp diff --git a/qtfred/src/mission/Editor.cpp b/qtfred/src/mission/Editor.cpp index 6b364408642..8eac2bc69cf 100644 --- a/qtfred/src/mission/Editor.cpp +++ b/qtfred/src/mission/Editor.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -877,6 +878,14 @@ int Editor::create_waypoint(vec3d* pos, int waypoint_instance) { return obj; } + +int Editor::create_coordinate_point(vec3d* pos) { + int obj = coordinate_point_create(pos); + + missionChanged(); + + return obj; +} int Editor::getNumMarked() { return numMarked; } diff --git a/qtfred/src/mission/Editor.h b/qtfred/src/mission/Editor.h index 7241028435d..d4a222e521d 100644 --- a/qtfred/src/mission/Editor.h +++ b/qtfred/src/mission/Editor.h @@ -93,6 +93,8 @@ class Editor : public QObject { int create_waypoint(vec3d* pos, int waypoint_instance); + int create_coordinate_point(vec3d* pos); + bool query_ship_name_duplicate(int ship); void fix_ship_name(int ship); diff --git a/qtfred/src/mission/EditorViewport.cpp b/qtfred/src/mission/EditorViewport.cpp index cee5ef70045..946e31b1691 100644 --- a/qtfred/src/mission/EditorViewport.cpp +++ b/qtfred/src/mission/EditorViewport.cpp @@ -1224,6 +1224,9 @@ int EditorViewport::create_object(vec3d* pos, int waypoint_instance, CreateKind Jump_nodes.push_back(std::move(jnp)); break; } + case OtherKind::CoordinatePoint: + obj = editor->create_coordinate_point(pos); + break; default: obj = -1; break; diff --git a/qtfred/src/mission/EditorViewport.h b/qtfred/src/mission/EditorViewport.h index 91dca1b071a..91904a0ec26 100644 --- a/qtfred/src/mission/EditorViewport.h +++ b/qtfred/src/mission/EditorViewport.h @@ -27,6 +27,7 @@ enum class CreateKind { enum class OtherKind { Waypoint, JumpNode, + CoordinatePoint, }; struct ViewSettings { diff --git a/qtfred/src/ui/widgets/ObjectComboBox.cpp b/qtfred/src/ui/widgets/ObjectComboBox.cpp index 7c076978efa..fdab7467528 100644 --- a/qtfred/src/ui/widgets/ObjectComboBox.cpp +++ b/qtfred/src/ui/widgets/ObjectComboBox.cpp @@ -69,6 +69,10 @@ void ObjectComboBox::buildOtherModel() { jumpNode->setData(static_cast(OtherKind::JumpNode), Qt::UserRole); model->appendRow(jumpNode); + auto coordinatePoint = new QStandardItem("Coordinate Point"); + coordinatePoint->setData(static_cast(OtherKind::CoordinatePoint), Qt::UserRole); + model->appendRow(coordinatePoint); + setModel(model); } From 89ed64416dee5480ae192781b0997efe120556ba Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Tue, 12 May 2026 19:33:14 -0500 Subject: [PATCH 07/19] flag set for coordinate points --- code/coordinate_points/coordinate_point.cpp | 5 ++++ code/coordinate_points/coordinate_point.h | 3 +++ .../coordinate_point_flags.h | 12 +++++++++ code/mission/missionparse.cpp | 21 +++++++++++++++ code/mission/missionparse.h | 5 ++++ code/missioneditor/missionsave.cpp | 16 ++++++++++++ code/scripting/api/objs/coordinatepoint.cpp | 26 +++++++++++++++++++ code/source_groups.cmake | 1 + 8 files changed, 89 insertions(+) create mode 100644 code/coordinate_points/coordinate_point_flags.h diff --git a/code/coordinate_points/coordinate_point.cpp b/code/coordinate_points/coordinate_point.cpp index 8da9935ae02..46cb4eb346d 100644 --- a/code/coordinate_points/coordinate_point.cpp +++ b/code/coordinate_points/coordinate_point.cpp @@ -91,6 +91,10 @@ int coordinate_point_create(const vec3d* pos, const char* name) flagset flags; flags.set(Object::Object_Flags::Renders); + // Coordinate points have no meaningful orientation; mark that explicitly so any future + // physics/rotation code shortcuts cleanly. Position is left mutable so SEXPs and Lua + // can relocate the point at runtime. + flags.set(Object::Object_Flags::Dont_change_orientation); int objnum = obj_create(OBJ_COORDINATE_POINT, -1, -1, nullptr, pos, COORDINATE_POINT_DEFAULT_RADIUS, flags); if (objnum < 0) { return -1; @@ -164,6 +168,7 @@ void post_process_mission_coordinate_points() cp->shape = parsed.shape; cp->size_scale = parsed.size_scale; cp->escort_priority = parsed.escort_priority; + cp->flags = parsed.flags; } Parse_coordinate_points.clear(); diff --git a/code/coordinate_points/coordinate_point.h b/code/coordinate_points/coordinate_point.h index 98011e025b4..4fc5ae7d0db 100644 --- a/code/coordinate_points/coordinate_point.h +++ b/code/coordinate_points/coordinate_point.h @@ -1,5 +1,6 @@ #pragma once +#include "coordinate_points/coordinate_point_flags.h" #include "globalincs/pstypes.h" #include "graphics/2d.h" @@ -29,6 +30,7 @@ struct mission_coordinate_point CoordinatePointShape shape = CoordinatePointShape::Triangle; float size_scale = 1.0f; int escort_priority = 0; // 0 = not on escort list; >0 = on the list (and sort key) + flagset flags; int objnum = -1; mission_coordinate_point(); @@ -45,6 +47,7 @@ struct parsed_coordinate_point CoordinatePointShape shape = CoordinatePointShape::Triangle; float size_scale = 1.0f; int escort_priority = 0; + flagset flags; parsed_coordinate_point(); }; diff --git a/code/coordinate_points/coordinate_point_flags.h b/code/coordinate_points/coordinate_point_flags.h new file mode 100644 index 00000000000..a49cdf22e23 --- /dev/null +++ b/code/coordinate_points/coordinate_point_flags.h @@ -0,0 +1,12 @@ +#pragma once + +#include "globalincs/flagset.h" + +namespace CoordinatePoint { + +FLAG_LIST(Flags){ + Visible_in_mission, // Render the shape in-game (not just in the editor) and allow target-in-front pickup. + + NUM_VALUES}; + +} // namespace CoordinatePoint diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index c376f44c11d..f4275e59b98 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -644,6 +644,17 @@ parse_object_flag_description Parse_prop_flag_descr const size_t Num_parse_prop_flags = sizeof(Parse_prop_flags) / sizeof(flag_def_list_new); const size_t Num_parse_prop_flag_descriptions = sizeof(Parse_prop_flag_descriptions) / sizeof(parse_object_flag_description); +flag_def_list_new Parse_coordinate_point_flags[] = { + { "visible_in_mission", CoordinatePoint::Flags::Visible_in_mission, true, false }, +}; + +parse_object_flag_description Parse_coordinate_point_flag_descriptions[] = { + { CoordinatePoint::Flags::Visible_in_mission, "Render the shape in-game so the player can see and target it, not just in the editor."}, +}; + +const size_t Num_parse_coordinate_point_flags = sizeof(Parse_coordinate_point_flags) / sizeof(flag_def_list_new); +const size_t Num_parse_coordinate_point_flag_descriptions = sizeof(Parse_coordinate_point_flag_descriptions) / sizeof(parse_object_flag_description); + // These are only the flags that are saved to the mission file. See the MEF_ #defines. flag_def_list Mission_event_flags[] = { { "interval & delay use msecs", MEF_USE_MSECS, 0 }, @@ -5487,6 +5498,16 @@ void parse_coordinate_point(mission* /*pm*/) } } + if (optional_string("+Flags:")) { + SCP_vector unparsed; + parse_string_flag_list(cp.flags, Parse_coordinate_point_flags, Num_parse_coordinate_point_flags, &unparsed); + if (!unparsed.empty()) { + for (const auto& f : unparsed) { + WarningEx(LOCATION, "Unknown flag in coordinate point flags: %s", f.c_str()); + } + } + } + Parse_coordinate_points.emplace_back(std::move(cp)); } diff --git a/code/mission/missionparse.h b/code/mission/missionparse.h index 8e917787108..f8b12dd7fa0 100644 --- a/code/mission/missionparse.h +++ b/code/mission/missionparse.h @@ -15,6 +15,7 @@ #include "ai/ai.h" #include "ai/ai_profiles.h" +#include "coordinate_points/coordinate_point_flags.h" #include "globalincs/version.h" #include "graphics/2d.h" #include "io/keycontrol.h" @@ -338,6 +339,10 @@ extern flag_def_list_new Parse_prop_flags[]; extern parse_object_flag_description Parse_prop_flag_descriptions[]; extern const size_t Num_parse_prop_flags; extern const size_t Num_parse_prop_flag_descriptions; +extern flag_def_list_new Parse_coordinate_point_flags[]; +extern parse_object_flag_description Parse_coordinate_point_flag_descriptions[]; +extern const size_t Num_parse_coordinate_point_flags; +extern const size_t Num_parse_coordinate_point_flag_descriptions; extern const char *Icon_names[]; extern const char *Mission_event_log_flags[]; diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index 31aa796b04b..3701e587534 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -5510,6 +5510,22 @@ int Fred_mission_save::save_coordinate_points() fout(" %d", cp.escort_priority); } + if (cp.flags.any_set()) { + if (optional_string_fred("+Flags:", "$Name:")) { + parse_comments(); + fout(" ("); + } else { + fout("\n+Flags: ("); + } + for (size_t fi = 0; fi < Num_parse_coordinate_point_flags; ++fi) { + const auto& def = Parse_coordinate_point_flags[fi]; + if (cp.flags[def.def]) { + fout(" \"%s\"", def.name); + } + } + fout(" )"); + } + fso_comment_pop(); } diff --git a/code/scripting/api/objs/coordinatepoint.cpp b/code/scripting/api/objs/coordinatepoint.cpp index 957551a8c92..9caf62a94f5 100644 --- a/code/scripting/api/objs/coordinatepoint.cpp +++ b/code/scripting/api/objs/coordinatepoint.cpp @@ -90,6 +90,32 @@ ADE_VIRTVAR(EscortPriority, return ade_set_args(L, "i", cp->escort_priority); } +ADE_VIRTVAR(VisibleInMission, + l_CoordinatePoint, + "boolean", + "Whether this coordinate point's shape renders in-game (not just in the editor). Allows the player to target it with the target-in-front reticle. Defaults to false.", + "boolean", + "true if visible in mission, false otherwise (also false if handle is invalid)") +{ + object_h* objh; + bool val = false; + if (!ade_get_args(L, "o|b", l_CoordinatePoint.GetPtr(&objh), &val)) + return ADE_RETURN_FALSE; + + if (!objh->isValid() || objh->objp()->type != OBJ_COORDINATE_POINT) + return ADE_RETURN_FALSE; + + auto* cp = find_coordinate_point_by_objnum(objh->objnum); + if (cp == nullptr) + return ADE_RETURN_FALSE; + + if (ADE_SETTING_VAR) { + cp->flags.set(CoordinatePoint::Flags::Visible_in_mission, val); + } + + return ade_set_args(L, "b", cp->flags[CoordinatePoint::Flags::Visible_in_mission]); +} + ADE_FUNC(addToEscortList, l_CoordinatePoint, nullptr, "Adds this coordinate point to the player's escort list. Requires EscortPriority > 0; otherwise a no-op.", "boolean", diff --git a/code/source_groups.cmake b/code/source_groups.cmake index 8e05a0a9157..ac266cd2e7b 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -1247,6 +1247,7 @@ add_file_folder("Prop" add_file_folder("Coordinate Points" coordinate_points/coordinate_point.cpp coordinate_points/coordinate_point.h + coordinate_points/coordinate_point_flags.h ) # Radar files From 9914bcaef7d9a10d0865304f3aa2bad7930cde1d Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Tue, 12 May 2026 21:36:04 -0500 Subject: [PATCH 08/19] editing menu and in mission features --- code/coordinate_points/coordinate_point.cpp | 21 +- code/coordinate_points/coordinate_point.h | 11 +- .../coordinate_point_render.cpp | 270 +++++++++ .../coordinate_point_render.h | 31 ++ code/hud/hudbrackets.cpp | 35 +- code/hud/hudescort.cpp | 10 +- code/hud/hudlock.cpp | 2 +- code/hud/hudtarget.cpp | 13 + code/mission/missionparse.cpp | 31 +- code/missioneditor/missionsave.cpp | 28 +- code/object/object.cpp | 17 +- code/scripting/api/objs/coordinatepoint.cpp | 30 + code/source_groups.cmake | 2 + freespace2/freespace.cpp | 4 + .../dialogs/CoordinatePointEditorDialog.html | 73 +++ qtfred/help-src/doc/index.html | 1 + qtfred/help-src/doc/qtfred.qhp | 3 + qtfred/help-src/qtfred.qhp | 3 + qtfred/source_groups.cmake | 5 + qtfred/src/mission/Editor.cpp | 17 + qtfred/src/mission/EditorViewport.cpp | 53 +- qtfred/src/mission/EditorViewport.h | 1 + qtfred/src/mission/FredRenderer.cpp | 31 ++ .../CoordinatePointEditorDialogModel.cpp | 517 ++++++++++++++++++ .../CoordinatePointEditorDialogModel.h | 119 ++++ .../dialogs/LayerManagerDialogModel.cpp | 8 + .../mission/dialogs/LayerManagerDialogModel.h | 2 + .../dialogs/ObjectOrientEditorDialogModel.cpp | 34 +- .../src/mission/dialogs/SceneBrowserModel.cpp | 17 + qtfred/src/mission/object.cpp | 8 + qtfred/src/ui/FredView.cpp | 16 +- qtfred/src/ui/FredView.h | 1 + .../dialogs/CoordinatePointEditorDialog.cpp | 245 +++++++++ .../ui/dialogs/CoordinatePointEditorDialog.h | 44 ++ qtfred/src/ui/dialogs/LayerManagerDialog.cpp | 3 + qtfred/src/ui/dialogs/LayerManagerDialog.h | 1 + qtfred/src/ui/util/ErrorChecker.cpp | 7 + qtfred/ui/CoordinatePointEditorDialog.ui | 262 +++++++++ qtfred/ui/FredView.ui | 6 + qtfred/ui/LayerManagerDialog.ui | 7 + 40 files changed, 1947 insertions(+), 42 deletions(-) create mode 100644 code/coordinate_points/coordinate_point_render.cpp create mode 100644 code/coordinate_points/coordinate_point_render.h create mode 100644 qtfred/help-src/doc/dialogs/CoordinatePointEditorDialog.html create mode 100644 qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.cpp create mode 100644 qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.h create mode 100644 qtfred/src/ui/dialogs/CoordinatePointEditorDialog.cpp create mode 100644 qtfred/src/ui/dialogs/CoordinatePointEditorDialog.h create mode 100644 qtfred/ui/CoordinatePointEditorDialog.ui diff --git a/code/coordinate_points/coordinate_point.cpp b/code/coordinate_points/coordinate_point.cpp index 46cb4eb346d..ca703dff6d2 100644 --- a/code/coordinate_points/coordinate_point.cpp +++ b/code/coordinate_points/coordinate_point.cpp @@ -16,6 +16,7 @@ static const char* Coordinate_point_shape_names[] = { "Pentagon", "Hexagon", "Cross", + "Star", }; static_assert(sizeof(Coordinate_point_shape_names) / sizeof(Coordinate_point_shape_names[0]) @@ -40,7 +41,7 @@ CoordinatePointShape coordinate_point_shape_from_string(const char* s) } } } - return CoordinatePointShape::Triangle; + return CoordinatePointShape::Diamond; } mission_coordinate_point::mission_coordinate_point() @@ -56,12 +57,22 @@ parsed_coordinate_point::parsed_coordinate_point() void coordinate_points_level_close() { - for (auto& cp : Coordinate_points) { - if (cp.objnum >= 0 && Objects[cp.objnum].type != OBJ_NONE) { - obj_delete(cp.objnum); + // Snapshot the objnums and clear the list first so the per-object obj_delete callback + // (which calls coordinate_point_delete) doesn't try to erase from the list we're walking. + SCP_vector objnums; + objnums.reserve(Coordinate_points.size()); + for (const auto& cp : Coordinate_points) { + if (cp.objnum >= 0) { + objnums.push_back(cp.objnum); } } Coordinate_points.clear(); + + for (int objnum : objnums) { + if (Objects[objnum].type != OBJ_NONE) { + obj_delete(objnum); + } + } } static bool coordinate_point_name_in_use(const char* name) @@ -168,7 +179,9 @@ void post_process_mission_coordinate_points() cp->shape = parsed.shape; cp->size_scale = parsed.size_scale; cp->escort_priority = parsed.escort_priority; + cp->multi_team = parsed.multi_team; cp->flags = parsed.flags; + cp->fred_layer = parsed.fred_layer; } Parse_coordinate_points.clear(); diff --git a/code/coordinate_points/coordinate_point.h b/code/coordinate_points/coordinate_point.h index 4fc5ae7d0db..dd474e83021 100644 --- a/code/coordinate_points/coordinate_point.h +++ b/code/coordinate_points/coordinate_point.h @@ -13,6 +13,7 @@ enum class CoordinatePointShape { Pentagon, Hexagon, Cross, + Star, NUM_SHAPES }; @@ -20,17 +21,19 @@ constexpr float COORDINATE_POINT_SIZE_MIN = 0.25f; constexpr float COORDINATE_POINT_SIZE_MAX = 8.0f; const char* coordinate_point_shape_to_string(CoordinatePointShape s); -CoordinatePointShape coordinate_point_shape_from_string(const char* s); // returns Triangle on miss +CoordinatePointShape coordinate_point_shape_from_string(const char* s); // returns Diamond on miss struct mission_coordinate_point { SCP_string name; SCP_string category; color display_color; - CoordinatePointShape shape = CoordinatePointShape::Triangle; + CoordinatePointShape shape = CoordinatePointShape::Diamond; float size_scale = 1.0f; int escort_priority = 0; // 0 = not on escort list; >0 = on the list (and sort key) + int multi_team = -1; // -1 = visible to all; otherwise TVT team index (0..MAX_TVT_TEAMS-1) flagset flags; + SCP_string fred_layer = "Default"; // FRED view layer assignment int objnum = -1; mission_coordinate_point(); @@ -44,10 +47,12 @@ struct parsed_coordinate_point SCP_string category; vec3d position; color display_color; - CoordinatePointShape shape = CoordinatePointShape::Triangle; + CoordinatePointShape shape = CoordinatePointShape::Diamond; float size_scale = 1.0f; int escort_priority = 0; + int multi_team = -1; flagset flags; + SCP_string fred_layer = "Default"; parsed_coordinate_point(); }; diff --git a/code/coordinate_points/coordinate_point_render.cpp b/code/coordinate_points/coordinate_point_render.cpp new file mode 100644 index 00000000000..7abe4c8978d --- /dev/null +++ b/code/coordinate_points/coordinate_point_render.cpp @@ -0,0 +1,270 @@ +#include "coordinate_points/coordinate_point_render.h" + +#include "coordinate_points/coordinate_point.h" +#include "graphics/material.h" +#include "math/floating.h" +#include "math/vecmat.h" +#include "network/multi.h" +#include "object/object.h" +#include "render/3d.h" + +namespace { + +// Unit-radius 2D vertex tables per shape, then a triangle-index table that triangulates each +// shape into a TRIS primitive list (each consecutive index triple is one triangle, CCW). + +struct vec2f { + float x, y; +}; + +// --- Vertex tables --- + +const vec2f Tri_verts[] = { + { 0.0f, 1.0f}, + { -0.866f, -0.5f}, + { 0.866f, -0.5f}, +}; + +const vec2f Sq_verts[] = { + { -1.0f, 1.0f}, + { 1.0f, 1.0f}, + { 1.0f, -1.0f}, + { -1.0f, -1.0f}, +}; + +const vec2f Dia_verts[] = { + { 0.0f, 1.0f}, + { 1.0f, 0.0f}, + { 0.0f, -1.0f}, + { -1.0f, 0.0f}, +}; + +const vec2f Pent_verts[] = { + { 0.0f, 1.0f }, + { 0.951f, 0.309f }, + { 0.588f, -0.809f }, + { -0.588f, -0.809f }, + { -0.951f, 0.309f }, +}; + +const vec2f Hex_verts[] = { + { -0.5f, 0.866f}, + { 0.5f, 0.866f}, + { 1.0f, 0.0f }, + { 0.5f, -0.866f}, + { -0.5f, -0.866f}, + { -1.0f, 0.0f }, +}; + +const vec2f Cross_verts[] = { + { -0.33f, 1.0f }, // 0: top-left of vertical bar + { 0.33f, 1.0f }, // 1: top-right of vertical bar + { 0.33f, 0.33f}, // 2 + { 1.0f, 0.33f}, // 3: top-right of horizontal bar + { 1.0f, -0.33f}, // 4: bottom-right of horizontal bar + { 0.33f, -0.33f}, // 5 + { 0.33f, -1.0f }, // 6: bottom-right of vertical bar + { -0.33f, -1.0f }, // 7: bottom-left of vertical bar + { -0.33f, -0.33f}, // 8 + { -1.0f, -0.33f}, // 9: bottom-left of horizontal bar + { -1.0f, 0.33f}, // 10: top-left of horizontal bar + { -0.33f, 0.33f}, // 11 +}; + +// 5-point star: alternating outer (radius 1) and inner (radius ≈ 0.382) points, starting at +// the top and going counterclockwise. Inner radius gives the classic 36° star arm angle. +const vec2f Star_verts[] = { + { 0.000f, 1.000f}, // 0: outer (top) + { -0.225f, 0.309f}, // 1: inner (top-left) + { -0.951f, 0.309f}, // 2: outer (top-left) + { -0.363f, -0.118f}, // 3: inner (lower-left) + { -0.588f, -0.809f}, // 4: outer (bottom-left) + { 0.000f, -0.382f}, // 5: inner (bottom) + { 0.588f, -0.809f}, // 6: outer (bottom-right) + { 0.363f, -0.118f}, // 7: inner (lower-right) + { 0.951f, 0.309f}, // 8: outer (top-right) + { 0.225f, 0.309f}, // 9: inner (top-right) +}; + +// --- Triangle index tables (each triple = one CCW triangle) --- + +const int Tri_tris[] = { + 0, 1, 2, +}; + +const int Sq_tris[] = { + 0, 3, 2, + 0, 2, 1, +}; + +const int Dia_tris[] = { + 0, 3, 2, + 0, 2, 1, +}; + +const int Pent_tris[] = { + 0, 4, 3, + 0, 3, 2, + 0, 2, 1, +}; + +const int Hex_tris[] = { + 0, 5, 4, + 0, 4, 3, + 0, 3, 2, + 0, 2, 1, +}; + +// Cross: two rectangles, each split into two triangles. Indices reference Cross_verts above. +const int Cross_tris[] = { + // Vertical bar (corners 0,1,6,7) + 0, 7, 6, + 0, 6, 1, + // Horizontal bar (corners 10,3,4,9) + 10, 9, 4, + 10, 4, 3, +}; + +// Star: 5 arm-triangles (outer point + its two neighboring inner points) plus the central +// inner pentagon fanned from inner vertex 1. Indices reference Star_verts above. +const int Star_tris[] = { + // 5 arms + 9, 0, 1, // top arm + 1, 2, 3, // top-left arm + 3, 4, 5, // bottom-left arm + 5, 6, 7, // bottom-right arm + 7, 8, 9, // top-right arm + // Inner pentagon, fanned from vertex 1 + 1, 3, 5, + 1, 5, 7, + 1, 7, 9, +}; + +struct ShapeDef { + const vec2f* verts; + int vert_count; + const int* tris; + int tri_index_count; // number of indices, always a multiple of 3 +}; + +const ShapeDef Shape_defs[] = { + { Tri_verts, static_cast(sizeof(Tri_verts) / sizeof(vec2f)), + Tri_tris, static_cast(sizeof(Tri_tris) / sizeof(int)) }, + { Sq_verts, static_cast(sizeof(Sq_verts) / sizeof(vec2f)), + Sq_tris, static_cast(sizeof(Sq_tris) / sizeof(int)) }, + { Dia_verts, static_cast(sizeof(Dia_verts) / sizeof(vec2f)), + Dia_tris, static_cast(sizeof(Dia_tris) / sizeof(int)) }, + { Pent_verts, static_cast(sizeof(Pent_verts) / sizeof(vec2f)), + Pent_tris, static_cast(sizeof(Pent_tris) / sizeof(int)) }, + { Hex_verts, static_cast(sizeof(Hex_verts) / sizeof(vec2f)), + Hex_tris, static_cast(sizeof(Hex_tris) / sizeof(int)) }, + { Cross_verts, static_cast(sizeof(Cross_verts) / sizeof(vec2f)), + Cross_tris, static_cast(sizeof(Cross_tris) / sizeof(int)) }, + { Star_verts, static_cast(sizeof(Star_verts) / sizeof(vec2f)), + Star_tris, static_cast(sizeof(Star_tris) / sizeof(int)) }, +}; + +static_assert(sizeof(Shape_defs) / sizeof(ShapeDef) == static_cast(CoordinatePointShape::NUM_SHAPES), + "Shape_defs is out of sync with CoordinatePointShape"); + +} // anonymous namespace + +float get_coordinate_point_world_radius(const mission_coordinate_point& cp, const vec3d& camera_eye) +{ + if (cp.objnum < 0) { + return COORDINATE_POINT_LOLLIPOP_SIZE * cp.size_scale; + } + const vec3d& pos = Objects[cp.objnum].pos; + float dist = vm_vec_dist(&camera_eye, &pos); + float size = fl_sqrt(dist / COORDINATE_POINT_DIST_DIVISOR) * cp.size_scale; + const float floor_size = COORDINATE_POINT_LOLLIPOP_SIZE * cp.size_scale; + if (size < floor_size) { + size = floor_size; + } + return size; +} + +void draw_coordinate_point_shape(const mission_coordinate_point& cp, + const vec3d* camera_eye, + const matrix* camera_orient) +{ + if (cp.objnum < 0) { + return; + } + + const auto shape_idx = static_cast(cp.shape); + if (shape_idx >= sizeof(Shape_defs) / sizeof(ShapeDef)) { + return; + } + + const ShapeDef& def = Shape_defs[shape_idx]; + const vec3d& pos = Objects[cp.objnum].pos; + const float radius = get_coordinate_point_world_radius(cp, *camera_eye); + + const vec3d& rvec = camera_orient->vec.rvec; + const vec3d& uvec = camera_orient->vec.uvec; + + // Build the camera-facing world-space vertices once, then expand the triangle index table + // into a flat vertex array for PRIM_TYPE_TRIS. + vec3d world_verts[32]; + Assertion(def.vert_count <= static_cast(sizeof(world_verts) / sizeof(world_verts[0])), + "Coordinate-point shape vertex table is larger than the local buffer"); + for (int i = 0; i < def.vert_count; ++i) { + world_verts[i] = pos; + vm_vec_scale_add2(&world_verts[i], &rvec, def.verts[i].x * radius); + vm_vec_scale_add2(&world_verts[i], &uvec, def.verts[i].y * radius); + } + + vertex tri_verts[64]; + Assertion(def.tri_index_count <= static_cast(sizeof(tri_verts) / sizeof(tri_verts[0])), + "Coordinate-point shape triangle list is larger than the local buffer"); + for (int i = 0; i < def.tri_index_count; ++i) { + g3_transfer_vertex(&tri_verts[i], &world_verts[def.tris[i]]); + } + + material mat; + mat.set_color(cp.display_color); + mat.set_depth_mode(ZBUFFER_TYPE_FULL); + mat.set_blend_mode(ALPHA_BLEND_ALPHA_BLEND_ALPHA); + + g3_render_primitives(&mat, tri_verts, def.tri_index_count, PRIM_TYPE_TRIS, false); +} + +void coordinate_points_render_all_in_mission() +{ + bool any_drawn = false; + const bool in_multi = (Net_player != nullptr); + const int local_multi_team = in_multi ? Net_player->p_info.team : -1; + + for (const auto& cp : Coordinate_points) { + if (cp.objnum < 0) { + continue; + } + if (!cp.flags[CoordinatePoint::Flags::Visible_in_mission]) { + continue; + } + // Multiplayer team filter: if this point is assigned to a specific team and the local + // player is on a different team, skip rendering. Singleplayer and "no team" (team < 0) + // points always render. + if (in_multi && cp.multi_team >= 0 && cp.multi_team != local_multi_team) { + continue; + } + draw_coordinate_point_shape(cp, &Eye_position, &Eye_matrix); + any_drawn = true; + } + // g3_render_primitives leaks the material's color into the immediate-mode draw state. + // Reset it so downstream HUD draws see a known starting color. + if (any_drawn) { + gr_set_color(255, 255, 255); + } +} + +void coordinate_points_render_all_in_editor(const vec3d* camera_eye, const matrix* camera_orient) +{ + for (const auto& cp : Coordinate_points) { + if (cp.objnum < 0) { + continue; + } + draw_coordinate_point_shape(cp, camera_eye, camera_orient); + } +} diff --git a/code/coordinate_points/coordinate_point_render.h b/code/coordinate_points/coordinate_point_render.h new file mode 100644 index 00000000000..400500aa19f --- /dev/null +++ b/code/coordinate_points/coordinate_point_render.h @@ -0,0 +1,31 @@ +#pragma once + +#include "coordinate_points/coordinate_point.h" + +struct vec3d; +struct matrix; + +// Distance-scaled size constants: matches the waypoint sphere sizing in qtFRED so coord points +// read at similar scale to existing markers. +constexpr float COORDINATE_POINT_LOLLIPOP_SIZE = 2.0f; +constexpr float COORDINATE_POINT_DIST_DIVISOR = 20.0f; + +// Draw a single coordinate point as a camera-facing line-loop polygon at its world position. +// Uses the supplied camera orientation for right/up vectors. The camera eye is needed for +// distance-based sizing. Color, shape, and size_scale come from the coord point itself. +void draw_coordinate_point_shape(const mission_coordinate_point& cp, + const vec3d* camera_eye, + const matrix* camera_orient); + +// Returns the world-space radius the shape will be drawn at given the camera eye position. +// Exposed for HUD target-bracket sizing so the bracket fits the rendered shape. +float get_coordinate_point_world_radius(const mission_coordinate_point& cp, const vec3d& camera_eye); + +// In-game render pass. Iterates Coordinate_points, filters by the Visible_in_mission flag, and +// draws each visible coord point using the global Eye_position / Eye_matrix. +void coordinate_points_render_all_in_mission(); + +// Editor render pass. Iterates Coordinate_points and draws each one regardless of the +// Visible_in_mission flag, using the supplied camera. Caller is responsible for any +// view-level "show coordinate points" toggle. +void coordinate_points_render_all_in_editor(const vec3d* camera_eye, const matrix* camera_orient); diff --git a/code/hud/hudbrackets.cpp b/code/hud/hudbrackets.cpp index eff306a6963..0a86ab592cd 100644 --- a/code/hud/hudbrackets.cpp +++ b/code/hud/hudbrackets.cpp @@ -16,6 +16,7 @@ #include "hud/hudtarget.h" #include "iff_defs/iff_defs.h" #include "coordinate_points/coordinate_point.h" +#include "coordinate_points/coordinate_point_render.h" #include "jumpnode/jumpnode.h" #include "mission/missionparse.h" #include "object/object.h" @@ -458,12 +459,34 @@ void HudGaugeBrackets::renderObjectBrackets(object *targetp, color *clr, int w_c case OBJ_COORDINATE_POINT: { - // Coordinate points have no model; project the world position to screen and draw a small bracket. - vertex vtx; - g3_rotate_vertex(&vtx, &targetp->pos); - g3_project_vertex(&vtx); - x1 = x2 = (int)vtx.screen.xyw.x; - y1 = y2 = (int)vtx.screen.xyw.y; + // Coordinate points have no model. Size the bracket so it matches the rendered + // shape: use the same world-space radius the renderer uses, project a point offset + // along the camera right vector, and measure the screen-space delta to derive a + // half-extent. This automatically scales with both distance and the coord point's + // size_scale field. + vertex v_center; + g3_rotate_vertex(&v_center, &targetp->pos); + g3_project_vertex(&v_center); + + int half_extent = 0; + auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(targetp)); + if (cp != nullptr) { + const float radius = get_coordinate_point_world_radius(*cp, Eye_position); + vec3d offset_pos = targetp->pos; + vm_vec_scale_add2(&offset_pos, &Eye_matrix.vec.rvec, radius); + + vertex v_offset; + g3_rotate_vertex(&v_offset, &offset_pos); + g3_project_vertex(&v_offset); + + half_extent = std::abs(static_cast(v_offset.screen.xyw.x) - + static_cast(v_center.screen.xyw.x)); + } + + x1 = static_cast(v_center.screen.xyw.x) - half_extent; + x2 = static_cast(v_center.screen.xyw.x) + half_extent; + y1 = static_cast(v_center.screen.xyw.y) - half_extent; + y2 = static_cast(v_center.screen.xyw.y) + half_extent; } break; diff --git a/code/hud/hudescort.cpp b/code/hud/hudescort.cpp index fc283abcb4e..d52213c926d 100644 --- a/code/hud/hudescort.cpp +++ b/code/hud/hudescort.cpp @@ -341,14 +341,10 @@ void HudGaugeEscort::renderIcon(int x, int y, int index, float scale, bool confi gr_set_color_fast(&cp->display_color); - char buf[256]; - if (!cp->category.empty()) { - snprintf(buf, sizeof(buf), "%s (%s)", cp->name.c_str(), cp->category.c_str()); - } else { - snprintf(buf, sizeof(buf), "%s", cp->name.c_str()); - } + char buf[NAME_LENGTH]; + snprintf(buf, sizeof(buf), "%s", cp->name.c_str()); - const int w = font::force_fit_string(buf, 255, fl2i(ship_name_max_width * scale), scale); + const int w = font::force_fit_string(buf, NAME_LENGTH - 1, fl2i(ship_name_max_width * scale), scale); if (right_align_names) { renderString(x + fl2i(ship_name_offsets[0] * scale) + fl2i(ship_name_max_width * scale) - w, y + fl2i(ship_name_offsets[1] * scale), EG_ESCORT1 + index, buf, scale, config); diff --git a/code/hud/hudlock.cpp b/code/hud/hudlock.cpp index a1dc1407f4e..79e4f5c24e5 100644 --- a/code/hud/hudlock.cpp +++ b/code/hud/hudlock.cpp @@ -482,7 +482,7 @@ int hud_abort_lock() if ( (Player_ai->target_objnum >= 0) ) { target_team = obj_team(&Objects[Player_ai->target_objnum]); - if ( ( Player_ship->team == target_team) && ( !iff_x_attacks_y(Player_ship->team, target_team) ) + if ( ( Player_ship->team == target_team) && ( !iff_x_attacks_y(Player_ship->team, target_team) ) && !weapon_has_iff_restrictions(wip)) { // if we're in multiplayer dogfight, ignore this // remember to check if we're firing a missile that doesn't require a current target diff --git a/code/hud/hudtarget.cpp b/code/hud/hudtarget.cpp index 6d2c7711582..22dabebd79d 100644 --- a/code/hud/hudtarget.cpp +++ b/code/hud/hudtarget.cpp @@ -30,6 +30,7 @@ #include "hud/hudtargetbox.h" #include "iff_defs/iff_defs.h" #include "io/timer.h" +#include "coordinate_points/coordinate_point.h" #include "jumpnode/jumpnode.h" #include "localization/localize.h" #include "mission/missionhotkey.h" @@ -2465,6 +2466,14 @@ int object_targetable_in_reticle(object *target_objp) if (!jnp->IsHidden()) return 1; } + else if ( obj_type == OBJ_COORDINATE_POINT ) + { + // Only target-in-front-able when the designer marked the point as visible in mission. + auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(target_objp)); + if (cp != nullptr && cp->flags[CoordinatePoint::Flags::Visible_in_mission]) { + return 1; + } + } return 0; } @@ -2551,6 +2560,10 @@ void hud_target_in_reticle_new() mc.model_num = jnp->GetModelNumber(); } break; + case OBJ_COORDINATE_POINT: + // No model to ray-cast against; fall through to the point-distance path below. + mc.model_num = -1; + break; default: Int3(); // Illegal object type. } diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index f4275e59b98..0efd8a667df 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -5433,9 +5433,9 @@ void parse_wings(mission* pm) { required_string("#Wings"); while (true) { - int which = required_string_one_of(3, "#Events", "#Props", "$Name:"); + int which = required_string_one_of(4, "#Events", "#Props", "#Coordinate Points", "$Name:"); - if (which == -1 || which == 0 || which == 1) // #Events or #Props + if (which == -1 || which == 0 || which == 1 || which == 2) // any section terminator break; Assert(Num_wings < MAX_WINGS); @@ -5472,12 +5472,13 @@ void parse_coordinate_point(mission* /*pm*/) } if (optional_string("+Color:")) { - int rgb[3] = { 255, 255, 255 }; - stuff_int_list(rgb, 3, ParseLookupType::RAW_INTEGER_TYPE); - for (int& c : rgb) { + int rgba[4] = { 255, 255, 255, 255 }; + // Three-value form (R G B) is accepted for backwards compatibility; alpha defaults to 255. + stuff_int_list(rgba, 4, ParseLookupType::RAW_INTEGER_TYPE); + for (int& c : rgba) { CLAMP(c, 0, 255); } - gr_init_alphacolor(&cp.display_color, rgb[0], rgb[1], rgb[2], 255); + gr_init_alphacolor(&cp.display_color, rgba[0], rgba[1], rgba[2], rgba[3]); } if (optional_string("+Shape:")) { @@ -5498,6 +5499,13 @@ void parse_coordinate_point(mission* /*pm*/) } } + if (optional_string("+Multi Team:")) { + stuff_int(&cp.multi_team); + if (cp.multi_team < -1 || cp.multi_team >= MAX_TVT_TEAMS) { + cp.multi_team = -1; + } + } + if (optional_string("+Flags:")) { SCP_vector unparsed; parse_string_flag_list(cp.flags, Parse_coordinate_point_flags, Num_parse_coordinate_point_flags, &unparsed); @@ -5508,6 +5516,17 @@ void parse_coordinate_point(mission* /*pm*/) } } + if (optional_string("+Layer:")) { + stuff_string(cp.fred_layer, F_NAME); + if (!mission_has_layer_name(&The_mission, cp.fred_layer)) { + if (cp.fred_layer.empty()) { + cp.fred_layer = "Default"; + } else { + The_mission.fred_layers.push_back(cp.fred_layer); + } + } + } + Parse_coordinate_points.emplace_back(std::move(cp)); } diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index 3701e587534..b20ae14a575 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -5473,17 +5473,19 @@ int Fred_mission_save::save_coordinate_points() } const bool color_is_default = - (cp.display_color.red == 255 && cp.display_color.green == 255 && cp.display_color.blue == 255); + (cp.display_color.red == 255 && cp.display_color.green == 255 && + cp.display_color.blue == 255 && cp.display_color.alpha == 255); if (!color_is_default) { if (optional_string_fred("+Color:", "$Name:")) { parse_comments(); } else { fout("\n+Color:"); } - fout(" ( %d %d %d )", cp.display_color.red, cp.display_color.green, cp.display_color.blue); + fout(" ( %d %d %d %d )", cp.display_color.red, cp.display_color.green, + cp.display_color.blue, cp.display_color.alpha); } - if (cp.shape != CoordinatePointShape::Triangle) { + if (cp.shape != CoordinatePointShape::Diamond) { if (optional_string_fred("+Shape:", "$Name:")) { parse_comments(); } else { @@ -5510,6 +5512,15 @@ int Fred_mission_save::save_coordinate_points() fout(" %d", cp.escort_priority); } + if (cp.multi_team >= 0) { + if (optional_string_fred("+Multi Team:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Multi Team:"); + } + fout(" %d", cp.multi_team); + } + if (cp.flags.any_set()) { if (optional_string_fred("+Flags:", "$Name:")) { parse_comments(); @@ -5526,6 +5537,17 @@ int Fred_mission_save::save_coordinate_points() fout(" )"); } + if (save_config.save_format != MissionFormat::RETAIL && + !cp.fred_layer.empty() && + !lcase_equal(cp.fred_layer, "Default")) { + if (optional_string_fred("+Layer:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Layer:"); + } + fout(" %s", cp.fred_layer.c_str()); + } + fso_comment_pop(); } diff --git a/code/object/object.cpp b/code/object/object.cpp index 42c8969e57f..793aa7ad753 100644 --- a/code/object/object.cpp +++ b/code/object/object.cpp @@ -21,6 +21,8 @@ #include "globalincs/vmallocator.h" #include "iff_defs/iff_defs.h" #include "io/timer.h" +#include "coordinate_points/coordinate_point.h" +#include "coordinate_points/coordinate_point_render.h" #include "jumpnode/jumpnode.h" #include "lighting/lighting.h" #include "lighting/lighting_profiles.h" @@ -772,7 +774,10 @@ void obj_delete(int objnum) prop_delete(objp); break; case OBJ_COORDINATE_POINT: - break; // requires no further action, handled by coordinate_point_delete / level close. + // Remove the matching entry from Coordinate_points so the list stays in sync with Objects[]. + // During level close the list is cleared up-front, so this call becomes a no-op then. + coordinate_point_delete(objnum); + break; case OBJ_NONE: Int3(); break; @@ -1976,7 +1981,8 @@ void obj_queue_render(object* obj, model_draw_list* scene) prop_render(obj, scene); break; case OBJ_COORDINATE_POINT: - // Coordinate Points never render in-game; the editor draws them via its own pass. + // Coordinate points are drawn in their own post-scene pass (coordinate_points_render_all_in_mission) + // to avoid immediate-mode state leaking into the queued scene render. break; default: Error( LOCATION, "Unhandled obj type %d in obj_render", obj->type ); @@ -2073,9 +2079,13 @@ int obj_team(object *objp) break; case OBJ_JUMP_NODE: + case OBJ_COORDINATE_POINT: + // No real team, but reporting the player's team short-circuits "same-team don't lock" + // logic everywhere and keeps the team != -1 invariant the assertion below requires. + // Jump nodes have done this since forever; coordinate points piggyback on the same trick. team = Player_ship->team; break; - + case OBJ_FIREBALL: case OBJ_WAYPOINT: case OBJ_START: @@ -2085,7 +2095,6 @@ int obj_team(object *objp) case OBJ_BEAM: case OBJ_RAW_POF: case OBJ_PROP: - case OBJ_COORDINATE_POINT: team = -1; break; diff --git a/code/scripting/api/objs/coordinatepoint.cpp b/code/scripting/api/objs/coordinatepoint.cpp index 9caf62a94f5..ee35a7ad2be 100644 --- a/code/scripting/api/objs/coordinatepoint.cpp +++ b/code/scripting/api/objs/coordinatepoint.cpp @@ -5,6 +5,7 @@ #include "object.h" #include "coordinate_points/coordinate_point.h" +#include "globalincs/globals.h" #include "hud/hudescort.h" namespace scripting::api { @@ -116,6 +117,35 @@ ADE_VIRTVAR(VisibleInMission, return ade_set_args(L, "b", cp->flags[CoordinatePoint::Flags::Visible_in_mission]); } +ADE_VIRTVAR(MultiTeam, + l_CoordinatePoint, + "number", + "Multiplayer team filter. -1 (default) = visible to all teams. 0 or higher = visible only to that TVT team in multiplayer. Singleplayer always renders the point regardless of this value.", + "number", + "Multi team value, or -1 if handle is invalid") +{ + object_h* objh; + int val = -1; + if (!ade_get_args(L, "o|i", l_CoordinatePoint.GetPtr(&objh), &val)) + return ade_set_error(L, "i", -1); + + if (!objh->isValid() || objh->objp()->type != OBJ_COORDINATE_POINT) + return ade_set_error(L, "i", -1); + + auto* cp = find_coordinate_point_by_objnum(objh->objnum); + if (cp == nullptr) + return ade_set_error(L, "i", -1); + + if (ADE_SETTING_VAR) { + if (val < -1 || val >= MAX_TVT_TEAMS) { + val = -1; + } + cp->multi_team = val; + } + + return ade_set_args(L, "i", cp->multi_team); +} + ADE_FUNC(addToEscortList, l_CoordinatePoint, nullptr, "Adds this coordinate point to the player's escort list. Requires EscortPriority > 0; otherwise a no-op.", "boolean", diff --git a/code/source_groups.cmake b/code/source_groups.cmake index ac266cd2e7b..223765198de 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -1248,6 +1248,8 @@ add_file_folder("Coordinate Points" coordinate_points/coordinate_point.cpp coordinate_points/coordinate_point.h coordinate_points/coordinate_point_flags.h + coordinate_points/coordinate_point_render.cpp + coordinate_points/coordinate_point_render.h ) # Radar files diff --git a/freespace2/freespace.cpp b/freespace2/freespace.cpp index 746edd3d81c..c8da60c2f6c 100644 --- a/freespace2/freespace.cpp +++ b/freespace2/freespace.cpp @@ -84,6 +84,7 @@ #include "io/key.h" #include "io/mouse.h" #include "io/timer.h" +#include "coordinate_points/coordinate_point_render.h" #include "jumpnode/jumpnode.h" #include "lab/labv2.h" #include "libs/discord/discord.h" @@ -3554,6 +3555,9 @@ void game_render_frame( camid cid, const vec3d* offset, const matrix* rot_offset shadows_render_all(Proj_fov, &Eye_matrix, &Eye_position, offset, rot_offset, fov_override); obj_render_queue_all(); + // Draw any Visible_in_mission coordinate points as overlays on top of the queued scene. + coordinate_points_render_all_in_mission(); + // render all ships with shader effects on them auto obji = effect_ships.begin(); for(;obji != effect_ships.end();++obji) diff --git a/qtfred/help-src/doc/dialogs/CoordinatePointEditorDialog.html b/qtfred/help-src/doc/dialogs/CoordinatePointEditorDialog.html new file mode 100644 index 00000000000..f437e908ab5 --- /dev/null +++ b/qtfred/help-src/doc/dialogs/CoordinatePointEditorDialog.html @@ -0,0 +1,73 @@ + + + + + Coordinate Point Editor - QtFRED Help + + + + +

Coordinate Point Editor

+

Opens via Editors › Coordinate Points, by double-clicking a +coordinate point in the viewport, or from the context bar when one is selected.

+ +

Coordinate Points are named positions in 3D space. They have no model, no AI, no +hitpoints, and no collision. SEXPs and Lua scripts can read their position by name, +and optionally they can be made visible in-mission, added to the escort list, and +targeted by the player.

+ +

The dialog tracks the viewport selection. Select one or more coordinate points and +the dialog shows their values; edits apply to every selected point at once. When a +field's value differs across the selection it is rendered as mixed — an +empty spin box, a partially-checked checkbox, or a "?" color swatch — and is +left alone until you commit a value.

+ +

Navigation

+ + + +
ButtonDescription
Prev / NextCycles the viewport selection to the previous or next + coordinate point in the mission, allowing sequential editing without + switching back to the viewport.
+ +

Fields

+ + + + + + + + + + + +
FieldDescription
NameIdentifies the point for SEXPs, Lua, and the escort list. + Must be unique across coordinate points, ships, wings, waypoint paths, and + jump nodes. Read-only when multiple points are selected; rename individually.
CategoryFree-form string. Shown in the HUD target box and the + escort list. Designers can use this for any visible grouping (e.g. + Combat, Nav, Objective).
ShapeOutline shape rendered in the editor (and in-mission when + the Visible flag is set). Choices: Triangle, Square, Diamond, Pentagon, + Hexagon, Cross, Star.
SizeScales the rendered shape. 1.0 is the default; range + 0.25-8.0.
Escort Priority0 (default) means the point is not on the escort + list. Any value greater than 0 adds the point to the escort list, using the + number as the sort key. Higher numbers sort higher on the list. The player + can cycle to the point with the standard escort target key when it is on + the list.
Visible in MissionWhen checked, the point's shape renders in-game + and the player can target it with the standard target-in-front key. When + unchecked, the point is visible in the editor only.
Multi TeamMultiplayer team filter. Any team (default) makes + the point visible to everyone. Selecting a specific team restricts in-mission + visibility to that team only; players on other teams will not see or be able to + target it. The selector is disabled when the mission is not a team mission, and + the value has no effect in single-player. Distinct from IFF team + (Friendly / Hostile).
LayerThe editor view layer this coordinate point belongs to. Hidden + layers don't render in the viewport. Layers are saved to the mission file and + managed via the Layer Manager.
R / G / B / ARGBA color used for the filled shape and the HUD target + bracket. Each channel can be edited independently. Alpha below 255 makes the + shape semi-transparent in-mission; 255 is fully opaque.
+ +
Coordinate point positions are moved by selecting the point in the +viewport and dragging, or by editing its coordinates in the +Object Orientation Editor.
+ + diff --git a/qtfred/help-src/doc/index.html b/qtfred/help-src/doc/index.html index 1f5c991e196..1ad65bad23c 100644 --- a/qtfred/help-src/doc/index.html +++ b/qtfred/help-src/doc/index.html @@ -74,6 +74,7 @@

Objects

  • Prop Editor
  • Ship Editor
  • Waypoint Editor
  • +
  • Coordinate Point Editor
  • Wing Editor
  • diff --git a/qtfred/help-src/doc/qtfred.qhp b/qtfred/help-src/doc/qtfred.qhp index 4b6d0e5146e..278c9f632fd 100644 --- a/qtfred/help-src/doc/qtfred.qhp +++ b/qtfred/help-src/doc/qtfred.qhp @@ -69,6 +69,7 @@
    +
    @@ -142,6 +143,7 @@ + @@ -204,6 +206,7 @@ dialogs/VoiceActingManager.html dialogs/VolumetricNebulaDialog.html dialogs/WaypointEditorDialog.html + dialogs/CoordinatePointEditorDialog.html dialogs/WaypointPathGeneratorDialog.html dialogs/WingEditorDialog.html diff --git a/qtfred/help-src/qtfred.qhp b/qtfred/help-src/qtfred.qhp index 59956fe7416..e769f2c5d57 100644 --- a/qtfred/help-src/qtfred.qhp +++ b/qtfred/help-src/qtfred.qhp @@ -67,6 +67,7 @@
    +
    @@ -138,6 +139,7 @@ + @@ -198,6 +200,7 @@ doc/dialogs/VoiceActingManager.html doc/dialogs/VolumetricNebulaDialog.html doc/dialogs/WaypointEditorDialog.html + doc/dialogs/CoordinatePointEditorDialog.html doc/dialogs/WaypointPathGeneratorDialog.html doc/dialogs/WingEditorDialog.html diff --git a/qtfred/source_groups.cmake b/qtfred/source_groups.cmake index 2858c90ab40..53ffb060670 100644 --- a/qtfred/source_groups.cmake +++ b/qtfred/source_groups.cmake @@ -104,6 +104,8 @@ add_file_folder("Source/Mission/Dialogs" src/mission/dialogs/VolumetricNebulaDialogModel.h src/mission/dialogs/WaypointEditorDialogModel.cpp src/mission/dialogs/WaypointEditorDialogModel.h + src/mission/dialogs/CoordinatePointEditorDialogModel.cpp + src/mission/dialogs/CoordinatePointEditorDialogModel.h src/mission/dialogs/WaypointPathGeneratorDialogModel.cpp src/mission/dialogs/WaypointPathGeneratorDialogModel.h src/mission/dialogs/WingEditorDialogModel.cpp @@ -225,6 +227,8 @@ add_file_folder("Source/UI/Dialogs" src/ui/dialogs/VolumetricNebulaDialog.cpp src/ui/dialogs/WaypointEditorDialog.cpp src/ui/dialogs/WaypointEditorDialog.h + src/ui/dialogs/CoordinatePointEditorDialog.cpp + src/ui/dialogs/CoordinatePointEditorDialog.h src/ui/dialogs/WaypointPathGeneratorDialog.cpp src/ui/dialogs/WaypointPathGeneratorDialog.h src/ui/dialogs/WingEditorDialog.cpp @@ -375,6 +379,7 @@ add_file_folder("UI" ui/VoiceActingManager.ui ui/VolumetricNebulaDialog.ui ui/WaypointEditorDialog.ui + ui/CoordinatePointEditorDialog.ui ui/WaypointPathGeneratorDialog.ui ui/ShipEditorDialog.ui ui/ShipInitialStatus.ui diff --git a/qtfred/src/mission/Editor.cpp b/qtfred/src/mission/Editor.cpp index 8eac2bc69cf..f9e5ef9165e 100644 --- a/qtfred/src/mission/Editor.cpp +++ b/qtfred/src/mission/Editor.cpp @@ -975,6 +975,23 @@ int Editor::dup_object(object* objp) { clone_prop_instance_data(inst, Objects[obj].instance); } } + } else if (objp->type == OBJ_COORDINATE_POINT) { + auto* src = find_coordinate_point_by_objnum(OBJ_INDEX(objp)); + obj = coordinate_point_create(&objp->pos); + if (obj >= 0 && src != nullptr) { + auto* clone = find_coordinate_point_by_objnum(obj); + if (clone != nullptr) { + // Copy every field except the auto-generated unique name and the objnum. + clone->category = src->category; + clone->display_color = src->display_color; + clone->shape = src->shape; + clone->size_scale = src->size_scale; + clone->escort_priority = src->escort_priority; + clone->multi_team = src->multi_team; + clone->flags = src->flags; + clone->fred_layer = src->fred_layer; + } + } } if (obj == -1) { diff --git a/qtfred/src/mission/EditorViewport.cpp b/qtfred/src/mission/EditorViewport.cpp index 946e31b1691..146ecdcfd2a 100644 --- a/qtfred/src/mission/EditorViewport.cpp +++ b/qtfred/src/mission/EditorViewport.cpp @@ -12,6 +12,8 @@ #include "EditorViewport.h" #include #include +#include +#include #include #include #include @@ -166,6 +168,7 @@ void EditorViewport::loadSettings() { view.Lighting_on = settings.value("view_lighting_on", view.Lighting_on).toBool(); view.FullDetail = settings.value("view_full_detail", view.FullDetail).toBool(); view.Show_waypoints = settings.value("view_show_waypoints", view.Show_waypoints).toBool(); + view.Show_coordinate_points = settings.value("view_show_coordinate_points", view.Show_coordinate_points).toBool(); view.Show_compass = settings.value("view_show_compass", view.Show_compass).toBool(); view.Highlight_selectable_subsys = settings.value("view_highlight_selectable_subsys", view.Highlight_selectable_subsys).toBool(); view.Outline_lod = settings.value("view_outline_lod", view.Outline_lod).toInt(); @@ -213,6 +216,7 @@ void EditorViewport::saveSettings() const { settings.setValue("view_lighting_on", view.Lighting_on); settings.setValue("view_full_detail", view.FullDetail); settings.setValue("view_show_waypoints", view.Show_waypoints); + settings.setValue("view_show_coordinate_points", view.Show_coordinate_points); settings.setValue("view_show_compass", view.Show_compass); settings.setValue("view_highlight_selectable_subsys", view.Highlight_selectable_subsys); settings.setValue("view_outline_lod", view.Outline_lod); @@ -335,6 +339,12 @@ void EditorViewport::select_objects(const Marking_box& box) { valid = 0; } break; + + case OBJ_COORDINATE_POINT: + if (!view.Show_coordinate_points) { + valid = 0; + } + break; } g3_rotate_vertex(&v, &ptr->pos); @@ -715,6 +725,10 @@ int EditorViewport::object_check_collision(object* objp, vec3d* p0, vec3d* p1, v return 0; } + if ((objp->type == OBJ_COORDINATE_POINT) && !view.Show_coordinate_points) { + return 0; + } + if (objp->flags[Object::Object_Flags::Hidden, Object::Object_Flags::Locked_from_editing]) { return 0; } @@ -814,7 +828,33 @@ int EditorViewport::select_object(int cx, int cy) { hitpos.xyz.x = vt.screen.xyw.x - cx; hitpos.xyz.y = vt.screen.xyw.y - cy; dist = hitpos.xyz.x * hitpos.xyz.x + hitpos.xyz.y * hitpos.xyz.y; - if ((dist < 8) && (dist < best_dist)) { + + // Coordinate points render at a per-instance scaled size; widen the click + // tolerance to match the visible shape rather than using the default 2.8-pixel + // grace area. + double threshold = 8.0; + if (objp->type == OBJ_COORDINATE_POINT) { + auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(objp)); + if (cp != nullptr) { + const float world_radius = get_coordinate_point_world_radius(*cp, camera.eye_pos); + vec3d offset_pos = objp->pos; + vm_vec_scale_add2(&offset_pos, &camera.eye_orient.vec.rvec, world_radius); + + vertex v_offset; + g3_rotate_vertex(&v_offset, &offset_pos); + if (!(v_offset.codes & CC_BEHIND) && + !(g3_project_vertex(&v_offset) & PF_OVERFLOW)) { + const double dx = v_offset.screen.xyw.x - vt.screen.xyw.x; + const double dy = v_offset.screen.xyw.y - vt.screen.xyw.y; + const double radius_sq = dx * dx + dy * dy; + // Keep at least the default tolerance so tiny / very-distant points + // stay clickable. + threshold = std::max(8.0, radius_sq); + } + } + } + + if ((dist < threshold) && (dist < best_dist)) { best = OBJ_INDEX(objp); best_dist = dist; } @@ -873,6 +913,11 @@ void EditorViewport::setObjectLayerByIndex(int objectIndex, size_t layerIndex) { if (jn != nullptr) { jn->SetFredLayer(layerName); } + } else if (Objects[objectIndex].type == OBJ_COORDINATE_POINT) { + auto* cp = find_coordinate_point_by_objnum(objectIndex); + if (cp != nullptr) { + cp->fred_layer = layerName; + } } else if (Objects[objectIndex].type == OBJ_WAYPOINT) { // Layer is tracked at the path level; sync all waypoints in the path to the same layer auto* wl = find_waypoint_list_with_instance(Objects[objectIndex].instance, nullptr); @@ -1040,6 +1085,12 @@ void EditorViewport::reloadLayersFromMission() { const auto found = getLayerIndex(wl->get_fred_layer()); layerIndex = found == static_cast(-1) ? 0 : found; } + } else if (objp->type == OBJ_COORDINATE_POINT) { + auto* cp = find_coordinate_point_by_objnum(objectIndex); + if (cp != nullptr) { + const auto found = getLayerIndex(cp->fred_layer); + layerIndex = found == static_cast(-1) ? 0 : found; + } } setObjectLayerByIndex(objectIndex, layerIndex); diff --git a/qtfred/src/mission/EditorViewport.h b/qtfred/src/mission/EditorViewport.h index 91904a0ec26..daa001f77ff 100644 --- a/qtfred/src/mission/EditorViewport.h +++ b/qtfred/src/mission/EditorViewport.h @@ -54,6 +54,7 @@ struct ViewSettings { bool Show_waypoints = true; bool Show_props = true; bool Show_jump_nodes = true; + bool Show_coordinate_points = true; bool Show_compass = true; bool Highlight_selectable_subsys = false; int Outline_lod = 1; diff --git a/qtfred/src/mission/FredRenderer.cpp b/qtfred/src/mission/FredRenderer.cpp index 5bd82493288..4433d65cd3b 100644 --- a/qtfred/src/mission/FredRenderer.cpp +++ b/qtfred/src/mission/FredRenderer.cpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include #include #include #include @@ -410,6 +412,10 @@ void FredRenderer::display_ship_info(int cur_object_index) { render = 0; } + if ((objp->type == OBJ_COORDINATE_POINT) && !view().Show_coordinate_points) { + render = 0; + } + if (objp->flags[Object::Object_Flags::Hidden]) { render = 0; } @@ -447,6 +453,15 @@ void FredRenderer::display_ship_info(int cur_object_index) { if (propp != nullptr) { sprintf(buf, "%s\n", propp->prop_name); } + } else if (objp->type == OBJ_COORDINATE_POINT) { + auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(objp)); + if (cp != nullptr) { + if (!cp->category.empty()) { + sprintf(buf, "%s\n%s", cp->name.c_str(), cp->category.c_str()); + } else { + sprintf(buf, "%s", cp->name.c_str()); + } + } } else Assert(0); } @@ -639,6 +654,10 @@ void FredRenderer::render_one_model_htl(object* objp, return; // jump nodes have their own render loop in render_frame } + if (objp->type == OBJ_COORDINATE_POINT) { + return; // coordinate points have their own render loop in render_frame + } + if ((objp->type == OBJ_WAYPOINT) && !view().Show_waypoints) { return; } @@ -1097,6 +1116,18 @@ void FredRenderer::render_frame(int cur_object_index, } } } + + if (view().Show_coordinate_points) { + for (const auto& cp : Coordinate_points) { + if (cp.objnum < 0) { + continue; + } + if (!_viewport->isObjectVisibleInLayer(&Objects[cp.objnum])) { + continue; + } + draw_coordinate_point_shape(cp, &_viewport->camera.eye_pos, &_viewport->camera.eye_orient); + } + } disable_htl(); sprintf(buf, "(%.1f,%.1f,%.1f)", _viewport->camera.eye_pos.xyz.x, _viewport->camera.eye_pos.xyz.y, _viewport->camera.eye_pos.xyz.z); diff --git a/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.cpp b/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.cpp new file mode 100644 index 00000000000..3de5ec5f3a1 --- /dev/null +++ b/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.cpp @@ -0,0 +1,517 @@ +#include "mission/dialogs/CoordinatePointEditorDialogModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fso::fred::dialogs { + +CoordinatePointEditorDialogModel::CoordinatePointEditorDialogModel(QObject* parent, EditorViewport* viewport) : + AbstractDialogModel(parent, viewport) +{ + connect(viewport->editor, &Editor::currentObjectChanged, this, &CoordinatePointEditorDialogModel::onSelectedObjectChanged); + connect(viewport->editor, &Editor::objectMarkingChanged, this, &CoordinatePointEditorDialogModel::onSelectedObjectMarkingChanged); + connect(viewport->editor, &Editor::missionChanged, this, &CoordinatePointEditorDialogModel::onMissionChanged); + + initializeData(); +} + +bool CoordinatePointEditorDialogModel::apply() { return true; } +void CoordinatePointEditorDialogModel::reject() {} + +mission_coordinate_point* CoordinatePointEditorDialogModel::getSelected(int objnum) const +{ + return find_coordinate_point_by_objnum(objnum); +} + +void CoordinatePointEditorDialogModel::initializeData() +{ + _selectedObjnums.clear(); + _categoryMixed = false; + _redMixed = _greenMixed = _blueMixed = _alphaMixed = false; + _shapeMixed = false; + _sizeMixed = false; + _escortPriorityMixed = false; + _multiTeamMixed = false; + _visibleInMissionMixed = false; + + // Collect every marked OBJ_COORDINATE_POINT object. + for (auto* ptr = GET_FIRST(&obj_used_list); ptr != END_OF_LIST(&obj_used_list); ptr = GET_NEXT(ptr)) { + if (ptr->type == OBJ_COORDINATE_POINT && ptr->flags[Object::Object_Flags::Marked]) { + _selectedObjnums.push_back(OBJ_INDEX(ptr)); + } + } + + if (!_selectedObjnums.empty()) { + const mission_coordinate_point* first = getSelected(_selectedObjnums.front()); + if (first != nullptr) { + _currentName = first->name; + _category = first->category; + _colorR = first->display_color.red; + _colorG = first->display_color.green; + _colorB = first->display_color.blue; + _colorA = first->display_color.alpha; + _shape = first->shape; + _size = first->size_scale; + _escortPriority = first->escort_priority; + _multiTeam = first->multi_team; + _visibleInMission = first->flags[CoordinatePoint::Flags::Visible_in_mission]; + + for (size_t i = 1; i < _selectedObjnums.size(); ++i) { + const auto* other = getSelected(_selectedObjnums[i]); + if (other == nullptr) continue; + if (other->category != _category) _categoryMixed = true; + if (other->display_color.red != _colorR) _redMixed = true; + if (other->display_color.green != _colorG) _greenMixed = true; + if (other->display_color.blue != _colorB) _blueMixed = true; + if (other->display_color.alpha != _colorA) _alphaMixed = true; + if (other->shape != _shape) _shapeMixed = true; + if (other->size_scale != _size) _sizeMixed = true; + if (other->escort_priority != _escortPriority) _escortPriorityMixed = true; + if (other->multi_team != _multiTeam) _multiTeamMixed = true; + if (other->flags[CoordinatePoint::Flags::Visible_in_mission] != _visibleInMission) + _visibleInMissionMixed = true; + } + } + } else { + _currentName.clear(); + _category.clear(); + _colorR = _colorG = _colorB = _colorA = 255; + _shape = CoordinatePointShape::Diamond; + _size = 1.0f; + _escortPriority = 0; + _multiTeam = -1; + _visibleInMission = false; + } + + Q_EMIT coordinatePointMarkingChanged(); + _modified = false; +} + +bool CoordinatePointEditorDialogModel::hasValidSelection() const { return !_selectedObjnums.empty(); } +bool CoordinatePointEditorDialogModel::hasMultipleSelection() const { return _selectedObjnums.size() > 1; } +bool CoordinatePointEditorDialogModel::hasAnyCoordinatePointsInMission() { return !Coordinate_points.empty(); } +int CoordinatePointEditorDialogModel::getSelectionCount() const { return static_cast(_selectedObjnums.size()); } + +void CoordinatePointEditorDialogModel::showErrorDialogNoCancel(const SCP_string& message) +{ + if (_bypass_errors) return; + _bypass_errors = true; + _viewport->dialogProvider->showButtonDialog(DialogType::Error, "Error", message, {DialogButton::Ok}); +} + +bool CoordinatePointEditorDialogModel::validateName(const SCP_string& name) +{ + if (name.empty()) { + showErrorDialogNoCancel("Coordinate point name cannot be empty."); + return false; + } + + // Collision with another coordinate point (excluding the one we're editing). + const auto* current = getSelected(_selectedObjnums.front()); + for (const auto& other : Coordinate_points) { + if (&other == current) continue; + if (!stricmp(other.name.c_str(), name.c_str())) { + showErrorDialogNoCancel("This name is already being used by another coordinate point."); + return false; + } + } + + // Collision with a ship. + for (auto* ptr = GET_FIRST(&obj_used_list); ptr != END_OF_LIST(&obj_used_list); ptr = GET_NEXT(ptr)) { + if ((ptr->type == OBJ_SHIP) || (ptr->type == OBJ_START)) { + if (!stricmp(name.c_str(), Ships[ptr->instance].ship_name)) { + showErrorDialogNoCancel("This name is already being used by a ship."); + return false; + } + } + } + + // Collision with a wing. + for (auto& wing : Wings) { + if (!stricmp(wing.name, name.c_str())) { + showErrorDialogNoCancel("This name is already being used by a wing."); + return false; + } + } + + // Collision with a waypoint list. + for (const auto& wl : Waypoint_lists) { + if (!stricmp(wl.get_name(), name.c_str())) { + showErrorDialogNoCancel("This name is already being used by a waypoint path."); + return false; + } + } + + // Collision with a jump node. + if (jumpnode_get_by_name(name.c_str()) != nullptr) { + showErrorDialogNoCancel("This name is already being used by a jump node."); + return false; + } + + if (name[0] == '<') { + showErrorDialogNoCancel("Coordinate point names cannot begin with '<'."); + return false; + } + + return true; +} + +const SCP_string& CoordinatePointEditorDialogModel::getCurrentName() const { return _currentName; } + +bool CoordinatePointEditorDialogModel::setCurrentName(const SCP_string& name) +{ + if (hasMultipleSelection() || _selectedObjnums.empty()) { + return false; + } + + _bypass_errors = false; + SCP_string trimmed = name; + SCP_trim(trimmed); + + if (!validateName(trimmed)) { + return false; + } + + auto* cp = getSelected(_selectedObjnums.front()); + if (cp == nullptr) return false; + + cp->name = trimmed; + _currentName = trimmed; + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; + return true; +} + +const SCP_string& CoordinatePointEditorDialogModel::getCategory() const { return _category; } +bool CoordinatePointEditorDialogModel::isCategoryMixed() const { return _categoryMixed; } + +void CoordinatePointEditorDialogModel::setCategory(const SCP_string& category) +{ + _category = category; + _categoryMixed = false; + for (int objnum : _selectedObjnums) { + auto* cp = getSelected(objnum); + if (cp != nullptr) cp->category = category; + } + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +int CoordinatePointEditorDialogModel::getColorR() const { return _colorR; } +int CoordinatePointEditorDialogModel::getColorG() const { return _colorG; } +int CoordinatePointEditorDialogModel::getColorB() const { return _colorB; } +int CoordinatePointEditorDialogModel::getColorA() const { return _colorA; } +bool CoordinatePointEditorDialogModel::isColorRMixed() const { return _redMixed; } +bool CoordinatePointEditorDialogModel::isColorGMixed() const { return _greenMixed; } +bool CoordinatePointEditorDialogModel::isColorBMixed() const { return _blueMixed; } +bool CoordinatePointEditorDialogModel::isColorAMixed() const { return _alphaMixed; } +bool CoordinatePointEditorDialogModel::hasAnyColorMixed() const { + return _redMixed || _greenMixed || _blueMixed || _alphaMixed; +} + +namespace { +void applyColorChannel(SCP_vector& selected, + int r, int g, int b, int a, + bool rMixed, bool gMixed, bool bMixed, bool aMixed) +{ + for (int objnum : selected) { + auto* cp = find_coordinate_point_by_objnum(objnum); + if (cp == nullptr) continue; + int outR = rMixed ? cp->display_color.red : r; + int outG = gMixed ? cp->display_color.green : g; + int outB = bMixed ? cp->display_color.blue : b; + int outA = aMixed ? cp->display_color.alpha : a; + gr_init_alphacolor(&cp->display_color, outR, outG, outB, outA); + } +} +} + +void CoordinatePointEditorDialogModel::setColorR(int r) +{ + // -1 = spinbox "mixed" sentinel; leave _redMixed untouched. + if (r < 0) return; + CLAMP(r, 0, 255); + _colorR = r; + _redMixed = false; + applyColorChannel(_selectedObjnums, _colorR, _colorG, _colorB, _colorA, + _redMixed, _greenMixed, _blueMixed, _alphaMixed); + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +void CoordinatePointEditorDialogModel::setColorG(int g) +{ + if (g < 0) return; + CLAMP(g, 0, 255); + _colorG = g; + _greenMixed = false; + applyColorChannel(_selectedObjnums, _colorR, _colorG, _colorB, _colorA, + _redMixed, _greenMixed, _blueMixed, _alphaMixed); + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +void CoordinatePointEditorDialogModel::setColorB(int b) +{ + if (b < 0) return; + CLAMP(b, 0, 255); + _colorB = b; + _blueMixed = false; + applyColorChannel(_selectedObjnums, _colorR, _colorG, _colorB, _colorA, + _redMixed, _greenMixed, _blueMixed, _alphaMixed); + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +void CoordinatePointEditorDialogModel::setColorA(int a) +{ + if (a < 0) return; + CLAMP(a, 0, 255); + _colorA = a; + _alphaMixed = false; + applyColorChannel(_selectedObjnums, _colorR, _colorG, _colorB, _colorA, + _redMixed, _greenMixed, _blueMixed, _alphaMixed); + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +CoordinatePointShape CoordinatePointEditorDialogModel::getShape() const { return _shape; } +bool CoordinatePointEditorDialogModel::isShapeMixed() const { return _shapeMixed; } + +void CoordinatePointEditorDialogModel::setShape(CoordinatePointShape shape) +{ + _shape = shape; + _shapeMixed = false; + for (int objnum : _selectedObjnums) { + auto* cp = getSelected(objnum); + if (cp != nullptr) cp->shape = shape; + } + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +float CoordinatePointEditorDialogModel::getSize() const { return _size; } +bool CoordinatePointEditorDialogModel::isSizeMixed() const { return _sizeMixed; } + +void CoordinatePointEditorDialogModel::setSize(float v) +{ + // Negative is the "mixed" sentinel for the dialog's double spinbox. + if (v < 0.0f) return; + CLAMP(v, COORDINATE_POINT_SIZE_MIN, COORDINATE_POINT_SIZE_MAX); + _size = v; + _sizeMixed = false; + for (int objnum : _selectedObjnums) { + auto* cp = getSelected(objnum); + if (cp != nullptr) cp->size_scale = v; + } + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +int CoordinatePointEditorDialogModel::getEscortPriority() const { return _escortPriority; } +bool CoordinatePointEditorDialogModel::isEscortPriorityMixed() const { return _escortPriorityMixed; } + +void CoordinatePointEditorDialogModel::setEscortPriority(int v) +{ + // -1 = "mixed" sentinel. + if (v < 0) return; + _escortPriority = v; + _escortPriorityMixed = false; + for (int objnum : _selectedObjnums) { + auto* cp = getSelected(objnum); + if (cp != nullptr) cp->escort_priority = v; + } + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +int CoordinatePointEditorDialogModel::getMultiTeam() const { return _multiTeam; } +bool CoordinatePointEditorDialogModel::isMultiTeamMixed() const { return _multiTeamMixed; } + +bool CoordinatePointEditorDialogModel::missionIsMultiTeam() +{ + return (The_mission.game_type & MISSION_TYPE_MULTI_TEAMS) != 0; +} + +SCP_string CoordinatePointEditorDialogModel::getLayer() const +{ + // "" sentinel when selected points span different layers; the dialog renders that + // as a blank combo (currentIndex = -1). + SCP_string result; + bool first = true; + for (int objnum : _selectedObjnums) { + SCP_string layer = _viewport->getObjectLayerName(objnum); + if (first) { + result = layer; + first = false; + } else if (result != layer) { + return ""; + } + } + return result; +} + +void CoordinatePointEditorDialogModel::setLayer(const SCP_string& layer) +{ + // moveObjectToLayer may unmark objects (when moving to a hidden layer) and fires + // notifyLayerStructureChanged; both can reach back into our refresh slots. Suppress + // re-entry during the batch, then run a single initializeData() at the end. + _suppressRefresh = true; + for (int objnum : _selectedObjnums) { + _viewport->moveObjectToLayer(objnum, layer); + } + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; + initializeData(); +} + +void CoordinatePointEditorDialogModel::setMultiTeam(int v) +{ + // Sentinel: a value below -1 is used by the dialog to mean "mixed; leave untouched". + if (v < -1) return; + if (v >= MAX_TVT_TEAMS) v = -1; + _multiTeam = v; + _multiTeamMixed = false; + for (int objnum : _selectedObjnums) { + auto* cp = getSelected(objnum); + if (cp != nullptr) cp->multi_team = v; + } + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +bool CoordinatePointEditorDialogModel::getVisibleInMission() const { return _visibleInMission; } + +int CoordinatePointEditorDialogModel::getVisibleInMissionState() const +{ + if (_visibleInMissionMixed) return Qt::PartiallyChecked; + return _visibleInMission ? Qt::Checked : Qt::Unchecked; +} + +void CoordinatePointEditorDialogModel::setVisibleInMission(bool v) +{ + _visibleInMission = v; + _visibleInMissionMixed = false; + for (int objnum : _selectedObjnums) { + auto* cp = getSelected(objnum); + if (cp != nullptr) cp->flags.set(CoordinatePoint::Flags::Visible_in_mission, v); + } + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +void CoordinatePointEditorDialogModel::selectCoordinatePointByObjnum(int objnum) +{ + if (objnum < 0) return; + + _suppressRefresh = true; + _editor->unmark_all(); + _editor->markObject(objnum); + _suppressRefresh = false; + initializeData(); +} + +void CoordinatePointEditorDialogModel::selectNextPoint() +{ + if (Coordinate_points.empty()) return; + + // Walk the list and find the entry after the currently-selected one. + int current = _selectedObjnums.empty() ? -1 : _selectedObjnums.front(); + int first_objnum = -1; + bool found_current = false; + for (const auto& cp : Coordinate_points) { + if (cp.objnum < 0) continue; + if (first_objnum < 0) first_objnum = cp.objnum; + if (found_current) { + selectCoordinatePointByObjnum(cp.objnum); + return; + } + if (cp.objnum == current) { + found_current = true; + } + } + // Either no current selection or current was the last one: wrap to first. + if (first_objnum >= 0) { + selectCoordinatePointByObjnum(first_objnum); + } +} + +void CoordinatePointEditorDialogModel::selectPreviousPoint() +{ + if (Coordinate_points.empty()) return; + + int current = _selectedObjnums.empty() ? -1 : _selectedObjnums.front(); + int prev_objnum = -1; + int last_objnum = -1; + for (const auto& cp : Coordinate_points) { + if (cp.objnum < 0) continue; + if (cp.objnum == current) { + if (prev_objnum >= 0) { + selectCoordinatePointByObjnum(prev_objnum); + return; + } + // Current is the first entry: wrap to last (computed after loop). + break; + } + prev_objnum = cp.objnum; + last_objnum = cp.objnum; + } + // Walk the rest to find the last live entry for wrap. + for (const auto& cp : Coordinate_points) { + if (cp.objnum >= 0) last_objnum = cp.objnum; + } + if (last_objnum >= 0) { + selectCoordinatePointByObjnum(last_objnum); + } +} + +void CoordinatePointEditorDialogModel::onSelectedObjectChanged(int) +{ + if (_suppressRefresh) return; + initializeData(); +} + +void CoordinatePointEditorDialogModel::onSelectedObjectMarkingChanged(int, bool) +{ + if (_suppressRefresh) return; + initializeData(); +} + +void CoordinatePointEditorDialogModel::onMissionChanged() +{ + if (_suppressRefresh) return; + initializeData(); +} + +} // namespace fso::fred::dialogs diff --git a/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.h b/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.h new file mode 100644 index 00000000000..7ed8d2c13ab --- /dev/null +++ b/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.h @@ -0,0 +1,119 @@ +#pragma once +#include "mission/dialogs/AbstractDialogModel.h" + +#include "coordinate_points/coordinate_point.h" + +namespace fso::fred::dialogs { + +class CoordinatePointEditorDialogModel : public AbstractDialogModel { + Q_OBJECT + +public: + CoordinatePointEditorDialogModel(QObject* parent, EditorViewport* viewport); + + bool apply() override; + void reject() override; + + bool hasValidSelection() const; + bool hasMultipleSelection() const; + static bool hasAnyCoordinatePointsInMission(); + int getSelectionCount() const; + + // Name (disabled in multi-select). Returns false on validation failure. + const SCP_string& getCurrentName() const; + bool setCurrentName(const SCP_string& name); + + // Category (free-form string). + const SCP_string& getCategory() const; + bool isCategoryMixed() const; + void setCategory(const SCP_string& category); + + // Color, per-channel. + int getColorR() const; + int getColorG() const; + int getColorB() const; + int getColorA() const; + void setColorR(int r); + void setColorG(int g); + void setColorB(int b); + void setColorA(int a); + bool isColorRMixed() const; + bool isColorGMixed() const; + bool isColorBMixed() const; + bool isColorAMixed() const; + bool hasAnyColorMixed() const; + + // Shape. + CoordinatePointShape getShape() const; + bool isShapeMixed() const; + void setShape(CoordinatePointShape shape); + + // Size scale (clamped to [SIZE_MIN, SIZE_MAX] on set). + float getSize() const; + bool isSizeMixed() const; + void setSize(float v); + + // Escort priority (0 = not on list; clamped to >= 0 on set). + int getEscortPriority() const; + bool isEscortPriorityMixed() const; + void setEscortPriority(int v); + + // Multiplayer team affiliation. -1 = visible to all teams (default). + // 0..MAX_TVT_TEAMS-1 = visible only to that team in multiplayer. + int getMultiTeam() const; + bool isMultiTeamMixed() const; + void setMultiTeam(int v); + static bool missionIsMultiTeam(); + + // Visible-in-mission flag (tristate when mixed). + bool getVisibleInMission() const; + int getVisibleInMissionState() const; // Qt::CheckState as int + void setVisibleInMission(bool v); + + // Layer assignment for the editor's view-layer system. Empty / "" handling is by + // the dialog; the model writes through to every selected point. + SCP_string getLayer() const; + void setLayer(const SCP_string& layer); + + void selectNextPoint(); + void selectPreviousPoint(); + +signals: + void coordinatePointMarkingChanged(); + +private slots: + void onSelectedObjectChanged(int); + void onSelectedObjectMarkingChanged(int, bool); + void onMissionChanged(); + +private: + void initializeData(); + void showErrorDialogNoCancel(const SCP_string& message); + bool validateName(const SCP_string& name); + void selectCoordinatePointByObjnum(int objnum); + mission_coordinate_point* getSelected(int objnum) const; + + SCP_vector _selectedObjnums; + + SCP_string _currentName; + SCP_string _category; + int _colorR = 255, _colorG = 255, _colorB = 255, _colorA = 255; + CoordinatePointShape _shape = CoordinatePointShape::Diamond; + float _size = 1.0f; + int _escortPriority = 0; + int _multiTeam = -1; + bool _visibleInMission = false; + + bool _categoryMixed = false; + bool _redMixed = false, _greenMixed = false, _blueMixed = false, _alphaMixed = false; + bool _shapeMixed = false; + bool _sizeMixed = false; + bool _escortPriorityMixed = false; + bool _multiTeamMixed = false; + bool _visibleInMissionMixed = false; + + bool _bypass_errors = false; + bool _suppressRefresh = false; +}; + +} // namespace fso::fred::dialogs diff --git a/qtfred/src/mission/dialogs/LayerManagerDialogModel.cpp b/qtfred/src/mission/dialogs/LayerManagerDialogModel.cpp index c6ee021a655..adda8dc1f79 100644 --- a/qtfred/src/mission/dialogs/LayerManagerDialogModel.cpp +++ b/qtfred/src/mission/dialogs/LayerManagerDialogModel.cpp @@ -70,6 +70,7 @@ bool LayerManagerDialogModel::getShowStarts() const { return _viewport->view bool LayerManagerDialogModel::getShowWaypoints() const { return _viewport->view.Show_waypoints; } bool LayerManagerDialogModel::getShowProps() const { return _viewport->view.Show_props; } bool LayerManagerDialogModel::getShowJumpNodes() const { return _viewport->view.Show_jump_nodes; } +bool LayerManagerDialogModel::getShowCoordinatePoints() const { return _viewport->view.Show_coordinate_points; } void LayerManagerDialogModel::setShowShips(bool value) { if (_viewport->view.Show_ships != value) { @@ -106,6 +107,13 @@ void LayerManagerDialogModel::setShowJumpNodes(bool value) { } } +void LayerManagerDialogModel::setShowCoordinatePoints(bool value) { + if (_viewport->view.Show_coordinate_points != value) { + _viewport->view.Show_coordinate_points = value; + _viewport->needsUpdate(); + } +} + // --- IFF team filters --- int LayerManagerDialogModel::getIffCount() { diff --git a/qtfred/src/mission/dialogs/LayerManagerDialogModel.h b/qtfred/src/mission/dialogs/LayerManagerDialogModel.h index 4409fd2e012..00ee22a590d 100644 --- a/qtfred/src/mission/dialogs/LayerManagerDialogModel.h +++ b/qtfred/src/mission/dialogs/LayerManagerDialogModel.h @@ -33,6 +33,8 @@ class LayerManagerDialogModel : public AbstractDialogModel { void setShowProps(bool value); bool getShowJumpNodes() const; void setShowJumpNodes(bool value); + bool getShowCoordinatePoints() const; + void setShowCoordinatePoints(bool value); // IFF team filters static int getIffCount(); diff --git a/qtfred/src/mission/dialogs/ObjectOrientEditorDialogModel.cpp b/qtfred/src/mission/dialogs/ObjectOrientEditorDialogModel.cpp index c14044f7e35..181bba0ddd2 100644 --- a/qtfred/src/mission/dialogs/ObjectOrientEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/ObjectOrientEditorDialogModel.cpp @@ -4,11 +4,21 @@ #include #include #include +#include #include #include namespace fso::fred::dialogs { +namespace { +// Object types that have no meaningful orientation in the editor; the dialog should never +// write to their orient field, even in multi-select scenarios. +bool object_type_supports_orientation(int type) +{ + return type != OBJ_WAYPOINT && type != OBJ_COORDINATE_POINT; +} +} // namespace + ObjectOrientEditorDialogModel::ObjectOrientEditorDialogModel(QObject* parent, EditorViewport* viewport) : AbstractDialogModel(parent, viewport) { @@ -56,6 +66,14 @@ void ObjectOrientEditorDialogModel::initializeData() if (Props[ptr->instance].has_value()) _pointToObjectList.emplace_back(ObjectEntry(Props[ptr->instance]->prop_name, OBJ_INDEX(ptr))); break; + case OBJ_COORDINATE_POINT: { + auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(ptr)); + if (cp != nullptr) + _pointToObjectList.emplace_back(ObjectEntry(cp->name, OBJ_INDEX(ptr))); + break; + } + case OBJ_POINT: + break; default: UNREACHABLE("Unknown object type %d in Object Orient Dialog!", ptr->type); // unknown object type } @@ -65,7 +83,8 @@ void ObjectOrientEditorDialogModel::initializeData() } type = Objects[_editor->currentObject].type; - if (_editor->getNumMarked() == 1 && (type == OBJ_WAYPOINT || type == OBJ_JUMP_NODE)) { + if (_editor->getNumMarked() == 1 && + (type == OBJ_WAYPOINT || type == OBJ_JUMP_NODE || type == OBJ_COORDINATE_POINT)) { _orientationEnabledForType = false; _selectedPointToObjectIndex = -1; } else { @@ -78,7 +97,8 @@ void ObjectOrientEditorDialogModel::initializeData() void ObjectOrientEditorDialogModel::updateObject(object* ptr) { - if (ptr->type != OBJ_WAYPOINT && _pointTo) { + // Waypoints and coordinate points carry no meaningful orientation; "Point To" is a no-op. + if (ptr->type != OBJ_WAYPOINT && ptr->type != OBJ_COORDINATE_POINT && _pointTo) { vec3d v; matrix m; @@ -192,12 +212,12 @@ bool ObjectOrientEditorDialogModel::apply() if (_pointTo) { updateObject(origin_objp); _editor->missionChanged(); - } else if (change_orient) { + } else if (change_orient && object_type_supports_orientation(origin_objp->type)) { origin_objp->orient = desired_orient; _editor->missionChanged(); } - if (origin_objp->type != OBJ_WAYPOINT) { + if (origin_objp->type != OBJ_WAYPOINT && origin_objp->type != OBJ_COORDINATE_POINT) { vm_transpose(&saved_orient); origin_rotation = saved_orient * origin_objp->orient; } @@ -219,7 +239,9 @@ bool ObjectOrientEditorDialogModel::apply() vm_vec_unrotate(&transformed_pos, &relative_pos, &origin_rotation); vm_vec_add(&ptr->pos, &transformed_pos, &origin_objp->pos); - ptr->orient = ptr->orient * origin_rotation; + if (object_type_supports_orientation(ptr->type)) { + ptr->orient = ptr->orient * origin_rotation; + } _editor->missionChanged(); } else { // Independent transform of each marked object @@ -230,7 +252,7 @@ bool ObjectOrientEditorDialogModel::apply() if (_pointTo) { updateObject(ptr); _editor->missionChanged(); - } else if (change_orient) { + } else if (change_orient && object_type_supports_orientation(ptr->type)) { ptr->orient = desired_orient; _editor->missionChanged(); } diff --git a/qtfred/src/mission/dialogs/SceneBrowserModel.cpp b/qtfred/src/mission/dialogs/SceneBrowserModel.cpp index 33e4ec58f39..766dc90d41d 100644 --- a/qtfred/src/mission/dialogs/SceneBrowserModel.cpp +++ b/qtfred/src/mission/dialogs/SceneBrowserModel.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -154,6 +155,22 @@ void SceneBrowserModel::buildTree() } if (!props.items.isEmpty()) layer.categories.push_back(props); + // --- Coordinate Points --- + BrowserCategory coordPoints; + coordPoints.name = "Coordinate Points"; + for (auto* ptr = GET_FIRST(&obj_used_list); ptr != END_OF_LIST(&obj_used_list); ptr = GET_NEXT(ptr)) { + if (ptr->type != OBJ_COORDINATE_POINT) continue; + if (_viewport->getObjectLayerName(OBJ_INDEX(ptr)) != layerName) continue; + auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(ptr)); + if (cp == nullptr) continue; + + BrowserObject obj; + obj.objNum = OBJ_INDEX(ptr); + obj.displayName = QString::fromStdString(cp->name); + coordPoints.items.push_back(obj); + } + if (!coordPoints.items.isEmpty()) layer.categories.push_back(coordPoints); + _tree.push_back(layer); } } diff --git a/qtfred/src/mission/object.cpp b/qtfred/src/mission/object.cpp index c7b58bc4e2f..5ced45c5a98 100644 --- a/qtfred/src/mission/object.cpp +++ b/qtfred/src/mission/object.cpp @@ -4,6 +4,7 @@ #include "object/waypoint.h" #include "object/objectdock.h" #include "ship/ship.h" +#include "coordinate_points/coordinate_point.h" #include "jumpnode/jumpnode.h" #include "prop/prop.h" @@ -78,6 +79,13 @@ const char* object_name(int obj) { return Props[idx]->prop_name; break; } + + case OBJ_COORDINATE_POINT: { + auto* cp = find_coordinate_point_by_objnum(obj); + if (cp != nullptr) + return cp->name.c_str(); + break; + } } return "*unknown*"; diff --git a/qtfred/src/ui/FredView.cpp b/qtfred/src/ui/FredView.cpp index 4cba59ffbeb..e631d7e30bc 100644 --- a/qtfred/src/ui/FredView.cpp +++ b/qtfred/src/ui/FredView.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -1088,6 +1089,8 @@ void FredView::onUpdateContextToolbar() { addBtn(tr("Edit Jump Node"), &FredView::on_actionJump_Nodes_triggered); } else if (effectiveType == OBJ_PROP) { addBtn(tr("Edit Prop"), &FredView::on_actionProps_triggered); + } else if (effectiveType == OBJ_COORDINATE_POINT) { + addBtn(tr("Edit Coordinate Point"),&FredView::on_actionCoordinate_Points_triggered); } if (anythingSelected) { @@ -1676,7 +1679,8 @@ void FredView::connectActionToViewSetting(QAction* option, std::vector* ve static bool canObjectBeAssignedLayer(int objType) { return (objType == OBJ_SHIP) || (objType == OBJ_START) || (objType == OBJ_PROP) || - (objType == OBJ_JUMP_NODE) || (objType == OBJ_WAYPOINT); + (objType == OBJ_JUMP_NODE) || (objType == OBJ_WAYPOINT) || + (objType == OBJ_COORDINATE_POINT); } void FredView::showContextMenu(int objNum, const QPoint& globalPos) { @@ -2428,6 +2432,11 @@ void FredView::on_actionWaypoint_Paths_triggered(bool) { editorDialog->setAttribute(Qt::WA_DeleteOnClose); editorDialog->show(); } +void FredView::on_actionCoordinate_Points_triggered(bool) { + auto editorDialog = new dialogs::CoordinatePointEditorDialog(this, _viewport); + editorDialog->setAttribute(Qt::WA_DeleteOnClose); + editorDialog->show(); +} void FredView::on_actionJump_Nodes_triggered(bool) { auto editorDialog = new dialogs::JumpNodeEditorDialog(this, _viewport); @@ -2601,6 +2610,11 @@ void FredView::handleObjectEditor(int objNum) { // If this is a waypoint, we need to show the waypoint editor on_actionWaypoint_Paths_triggered(false); } + } else if (Objects[objNum].type == OBJ_COORDINATE_POINT) { + fred->selectObject(objNum); + on_actionCoordinate_Points_triggered(false); + } else if (Objects[objNum].type == OBJ_POINT) { + return; } else { Assertion(false, "Unhandled object type %d!", Objects[objNum].type); } diff --git a/qtfred/src/ui/FredView.h b/qtfred/src/ui/FredView.h index fda7d73e8fa..58c52e75f3a 100644 --- a/qtfred/src/ui/FredView.h +++ b/qtfred/src/ui/FredView.h @@ -121,6 +121,7 @@ class FredView: public QMainWindow, public IDialogProvider { void on_actionBriefing_triggered(bool); void on_actionMission_Specs_triggered(bool); void on_actionWaypoint_Paths_triggered(bool); + void on_actionCoordinate_Points_triggered(bool); void on_actionJump_Nodes_triggered(bool); void on_actionObject_Orientation_triggered(bool); void on_actionShips_triggered(bool); diff --git a/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.cpp b/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.cpp new file mode 100644 index 00000000000..982b818d40e --- /dev/null +++ b/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.cpp @@ -0,0 +1,245 @@ +#include "ui/dialogs/CoordinatePointEditorDialog.h" +#include "ui/util/SignalBlockers.h" +#include "ui_CoordinatePointEditorDialog.h" + +#include +#include +#include + +namespace fso::fred::dialogs { + +CoordinatePointEditorDialog::CoordinatePointEditorDialog(FredView* parent, EditorViewport* viewport) : + QDialog(parent), + _viewport(viewport), + ui(new Ui::CoordinatePointEditorDialog()), + _model(new CoordinatePointEditorDialogModel(this, viewport)) +{ + this->setFocus(); + ui->setupUi(this); + + ui->nameEdit->setMaxLength(NAME_LENGTH - 1); + + // -1 is the "mixed selection" sentinel; rendered blank via specialValueText. + for (auto* sb : {ui->colorRSpinBox, ui->colorGSpinBox, ui->colorBSpinBox, ui->colorASpinBox}) { + sb->setMinimum(-1); + sb->setSpecialValueText(" "); + } + ui->escortPrioritySpinBox->setMinimum(-1); + ui->escortPrioritySpinBox->setSpecialValueText(" "); + + // Multiplayer team combo: "Any" + one entry per TVT team. Mixed-selection state is + // shown by setCurrentIndex(-1) (blank); selecting any real entry commits the value. + ui->multiTeamCombo->clear(); + ui->multiTeamCombo->addItem("Any team", -1); + for (const auto& team : Mission_event_teams_tvt) { + ui->multiTeamCombo->addItem(QString::fromStdString(team.first), team.second); + } + ui->sizeSpinBox->setMinimum(-0.01); // negative is the "mixed" sentinel + ui->sizeSpinBox->setMaximum(static_cast(COORDINATE_POINT_SIZE_MAX)); + ui->sizeSpinBox->setSingleStep(0.1); + ui->sizeSpinBox->setSpecialValueText(" "); + + // Populate the shape combo from the enum. + ui->shapeCombo->clear(); + for (int i = 0; i < static_cast(CoordinatePointShape::NUM_SHAPES); ++i) { + ui->shapeCombo->addItem(coordinate_point_shape_to_string(static_cast(i)), i); + } + + initializeUi(); + updateUi(); + + connect(_model.get(), &CoordinatePointEditorDialogModel::coordinatePointMarkingChanged, this, [this] { + initializeUi(); + updateUi(); + }); + + resize(QDialog::sizeHint()); +} + +CoordinatePointEditorDialog::~CoordinatePointEditorDialog() = default; + +void CoordinatePointEditorDialog::initializeUi() +{ + util::SignalBlockers blockers(this); + + ui->layerCombo->clear(); + for (const auto& name : _viewport->getLayerNames()) { + ui->layerCombo->addItem(QString::fromStdString(name), QString::fromStdString(name)); + } + + const bool enabled = _model->hasValidSelection(); + const bool hasAny = _model->hasAnyCoordinatePointsInMission(); + const bool multi = _model->hasMultipleSelection(); + + ui->nameEdit->setEnabled(enabled && !multi); + ui->categoryEdit->setEnabled(enabled); + ui->shapeCombo->setEnabled(enabled); + ui->sizeSpinBox->setEnabled(enabled); + ui->escortPrioritySpinBox->setEnabled(enabled); + // Team selector is greyed out when the mission isn't a team mission. + ui->multiTeamCombo->setEnabled(enabled && CoordinatePointEditorDialogModel::missionIsMultiTeam()); + ui->visibleInMissionCheck->setEnabled(enabled); + ui->layerCombo->setEnabled(enabled); + ui->colorRSpinBox->setEnabled(enabled); + ui->colorGSpinBox->setEnabled(enabled); + ui->colorBSpinBox->setEnabled(enabled); + ui->colorASpinBox->setEnabled(enabled); + ui->prevPointButton->setEnabled(hasAny); + ui->nextPointButton->setEnabled(hasAny); + + if (multi) { + setWindowTitle(QString("Edit %1 Coordinate Points").arg(_model->getSelectionCount())); + } else { + setWindowTitle("Coordinate Point Editor"); + } +} + +void CoordinatePointEditorDialog::updateUi() +{ + util::SignalBlockers blockers(this); + + ui->nameEdit->setText(QString::fromStdString(_model->getCurrentName())); + + if (_model->isCategoryMixed()) { + ui->categoryEdit->setPlaceholderText(""); + ui->categoryEdit->setText(""); + } else { + ui->categoryEdit->setPlaceholderText(""); + ui->categoryEdit->setText(QString::fromStdString(_model->getCategory())); + } + + if (_model->isShapeMixed()) { + ui->shapeCombo->setCurrentIndex(-1); + } else { + ui->shapeCombo->setCurrentIndex(static_cast(_model->getShape())); + } + + ui->sizeSpinBox->setValue(_model->isSizeMixed() ? -0.01 : static_cast(_model->getSize())); + ui->escortPrioritySpinBox->setValue(_model->isEscortPriorityMixed() ? -1 : _model->getEscortPriority()); + if (_model->isMultiTeamMixed()) { + ui->multiTeamCombo->setCurrentIndex(-1); // blank when values differ across selection + } else { + ui->multiTeamCombo->setCurrentIndex(ui->multiTeamCombo->findData(_model->getMultiTeam())); + } + + const int visState = _model->getVisibleInMissionState(); + ui->visibleInMissionCheck->setTristate(visState == Qt::PartiallyChecked); + ui->visibleInMissionCheck->setCheckState(static_cast(visState)); + + ui->layerCombo->setCurrentIndex(ui->layerCombo->findData(QString::fromStdString(_model->getLayer()))); + + ui->colorRSpinBox->setValue(_model->isColorRMixed() ? -1 : _model->getColorR()); + ui->colorGSpinBox->setValue(_model->isColorGMixed() ? -1 : _model->getColorG()); + ui->colorBSpinBox->setValue(_model->isColorBMixed() ? -1 : _model->getColorB()); + ui->colorASpinBox->setValue(_model->isColorAMixed() ? -1 : _model->getColorA()); + + updateColorSwatch(); +} + +void CoordinatePointEditorDialog::updateColorSwatch() +{ + if (_model->hasAnyColorMixed()) { + ui->colorSwatch->setText("?"); + ui->colorSwatch->setAlignment(Qt::AlignCenter); + ui->colorSwatch->setStyleSheet("background: #888; color: white;" + "border: 1px solid #444; border-radius: 3px;"); + return; + } + ui->colorSwatch->setText(""); + ui->colorSwatch->setStyleSheet(QString("background: rgba(%1,%2,%3,%4);" + "border: 1px solid #444; border-radius: 3px;") + .arg(_model->getColorR()) + .arg(_model->getColorG()) + .arg(_model->getColorB()) + .arg(_model->getColorA() / 255.0)); +} + +void CoordinatePointEditorDialog::on_prevPointButton_clicked() +{ + _model->selectPreviousPoint(); +} + +void CoordinatePointEditorDialog::on_nextPointButton_clicked() +{ + _model->selectNextPoint(); +} + +void CoordinatePointEditorDialog::on_nameEdit_editingFinished() +{ + if (!_model->setCurrentName(ui->nameEdit->text().toUtf8().constData())) { + util::SignalBlockers blockers(this); + ui->nameEdit->setText(QString::fromStdString(_model->getCurrentName())); + } +} + +void CoordinatePointEditorDialog::on_categoryEdit_editingFinished() +{ + _model->setCategory(ui->categoryEdit->text().toUtf8().constData()); + updateUi(); +} + +void CoordinatePointEditorDialog::on_shapeCombo_currentIndexChanged(int index) +{ + if (index < 0 || index >= static_cast(CoordinatePointShape::NUM_SHAPES)) + return; + _model->setShape(static_cast(index)); +} + +void CoordinatePointEditorDialog::on_sizeSpinBox_valueChanged(double value) +{ + _model->setSize(static_cast(value)); + updateUi(); +} + +void CoordinatePointEditorDialog::on_escortPrioritySpinBox_valueChanged(int value) +{ + _model->setEscortPriority(value); + updateUi(); +} + +void CoordinatePointEditorDialog::on_multiTeamCombo_currentIndexChanged(int index) +{ + if (index < 0) + return; + _model->setMultiTeam(ui->multiTeamCombo->itemData(index).toInt()); + updateUi(); +} + +void CoordinatePointEditorDialog::on_layerCombo_currentIndexChanged(int index) +{ + if (index < 0) + return; + _model->setLayer(ui->layerCombo->itemData(index).toString().toUtf8().constData()); +} + +void CoordinatePointEditorDialog::on_visibleInMissionCheck_clicked() +{ + _model->setVisibleInMission(ui->visibleInMissionCheck->isChecked()); + updateUi(); +} + +void CoordinatePointEditorDialog::on_colorRSpinBox_valueChanged(int value) +{ + _model->setColorR(value); + updateColorSwatch(); +} + +void CoordinatePointEditorDialog::on_colorGSpinBox_valueChanged(int value) +{ + _model->setColorG(value); + updateColorSwatch(); +} + +void CoordinatePointEditorDialog::on_colorBSpinBox_valueChanged(int value) +{ + _model->setColorB(value); + updateColorSwatch(); +} + +void CoordinatePointEditorDialog::on_colorASpinBox_valueChanged(int value) +{ + _model->setColorA(value); + updateColorSwatch(); +} + +} // namespace fso::fred::dialogs diff --git a/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.h b/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.h new file mode 100644 index 00000000000..6c1ad41ed03 --- /dev/null +++ b/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.h @@ -0,0 +1,44 @@ +#pragma once +#include +#include +#include + +namespace fso::fred::dialogs { + +namespace Ui { +class CoordinatePointEditorDialog; +} + +class CoordinatePointEditorDialog : public QDialog { + Q_OBJECT +public: + CoordinatePointEditorDialog(FredView* parent, EditorViewport* viewport); + ~CoordinatePointEditorDialog() override; + +private slots: + void on_prevPointButton_clicked(); + void on_nextPointButton_clicked(); + void on_nameEdit_editingFinished(); + void on_categoryEdit_editingFinished(); + void on_shapeCombo_currentIndexChanged(int index); + void on_sizeSpinBox_valueChanged(double value); + void on_escortPrioritySpinBox_valueChanged(int value); + void on_multiTeamCombo_currentIndexChanged(int index); + void on_layerCombo_currentIndexChanged(int index); + void on_visibleInMissionCheck_clicked(); + void on_colorRSpinBox_valueChanged(int value); + void on_colorGSpinBox_valueChanged(int value); + void on_colorBSpinBox_valueChanged(int value); + void on_colorASpinBox_valueChanged(int value); + +private: + EditorViewport* _viewport; + std::unique_ptr ui; + std::unique_ptr _model; + + void initializeUi(); + void updateUi(); + void updateColorSwatch(); +}; + +} // namespace fso::fred::dialogs diff --git a/qtfred/src/ui/dialogs/LayerManagerDialog.cpp b/qtfred/src/ui/dialogs/LayerManagerDialog.cpp index 8d1a9ca3138..715573f4464 100644 --- a/qtfred/src/ui/dialogs/LayerManagerDialog.cpp +++ b/qtfred/src/ui/dialogs/LayerManagerDialog.cpp @@ -62,11 +62,13 @@ void LayerManagerDialog::updateUi() { QSignalBlocker b3(ui->showWaypointsCheck); QSignalBlocker b4(ui->showPropsCheck); QSignalBlocker b5(ui->showJumpNodesCheck); + QSignalBlocker b6(ui->showCoordinatePointsCheck); ui->showShipsCheck->setChecked(_model->getShowShips()); ui->showStartsCheck->setChecked(_model->getShowStarts()); ui->showWaypointsCheck->setChecked(_model->getShowWaypoints()); ui->showPropsCheck->setChecked(_model->getShowProps()); ui->showJumpNodesCheck->setChecked(_model->getShowJumpNodes()); + ui->showCoordinatePointsCheck->setChecked(_model->getShowCoordinatePoints()); } // Sync IFF checkboxes @@ -147,5 +149,6 @@ void LayerManagerDialog::on_showStartsCheck_toggled(bool checked) { _model->s void LayerManagerDialog::on_showWaypointsCheck_toggled(bool checked) { _model->setShowWaypoints(checked); } void LayerManagerDialog::on_showPropsCheck_toggled(bool checked) { _model->setShowProps(checked); } void LayerManagerDialog::on_showJumpNodesCheck_toggled(bool checked) { _model->setShowJumpNodes(checked); } +void LayerManagerDialog::on_showCoordinatePointsCheck_toggled(bool checked) { _model->setShowCoordinatePoints(checked); } } // namespace fso::fred::dialogs diff --git a/qtfred/src/ui/dialogs/LayerManagerDialog.h b/qtfred/src/ui/dialogs/LayerManagerDialog.h index a65ae96d897..8f4579fba0c 100644 --- a/qtfred/src/ui/dialogs/LayerManagerDialog.h +++ b/qtfred/src/ui/dialogs/LayerManagerDialog.h @@ -31,6 +31,7 @@ private slots: void on_showWaypointsCheck_toggled(bool checked); void on_showPropsCheck_toggled(bool checked); void on_showJumpNodesCheck_toggled(bool checked); + void on_showCoordinatePointsCheck_toggled(bool checked); private: // NOLINT(readability-redundant-access-specifiers) void initializeUi(); diff --git a/qtfred/src/ui/util/ErrorChecker.cpp b/qtfred/src/ui/util/ErrorChecker.cpp index e1ab1e68bd0..a0d2d94bb7e 100644 --- a/qtfred/src/ui/util/ErrorChecker.cpp +++ b/qtfred/src/ui/util/ErrorChecker.cpp @@ -1,6 +1,7 @@ #include "ui/util/ErrorChecker.h" #include +#include #include #include #include @@ -346,6 +347,12 @@ int ErrorChecker::checkObjectList() { entry.name = buf; } else if (ptr->type == OBJ_JUMP_NODE || ptr->type == OBJ_PROP) { // nothing needed + } else if (ptr->type == OBJ_COORDINATE_POINT) { + auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(ptr)); + if (cp == nullptr) { + return internal_error("Coordinate point object has no entry in Coordinate_points"); + } + entry.name = cp->name; } else { return internal_error("An unknown object type (%d) was detected", ptr->type); } diff --git a/qtfred/ui/CoordinatePointEditorDialog.ui b/qtfred/ui/CoordinatePointEditorDialog.ui new file mode 100644 index 00000000000..a98cc1d2458 --- /dev/null +++ b/qtfred/ui/CoordinatePointEditorDialog.ui @@ -0,0 +1,262 @@ + + + fso::fred::dialogs::CoordinatePointEditorDialog + + + + 0 + 0 + 320 + 300 + + + + Coordinate Point Editor + + + + QLayout::SetFixedSize + + + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + &Prev + + + + + + + &Next + + + + + + + + + &Name + + + nameEdit + + + + + + + + + + &Multi Team + + + Multiplayer team filter. "Any team" (default) is visible to everyone; selecting a specific team makes the point visible only to that team in multiplayer. Disabled when the mission is not a team mission. + + + multiTeamCombo + + + + + + + + 0 + 0 + + + + Multiplayer team filter. "Any team" makes the point visible to everyone; selecting a team restricts it to that team only. + + + + + + + &Layer + + + layerCombo + + + + + + + + 0 + 0 + + + + + + + + &Category + + + categoryEdit + + + + + + + + + + &Shape + + + shapeCombo + + + + + + + + 0 + 0 + + + + + + + + S&ize + + + sizeSpinBox + + + + + + + 2 + + + + + + + &Escort Priority + + + escortPrioritySpinBox + + + + + + + 9999 + + + + + + + &Visible in mission (renders in-game, target-in-front works) + + + + + + + + + R: + + + + + + + 255 + + + + + + + G: + + + + + + + 255 + + + + + + + B: + + + + + + + 255 + + + + + + + A: + + + + + + + 255 + + + + + + + + 28 + 28 + + + + QFrame::Box + + + + + + + + + + + + + + + + + diff --git a/qtfred/ui/FredView.ui b/qtfred/ui/FredView.ui index 604edb9165d..7a4e782240e 100644 --- a/qtfred/ui/FredView.ui +++ b/qtfred/ui/FredView.ui @@ -195,6 +195,7 @@ + @@ -1057,6 +1058,11 @@ Shift+Y + + + Coor&dinate Points + + Mission &Events diff --git a/qtfred/ui/LayerManagerDialog.ui b/qtfred/ui/LayerManagerDialog.ui index e79c8f9c39a..d4a8267dbf9 100644 --- a/qtfred/ui/LayerManagerDialog.ui +++ b/qtfred/ui/LayerManagerDialog.ui @@ -106,6 +106,13 @@ + + + + Coordinate Points + + + From 78fae2cb0a306513b8ff63d897ffa6398d33aa44 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Tue, 12 May 2026 21:44:39 -0500 Subject: [PATCH 09/19] no light shafts --- code/coordinate_points/coordinate_point_render.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/code/coordinate_points/coordinate_point_render.cpp b/code/coordinate_points/coordinate_point_render.cpp index 7abe4c8978d..58f824c0fac 100644 --- a/code/coordinate_points/coordinate_point_render.cpp +++ b/code/coordinate_points/coordinate_point_render.cpp @@ -224,7 +224,10 @@ void draw_coordinate_point_shape(const mission_coordinate_point& cp, material mat; mat.set_color(cp.display_color); - mat.set_depth_mode(ZBUFFER_TYPE_FULL); + // Depth-test but DON'T write to depth: the lightshaft post-pass samples the depth texture + // to find occluders, and coordinate points are conceptually HUD overlays, not physical + // objects, so they shouldn't carve shadow shafts into the lightshaft pattern. + mat.set_depth_mode(ZBUFFER_TYPE_READ); mat.set_blend_mode(ALPHA_BLEND_ALPHA_BLEND_ALPHA); g3_render_primitives(&mat, tri_verts, def.tri_index_count, PRIM_TYPE_TRIS, false); From fe0015832f975a40025dac13a31dc8b565dfcb93 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Sat, 16 May 2026 10:35:50 -0500 Subject: [PATCH 10/19] some fixes --- code/coordinate_points/coordinate_point.cpp | 1 - code/mission/missionparse.cpp | 3 +- code/parse/sexp.h | 3 ++ .../CoordinatePointEditorDialogModel.cpp | 4 +-- .../dialogs/MissionStatsDialogModel.cpp | 17 ++++++++++ .../mission/dialogs/MissionStatsDialogModel.h | 1 + qtfred/src/ui/dialogs/MissionStatsDialog.cpp | 1 + qtfred/ui/MissionStatsDialog.ui | 34 +++++++++++++------ 8 files changed, 50 insertions(+), 14 deletions(-) diff --git a/code/coordinate_points/coordinate_point.cpp b/code/coordinate_points/coordinate_point.cpp index ca703dff6d2..2ad9fc5e321 100644 --- a/code/coordinate_points/coordinate_point.cpp +++ b/code/coordinate_points/coordinate_point.cpp @@ -127,7 +127,6 @@ void coordinate_point_delete(int objnum) { for (auto it = Coordinate_points.begin(); it != Coordinate_points.end(); ++it) { if (it->objnum == objnum) { - it->objnum = -1; // mark detached so destructor / cleanup doesn't double-free Coordinate_points.erase(it); return; } diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index 0efd8a667df..dc6b3f476a3 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -5472,8 +5472,9 @@ void parse_coordinate_point(mission* /*pm*/) } if (optional_string("+Color:")) { + // rgba is pre-initialized so a three-value list (R G B) still leaves alpha at 255; + // stuff_int_list parses up to 4 ints and leaves trailing entries untouched. int rgba[4] = { 255, 255, 255, 255 }; - // Three-value form (R G B) is accepted for backwards compatibility; alpha defaults to 255. stuff_int_list(rgba, 4, ParseLookupType::RAW_INTEGER_TYPE); for (int& c : rgba) { CLAMP(c, 0, 255); diff --git a/code/parse/sexp.h b/code/parse/sexp.h index 0007d57a057..46d0456b16b 100644 --- a/code/parse/sexp.h +++ b/code/parse/sexp.h @@ -1623,6 +1623,9 @@ enum class oswpt_type : uint8_t PARSE_OBJECT, // a "ship" that hasn't arrived yet EXITED, WING_NOT_PRESENT, // a wing that hasn't arrived yet or is between waves + // TODO: SEXP support pending sexp_tree_refactor branch merge — the OSWPT serialization paths + // and Lua getType/get handlers recognize this value, but no SEXP parser code constructs an + // OSWPT of this type yet. COORDINATE_POINT // a named point in space defined per-mission }; diff --git a/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.cpp b/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.cpp index 3de5ec5f3a1..50a2857b559 100644 --- a/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.cpp @@ -361,8 +361,8 @@ bool CoordinatePointEditorDialogModel::missionIsMultiTeam() SCP_string CoordinatePointEditorDialogModel::getLayer() const { - // "" sentinel when selected points span different layers; the dialog renders that - // as a blank combo (currentIndex = -1). + // Returns empty string when selected points span different layers; the dialog's + // findData("") then yields -1 and the combo renders blank. SCP_string result; bool first = true; for (int objnum : _selectedObjnums) { diff --git a/qtfred/src/mission/dialogs/MissionStatsDialogModel.cpp b/qtfred/src/mission/dialogs/MissionStatsDialogModel.cpp index 7b8d7eb53df..08ed632c041 100644 --- a/qtfred/src/mission/dialogs/MissionStatsDialogModel.cpp +++ b/qtfred/src/mission/dialogs/MissionStatsDialogModel.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -76,6 +77,16 @@ int MissionStatsDialogModel::getJumpNodeCount() return static_cast(Jump_nodes.size()); } +int MissionStatsDialogModel::getCoordinatePointCount() +{ + int count = 0; + for (const auto& cp : Coordinate_points) { + if (cp.objnum >= 0) + ++count; + } + return count; +} + int MissionStatsDialogModel::getMessageCount() { return Num_messages - Num_builtin_messages; @@ -151,6 +162,12 @@ SCP_vector MissionStatsDialogModel::getEsc } } + for (const auto& cp : Coordinate_points) { + if (cp.objnum >= 0 && cp.escort_priority > 0) { + result.push_back({ cp.name, cp.escort_priority }); + } + } + return result; } diff --git a/qtfred/src/mission/dialogs/MissionStatsDialogModel.h b/qtfred/src/mission/dialogs/MissionStatsDialogModel.h index 84460aa1f69..0932f26b7fb 100644 --- a/qtfred/src/mission/dialogs/MissionStatsDialogModel.h +++ b/qtfred/src/mission/dialogs/MissionStatsDialogModel.h @@ -38,6 +38,7 @@ class MissionStatsDialogModel : public AbstractDialogModel { static int getWingCount(); static int getWaypointPathCount(); static int getJumpNodeCount(); + static int getCoordinatePointCount(); static int getMessageCount(); static int getEventCount(); static int getGoalCount(); diff --git a/qtfred/src/ui/dialogs/MissionStatsDialog.cpp b/qtfred/src/ui/dialogs/MissionStatsDialog.cpp index d83bf3e64f5..d9f59b72a0a 100644 --- a/qtfred/src/ui/dialogs/MissionStatsDialog.cpp +++ b/qtfred/src/ui/dialogs/MissionStatsDialog.cpp @@ -52,6 +52,7 @@ void MissionStatsDialog::populateSummaryTab() ui->labelWings->setText(QString::number(_model->getWingCount())); ui->labelWaypoints->setText(QString::number(_model->getWaypointPathCount())); ui->labelJumpNodes->setText(QString::number(_model->getJumpNodeCount())); + ui->labelCoordinatePoints->setText(QString::number(_model->getCoordinatePointCount())); ui->labelMessages->setText(QString::number(_model->getMessageCount())); ui->labelEvents->setText(QString::number(_model->getEventCount())); ui->labelGoals->setText(QString::number(_model->getGoalCount())); diff --git a/qtfred/ui/MissionStatsDialog.ui b/qtfred/ui/MissionStatsDialog.ui index 7c2374aee25..54aa0d1901f 100644 --- a/qtfred/ui/MissionStatsDialog.ui +++ b/qtfred/ui/MissionStatsDialog.ui @@ -144,69 +144,83 @@ + + + Coordinate Points: + + + + + + + 0 + + + + Messages: - + 0 - + Events: - + 0 - + Goals: - + 0 - + Variables: - + 0 - + Containers: - + 0 @@ -273,7 +287,7 @@ - Ship + Name From 85bc60ff90407c4812bc4645203f6a29987c4442 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Fri, 5 Jun 2026 10:23:12 -0500 Subject: [PATCH 11/19] unreachable --- code/hud/hudescort.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/code/hud/hudescort.cpp b/code/hud/hudescort.cpp index d52213c926d..76c0c421c20 100644 --- a/code/hud/hudescort.cpp +++ b/code/hud/hudescort.cpp @@ -637,8 +637,6 @@ static bool escort_compare(const escort_info &escort1, const escort_info &escort const char* name2 = get_sort_name(escort2); return (stricmp(name1, name2) > 0); } - - return false; } // create complete priority sorted escort list for all active ships From 6573f5c5ec70c0c462469713c12fafdbeb3a27f3 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Fri, 5 Jun 2026 12:04:37 -0500 Subject: [PATCH 12/19] better shape options --- code/coordinate_points/coordinate_point.cpp | 71 ++-- code/coordinate_points/coordinate_point.h | 45 ++- .../coordinate_point_render.cpp | 305 ++++++++---------- code/coordinate_points/coordinate_shapes.cpp | 145 +++++++++ code/coordinate_points/coordinate_shapes.h | 25 ++ .../data/tables/coordinate_points.tbl | 23 ++ code/hud/hudbrackets.cpp | 4 +- code/hud/hudtargetbox.cpp | 6 +- code/mission/missionparse.cpp | 46 ++- code/missioneditor/missionsave.cpp | 62 +++- code/scripting/api/objs/coordinatepoint.cpp | 10 +- code/source_groups.cmake | 3 + fred2/management.cpp | 2 + freespace2/freespace.cpp | 6 +- .../dialogs/CoordinatePointEditorDialog.html | 20 +- qtfred/src/mission/Editor.cpp | 21 +- qtfred/src/mission/FredRenderer.cpp | 32 +- .../CoordinatePointEditorDialogModel.cpp | 175 ++++++++-- .../CoordinatePointEditorDialogModel.h | 56 +++- qtfred/src/mission/management.cpp | 4 + .../dialogs/CoordinatePointEditorDialog.cpp | 129 ++++++-- .../ui/dialogs/CoordinatePointEditorDialog.h | 6 +- qtfred/ui/CoordinatePointEditorDialog.ui | 131 +++++++- 23 files changed, 989 insertions(+), 338 deletions(-) create mode 100644 code/coordinate_points/coordinate_shapes.cpp create mode 100644 code/coordinate_points/coordinate_shapes.h create mode 100644 code/def_files/data/tables/coordinate_points.tbl diff --git a/code/coordinate_points/coordinate_point.cpp b/code/coordinate_points/coordinate_point.cpp index 2ad9fc5e321..bc32bb62f48 100644 --- a/code/coordinate_points/coordinate_point.cpp +++ b/code/coordinate_points/coordinate_point.cpp @@ -1,5 +1,6 @@ #include "coordinate_points/coordinate_point.h" +#include "coordinate_points/coordinate_shapes.h" #include "object/object.h" #include "math/vecmat.h" #include "parse/parselo.h" @@ -9,41 +10,6 @@ SCP_vector Parse_coordinate_points; static constexpr float COORDINATE_POINT_DEFAULT_RADIUS = 1.0f; -static const char* Coordinate_point_shape_names[] = { - "Triangle", - "Square", - "Diamond", - "Pentagon", - "Hexagon", - "Cross", - "Star", -}; - -static_assert(sizeof(Coordinate_point_shape_names) / sizeof(Coordinate_point_shape_names[0]) - == static_cast(CoordinatePointShape::NUM_SHAPES), - "Coordinate_point_shape_names is out of sync with CoordinatePointShape"); - -const char* coordinate_point_shape_to_string(CoordinatePointShape s) -{ - auto idx = static_cast(s); - if (idx >= static_cast(CoordinatePointShape::NUM_SHAPES)) { - return Coordinate_point_shape_names[0]; - } - return Coordinate_point_shape_names[idx]; -} - -CoordinatePointShape coordinate_point_shape_from_string(const char* s) -{ - if (s != nullptr) { - for (size_t i = 0; i < static_cast(CoordinatePointShape::NUM_SHAPES); ++i) { - if (!stricmp(Coordinate_point_shape_names[i], s)) { - return static_cast(i); - } - } - } - return CoordinatePointShape::Diamond; -} - mission_coordinate_point::mission_coordinate_point() { gr_init_alphacolor(&display_color, 255, 255, 255, 255); @@ -173,14 +139,33 @@ void post_process_mission_coordinate_points() continue; } - cp->category = parsed.category; - cp->display_color = parsed.display_color; - cp->shape = parsed.shape; - cp->size_scale = parsed.size_scale; - cp->escort_priority = parsed.escort_priority; - cp->multi_team = parsed.multi_team; - cp->flags = parsed.flags; - cp->fred_layer = parsed.fred_layer; + cp->group = parsed.group; + cp->display_color = parsed.display_color; + cp->shape_kind = parsed.shape_kind; + cp->shape_sides = parsed.shape_sides; + cp->shape_points = parsed.shape_points; + cp->shape_inner_radius = parsed.shape_inner_radius; + cp->shape_angle_deg = parsed.shape_angle_deg; + cp->size_scale = parsed.size_scale; + cp->escort_priority = parsed.escort_priority; + cp->multi_team = parsed.multi_team; + cp->flags = parsed.flags; + cp->fred_layer = parsed.fred_layer; + + // Resolve a tabled-shape name to an index. If the name doesn't match anything in the + // registry (default table + TBMs), fall back to NGon(3) with a warning so the mission + // still loads cleanly. + if (cp->shape_kind == CoordinatePointShapeKind::Tabled) { + cp->shape_table_index = find_coordinate_shape_index_by_name(parsed.shape_table_name.c_str()); + if (cp->shape_table_index < 0) { + Warning(LOCATION, + "Coordinate point '%s' references unknown shape '%s'; falling back to NGon(3).", + cp->name.c_str(), + parsed.shape_table_name.c_str()); + cp->shape_kind = CoordinatePointShapeKind::NGon; + cp->shape_sides = 3; + } + } } Parse_coordinate_points.clear(); diff --git a/code/coordinate_points/coordinate_point.h b/code/coordinate_points/coordinate_point.h index dd474e83021..b2e4be9b9e5 100644 --- a/code/coordinate_points/coordinate_point.h +++ b/code/coordinate_points/coordinate_point.h @@ -6,29 +6,39 @@ struct vec3d; -enum class CoordinatePointShape { - Triangle = 0, - Square, - Diamond, - Pentagon, - Hexagon, - Cross, +// What kind of 2D shape this coordinate point draws as. NGon and Star are procedural primitives +// driven by parameter fields below. Tabled refers to a custom vertex+triangle definition loaded +// from coordinate_points.tbl / *-cps.tbm. +enum class CoordinatePointShapeKind : int { + NGon = 0, Star, - NUM_SHAPES + Tabled, }; constexpr float COORDINATE_POINT_SIZE_MIN = 0.25f; constexpr float COORDINATE_POINT_SIZE_MAX = 8.0f; -const char* coordinate_point_shape_to_string(CoordinatePointShape s); -CoordinatePointShape coordinate_point_shape_from_string(const char* s); // returns Diamond on miss +constexpr int NGON_SIDES_MIN = 3; +constexpr int NGON_SIDES_MAX = 32; +constexpr int STAR_POINTS_MIN = 3; +constexpr int STAR_POINTS_MAX = 32; +constexpr float STAR_INNER_MIN = 0.05f; +constexpr float STAR_INNER_MAX = 0.95f; +constexpr float STAR_INNER_DEFAULT = 0.382f; // classic 5-point arm angle struct mission_coordinate_point { SCP_string name; - SCP_string category; + SCP_string group; color display_color; - CoordinatePointShape shape = CoordinatePointShape::Diamond; + + CoordinatePointShapeKind shape_kind = CoordinatePointShapeKind::NGon; + int shape_sides = 4; // NGon: clamp [NGON_SIDES_MIN, NGON_SIDES_MAX] + int shape_points = 5; // Star: clamp [STAR_POINTS_MIN, STAR_POINTS_MAX] + float shape_inner_radius = STAR_INNER_DEFAULT; // Star: clamp [STAR_INNER_MIN, STAR_INNER_MAX] + int shape_table_index = -1; // Tabled: index into Coordinate_shapes; -1 = unset + float shape_angle_deg = 0.0f; // all: extra rotation around local Z + float size_scale = 1.0f; int escort_priority = 0; // 0 = not on escort list; >0 = on the list (and sort key) int multi_team = -1; // -1 = visible to all; otherwise TVT team index (0..MAX_TVT_TEAMS-1) @@ -44,10 +54,17 @@ extern SCP_list Coordinate_points; struct parsed_coordinate_point { SCP_string name; - SCP_string category; + SCP_string group; vec3d position; color display_color; - CoordinatePointShape shape = CoordinatePointShape::Diamond; + + CoordinatePointShapeKind shape_kind = CoordinatePointShapeKind::NGon; + int shape_sides = 4; + int shape_points = 5; + float shape_inner_radius = STAR_INNER_DEFAULT; + SCP_string shape_table_name; // resolved to shape_table_index at post-process time + float shape_angle_deg = 0.0f; + float size_scale = 1.0f; int escort_priority = 0; int multi_team = -1; diff --git a/code/coordinate_points/coordinate_point_render.cpp b/code/coordinate_points/coordinate_point_render.cpp index 58f824c0fac..90e7705eb24 100644 --- a/code/coordinate_points/coordinate_point_render.cpp +++ b/code/coordinate_points/coordinate_point_render.cpp @@ -1,6 +1,7 @@ #include "coordinate_points/coordinate_point_render.h" #include "coordinate_points/coordinate_point.h" +#include "coordinate_points/coordinate_shapes.h" #include "graphics/material.h" #include "math/floating.h" #include "math/vecmat.h" @@ -8,164 +9,130 @@ #include "object/object.h" #include "render/3d.h" -namespace { +#include -// Unit-radius 2D vertex tables per shape, then a triangle-index table that triangulates each -// shape into a TRIS primitive list (each consecutive index triple is one triangle, CCW). +namespace { struct vec2f { float x, y; }; -// --- Vertex tables --- - -const vec2f Tri_verts[] = { - { 0.0f, 1.0f}, - { -0.866f, -0.5f}, - { 0.866f, -0.5f}, -}; - -const vec2f Sq_verts[] = { - { -1.0f, 1.0f}, - { 1.0f, 1.0f}, - { 1.0f, -1.0f}, - { -1.0f, -1.0f}, -}; - -const vec2f Dia_verts[] = { - { 0.0f, 1.0f}, - { 1.0f, 0.0f}, - { 0.0f, -1.0f}, - { -1.0f, 0.0f}, -}; - -const vec2f Pent_verts[] = { - { 0.0f, 1.0f }, - { 0.951f, 0.309f }, - { 0.588f, -0.809f }, - { -0.588f, -0.809f }, - { -0.951f, 0.309f }, -}; - -const vec2f Hex_verts[] = { - { -0.5f, 0.866f}, - { 0.5f, 0.866f}, - { 1.0f, 0.0f }, - { 0.5f, -0.866f}, - { -0.5f, -0.866f}, - { -1.0f, 0.0f }, -}; - -const vec2f Cross_verts[] = { - { -0.33f, 1.0f }, // 0: top-left of vertical bar - { 0.33f, 1.0f }, // 1: top-right of vertical bar - { 0.33f, 0.33f}, // 2 - { 1.0f, 0.33f}, // 3: top-right of horizontal bar - { 1.0f, -0.33f}, // 4: bottom-right of horizontal bar - { 0.33f, -0.33f}, // 5 - { 0.33f, -1.0f }, // 6: bottom-right of vertical bar - { -0.33f, -1.0f }, // 7: bottom-left of vertical bar - { -0.33f, -0.33f}, // 8 - { -1.0f, -0.33f}, // 9: bottom-left of horizontal bar - { -1.0f, 0.33f}, // 10: top-left of horizontal bar - { -0.33f, 0.33f}, // 11 -}; - -// 5-point star: alternating outer (radius 1) and inner (radius ≈ 0.382) points, starting at -// the top and going counterclockwise. Inner radius gives the classic 36° star arm angle. -const vec2f Star_verts[] = { - { 0.000f, 1.000f}, // 0: outer (top) - { -0.225f, 0.309f}, // 1: inner (top-left) - { -0.951f, 0.309f}, // 2: outer (top-left) - { -0.363f, -0.118f}, // 3: inner (lower-left) - { -0.588f, -0.809f}, // 4: outer (bottom-left) - { 0.000f, -0.382f}, // 5: inner (bottom) - { 0.588f, -0.809f}, // 6: outer (bottom-right) - { 0.363f, -0.118f}, // 7: inner (lower-right) - { 0.951f, 0.309f}, // 8: outer (top-right) - { 0.225f, 0.309f}, // 9: inner (top-right) -}; - -// --- Triangle index tables (each triple = one CCW triangle) --- - -const int Tri_tris[] = { - 0, 1, 2, -}; - -const int Sq_tris[] = { - 0, 3, 2, - 0, 2, 1, -}; - -const int Dia_tris[] = { - 0, 3, 2, - 0, 2, 1, -}; - -const int Pent_tris[] = { - 0, 4, 3, - 0, 3, 2, - 0, 2, 1, -}; - -const int Hex_tris[] = { - 0, 5, 4, - 0, 4, 3, - 0, 3, 2, - 0, 2, 1, -}; +// Cap on locally-built 2D vertex/triangle buffers. NGon caps out at NGON_SIDES_MAX (32), Star +// caps at 2 * STAR_POINTS_MAX (64), and tabled shapes are bounded by author hand. 128 / 384 +// leaves plenty of headroom. +constexpr int MAX_LOCAL_VERTS = 128; +constexpr int MAX_LOCAL_TRIS = MAX_LOCAL_VERTS * 3; -// Cross: two rectangles, each split into two triangles. Indices reference Cross_verts above. -const int Cross_tris[] = { - // Vertical bar (corners 0,1,6,7) - 0, 7, 6, - 0, 6, 1, - // Horizontal bar (corners 10,3,4,9) - 10, 9, 4, - 10, 4, 3, -}; +constexpr float PI_F = 3.14159265358979323846f; -// Star: 5 arm-triangles (outer point + its two neighboring inner points) plus the central -// inner pentagon fanned from inner vertex 1. Indices reference Star_verts above. -const int Star_tris[] = { - // 5 arms - 9, 0, 1, // top arm - 1, 2, 3, // top-left arm - 3, 4, 5, // bottom-left arm - 5, 6, 7, // bottom-right arm - 7, 8, 9, // top-right arm - // Inner pentagon, fanned from vertex 1 - 1, 3, 5, - 1, 5, 7, - 1, 7, 9, -}; +// Generate a regular N-gon centered on (0,0), unit radius, first vertex at the top +// (i.e. starting angle = π/2). CCW winding. Triangulated as a fan from vertex 0. +void build_ngon(int sides, vec2f* out_verts, int& out_nv, int* out_tris, int& out_ntri_idx) +{ + if (sides < NGON_SIDES_MIN) sides = NGON_SIDES_MIN; + if (sides > NGON_SIDES_MAX) sides = NGON_SIDES_MAX; + + const float start = PI_F * 0.5f; // top + const float step = 2.0f * PI_F / static_cast(sides); + for (int i = 0; i < sides; ++i) { + // CCW means angles increase as i increases (standard math convention with +Y up). + const float a = start + step * static_cast(i); + out_verts[i].x = std::cos(a); + out_verts[i].y = std::sin(a); + } + out_nv = sides; + + // Fan from vertex 0: triangles (0, i, i+1) for i in [1, sides-2]. + int n = 0; + for (int i = 1; i < sides - 1; ++i) { + out_tris[n++] = 0; + out_tris[n++] = i; + out_tris[n++] = i + 1; + } + out_ntri_idx = n; +} -struct ShapeDef { - const vec2f* verts; - int vert_count; - const int* tris; - int tri_index_count; // number of indices, always a multiple of 3 -}; +// Generate an N-pointed star, unit outer radius, first outer point at the top. Alternates outer +// (1.0) and inner (inner_r) radius. Triangulated as N arm triangles (outer, inner_prev, inner_next) +// plus an inner-polygon fan from inner vertex 1. +void build_star(int points, float inner_r, vec2f* out_verts, int& out_nv, int* out_tris, int& out_ntri_idx) +{ + if (points < STAR_POINTS_MIN) points = STAR_POINTS_MIN; + if (points > STAR_POINTS_MAX) points = STAR_POINTS_MAX; + if (inner_r < STAR_INNER_MIN) inner_r = STAR_INNER_MIN; + if (inner_r > STAR_INNER_MAX) inner_r = STAR_INNER_MAX; + + const int nv = points * 2; + const float start = PI_F * 0.5f; // first outer vert at top + const float step = PI_F / static_cast(points); // half-step between outer & inner + for (int i = 0; i < nv; ++i) { + const float a = start + step * static_cast(i); + const float r = (i & 1) ? inner_r : 1.0f; // even=outer, odd=inner + out_verts[i].x = std::cos(a) * r; + out_verts[i].y = std::sin(a) * r; + } + out_nv = nv; + + int n = 0; + // Arms: each outer vertex paired with its two neighbouring inner vertices. + // Outer at index 2k (k = 0..points-1); neighbours are inner at indices 2k-1 (mod nv) and 2k+1. + for (int k = 0; k < points; ++k) { + const int outer = 2 * k; + const int prev_inner = (outer - 1 + nv) % nv; + const int next_inner = (outer + 1) % nv; + out_tris[n++] = prev_inner; + out_tris[n++] = outer; + out_tris[n++] = next_inner; + } + // Inner polygon: vertices at odd indices 1, 3, 5, ..., nv-1. Fan from vertex 1. + for (int k = 1; k + 1 < points; ++k) { + out_tris[n++] = 1; + out_tris[n++] = 1 + 2 * k; + out_tris[n++] = 1 + 2 * (k + 1); + } + out_ntri_idx = n; +} -const ShapeDef Shape_defs[] = { - { Tri_verts, static_cast(sizeof(Tri_verts) / sizeof(vec2f)), - Tri_tris, static_cast(sizeof(Tri_tris) / sizeof(int)) }, - { Sq_verts, static_cast(sizeof(Sq_verts) / sizeof(vec2f)), - Sq_tris, static_cast(sizeof(Sq_tris) / sizeof(int)) }, - { Dia_verts, static_cast(sizeof(Dia_verts) / sizeof(vec2f)), - Dia_tris, static_cast(sizeof(Dia_tris) / sizeof(int)) }, - { Pent_verts, static_cast(sizeof(Pent_verts) / sizeof(vec2f)), - Pent_tris, static_cast(sizeof(Pent_tris) / sizeof(int)) }, - { Hex_verts, static_cast(sizeof(Hex_verts) / sizeof(vec2f)), - Hex_tris, static_cast(sizeof(Hex_tris) / sizeof(int)) }, - { Cross_verts, static_cast(sizeof(Cross_verts) / sizeof(vec2f)), - Cross_tris, static_cast(sizeof(Cross_tris) / sizeof(int)) }, - { Star_verts, static_cast(sizeof(Star_verts) / sizeof(vec2f)), - Star_tris, static_cast(sizeof(Star_tris) / sizeof(int)) }, -}; +// Copy a tabled shape into the local buffers. Caller must have validated def has room. +bool copy_tabled(const coordinate_shape_def& def, vec2f* out_verts, int& out_nv, int* out_tris, int& out_ntri_idx) +{ + if (static_cast(def.verts.size()) > MAX_LOCAL_VERTS || + static_cast(def.tri_indices.size()) > MAX_LOCAL_TRIS) { + return false; + } + out_nv = static_cast(def.verts.size()); + for (int i = 0; i < out_nv; ++i) { + out_verts[i].x = def.verts[i].x; + out_verts[i].y = def.verts[i].y; + } + out_ntri_idx = static_cast(def.tri_indices.size()); + for (int i = 0; i < out_ntri_idx; ++i) { + out_tris[i] = def.tri_indices[i]; + } + return true; +} -static_assert(sizeof(Shape_defs) / sizeof(ShapeDef) == static_cast(CoordinatePointShape::NUM_SHAPES), - "Shape_defs is out of sync with CoordinatePointShape"); +// Build the shape's 2D verts + triangle indices for the given coord point. Returns false if +// the shape can't be resolved (out-of-range tabled index, oversized table entry). +bool build_shape_local(const mission_coordinate_point& cp, vec2f* verts, int& nv, int* tris, int& nti) +{ + switch (cp.shape_kind) { + case CoordinatePointShapeKind::NGon: + build_ngon(cp.shape_sides, verts, nv, tris, nti); + return true; + case CoordinatePointShapeKind::Star: + build_star(cp.shape_points, cp.shape_inner_radius, verts, nv, tris, nti); + return true; + case CoordinatePointShapeKind::Tabled: { + const int idx = cp.shape_table_index; + if (idx < 0 || idx >= static_cast(Coordinate_shapes.size())) { + return false; + } + return copy_tabled(Coordinate_shapes[idx], verts, nv, tris, nti); + } + } + return false; +} } // anonymous namespace @@ -192,34 +159,44 @@ void draw_coordinate_point_shape(const mission_coordinate_point& cp, return; } - const auto shape_idx = static_cast(cp.shape); - if (shape_idx >= sizeof(Shape_defs) / sizeof(ShapeDef)) { + vec2f local_verts[MAX_LOCAL_VERTS]; + int local_tris[MAX_LOCAL_TRIS]; + int nv = 0, nti = 0; + if (!build_shape_local(cp, local_verts, nv, local_tris, nti)) { return; } - const ShapeDef& def = Shape_defs[shape_idx]; + // Apply the per-instance rotation (degrees → radians) in 2D before billboarding. + if (cp.shape_angle_deg != 0.0f) { + const float a = cp.shape_angle_deg * (PI_F / 180.0f); + const float ca = std::cos(a); + const float sa = std::sin(a); + for (int i = 0; i < nv; ++i) { + const float x = local_verts[i].x; + const float y = local_verts[i].y; + local_verts[i].x = ca * x - sa * y; + local_verts[i].y = sa * x + ca * y; + } + } + const vec3d& pos = Objects[cp.objnum].pos; const float radius = get_coordinate_point_world_radius(cp, *camera_eye); const vec3d& rvec = camera_orient->vec.rvec; const vec3d& uvec = camera_orient->vec.uvec; - // Build the camera-facing world-space vertices once, then expand the triangle index table - // into a flat vertex array for PRIM_TYPE_TRIS. - vec3d world_verts[32]; - Assertion(def.vert_count <= static_cast(sizeof(world_verts) / sizeof(world_verts[0])), - "Coordinate-point shape vertex table is larger than the local buffer"); - for (int i = 0; i < def.vert_count; ++i) { + // Build camera-facing world-space vertices, then expand the triangle index list into a flat + // vertex array for PRIM_TYPE_TRIS. + vec3d world_verts[MAX_LOCAL_VERTS]; + for (int i = 0; i < nv; ++i) { world_verts[i] = pos; - vm_vec_scale_add2(&world_verts[i], &rvec, def.verts[i].x * radius); - vm_vec_scale_add2(&world_verts[i], &uvec, def.verts[i].y * radius); + vm_vec_scale_add2(&world_verts[i], &rvec, local_verts[i].x * radius); + vm_vec_scale_add2(&world_verts[i], &uvec, local_verts[i].y * radius); } - vertex tri_verts[64]; - Assertion(def.tri_index_count <= static_cast(sizeof(tri_verts) / sizeof(tri_verts[0])), - "Coordinate-point shape triangle list is larger than the local buffer"); - for (int i = 0; i < def.tri_index_count; ++i) { - g3_transfer_vertex(&tri_verts[i], &world_verts[def.tris[i]]); + vertex tri_verts[MAX_LOCAL_TRIS]; + for (int i = 0; i < nti; ++i) { + g3_transfer_vertex(&tri_verts[i], &world_verts[local_tris[i]]); } material mat; @@ -230,7 +207,7 @@ void draw_coordinate_point_shape(const mission_coordinate_point& cp, mat.set_depth_mode(ZBUFFER_TYPE_READ); mat.set_blend_mode(ALPHA_BLEND_ALPHA_BLEND_ALPHA); - g3_render_primitives(&mat, tri_verts, def.tri_index_count, PRIM_TYPE_TRIS, false); + g3_render_primitives(&mat, tri_verts, nti, PRIM_TYPE_TRIS, false); } void coordinate_points_render_all_in_mission() diff --git a/code/coordinate_points/coordinate_shapes.cpp b/code/coordinate_points/coordinate_shapes.cpp new file mode 100644 index 00000000000..d2f7083d3a5 --- /dev/null +++ b/code/coordinate_points/coordinate_shapes.cpp @@ -0,0 +1,145 @@ +#include "coordinate_points/coordinate_shapes.h" + +#include "cfile/cfile.h" +#include "def_files/def_files.h" +#include "parse/parselo.h" + +SCP_vector Coordinate_shapes; + +namespace { + +constexpr const char* DEFAULT_TABLE_NAME = "coordinate_points.tbl"; +constexpr const char* MODULAR_TABLE_GLOB = "*-cps.tbm"; + +// Reserved built-in shape kind keywords. Tabled entries cannot use these names. +bool is_reserved_shape_name(const char* name) +{ + return !stricmp(name, "NGon") || !stricmp(name, "Star"); +} + +bool shape_name_exists(const char* name) +{ + for (const auto& s : Coordinate_shapes) { + if (!stricmp(s.name.c_str(), name)) { + return true; + } + } + return false; +} + +void parse_coordinate_points_table(const char* filename) +{ + try { + if (filename != nullptr) { + read_file_text(filename, CF_TYPE_TABLES); + } else { + read_file_text_from_default(defaults_get_file(DEFAULT_TABLE_NAME)); + } + reset_parse(); + + required_string("#Coordinate Shapes"); + while (optional_string("$Name:")) { + // optional_string consumed the keyword; read the name directly here rather than + // rewinding for a helper. + SCP_string name; + stuff_string(name, F_NAME); + + bool skip = false; + if (is_reserved_shape_name(name.c_str())) { + Warning(LOCATION, + "Coordinate shape name '%s' is reserved (built-in kind); entry skipped.", + name.c_str()); + skip = true; + } else if (shape_name_exists(name.c_str())) { + Warning(LOCATION, + "Coordinate shape '%s' is already defined; duplicate entry skipped.", + name.c_str()); + skip = true; + } + + SCP_vector raw_verts; + SCP_vector raw_tris; + + required_string("$Vertices:"); + stuff_float_list(raw_verts); + + required_string("$Triangles:"); + stuff_int_list(raw_tris); + + if (skip) { + continue; + } + + if (raw_verts.empty() || (raw_verts.size() % 2) != 0) { + Warning(LOCATION, + "Coordinate shape '%s' has %d vertex floats; must be a non-zero even count (x y pairs). Entry skipped.", + name.c_str(), static_cast(raw_verts.size())); + continue; + } + if (raw_tris.empty() || (raw_tris.size() % 3) != 0) { + Warning(LOCATION, + "Coordinate shape '%s' has %d triangle indices; must be a non-zero multiple of 3. Entry skipped.", + name.c_str(), static_cast(raw_tris.size())); + continue; + } + + const int vert_count = static_cast(raw_verts.size() / 2); + bool indices_valid = true; + for (int idx : raw_tris) { + if (idx < 0 || idx >= vert_count) { + Warning(LOCATION, + "Coordinate shape '%s' triangle index %d is out of range [0, %d). Entry skipped.", + name.c_str(), idx, vert_count); + indices_valid = false; + break; + } + } + if (!indices_valid) { + continue; + } + + coordinate_shape_def def; + def.name = std::move(name); + def.verts.reserve(vert_count); + for (size_t i = 0; i < raw_verts.size(); i += 2) { + def.verts.push_back({raw_verts[i], raw_verts[i + 1]}); + } + def.tri_indices = std::move(raw_tris); + + Coordinate_shapes.push_back(std::move(def)); + } + required_string("#End"); + } catch (const parse::ParseException& e) { + mprintf(("TABLES: Unable to parse '%s'! Error message = %s.\n", + filename ? filename : DEFAULT_TABLE_NAME, e.what())); + } +} + +} // anonymous namespace + +void coordinate_shapes_init() +{ + Coordinate_shapes.clear(); + + // Default table first so its names take precedence over any colliding mod entries. + if (cf_exists_full(DEFAULT_TABLE_NAME, CF_TYPE_TABLES)) { + parse_coordinate_points_table(DEFAULT_TABLE_NAME); + } else { + parse_coordinate_points_table(nullptr); // compiled-in default + } + + parse_modular_table(MODULAR_TABLE_GLOB, parse_coordinate_points_table); +} + +int find_coordinate_shape_index_by_name(const char* name) +{ + if (name == nullptr) { + return -1; + } + for (size_t i = 0; i < Coordinate_shapes.size(); ++i) { + if (!stricmp(Coordinate_shapes[i].name.c_str(), name)) { + return static_cast(i); + } + } + return -1; +} diff --git a/code/coordinate_points/coordinate_shapes.h b/code/coordinate_points/coordinate_shapes.h new file mode 100644 index 00000000000..0d56866ad35 --- /dev/null +++ b/code/coordinate_points/coordinate_shapes.h @@ -0,0 +1,25 @@ +#pragma once + +#include "globalincs/pstypes.h" + +// Tabled custom shapes for coordinate points. Loaded from coordinate_points.tbl plus any +// *-cps.tbm modular tables. TBMs are additive only -- duplicate names and the reserved names +// "NGon" / "Star" are rejected with a warning. First definition wins, default table loads first. + +struct coordinate_shape_vec2f { + float x, y; +}; + +struct coordinate_shape_def { + SCP_string name; + SCP_vector verts; + SCP_vector tri_indices; // size always a multiple of 3; indices in [0, verts.size()) +}; + +extern SCP_vector Coordinate_shapes; + +// Loads the default table and any modular tables. Safe to call once at game init. +void coordinate_shapes_init(); + +// Returns -1 if no shape matches the given name (case-insensitive). nullptr is treated as miss. +int find_coordinate_shape_index_by_name(const char* name); diff --git a/code/def_files/data/tables/coordinate_points.tbl b/code/def_files/data/tables/coordinate_points.tbl new file mode 100644 index 00000000000..1e4231d3de9 --- /dev/null +++ b/code/def_files/data/tables/coordinate_points.tbl @@ -0,0 +1,23 @@ +#Coordinate Shapes + +; Default tabled shapes for coordinate points. Mods may add new shapes via *-cps.tbm files. +; TBMs are additive only: duplicate names and the reserved names "NGon" and "Star" are rejected +; with a warning. +; +; Each entry needs $Vertices: (a flat list of x y floats, unit radius, point-up convention) and +; $Triangles: (a flat list of CCW triangle indices into the vertex list, three per triangle). + +$Name: Cross +$Vertices: ( + -0.33 1.00 0.33 1.00 0.33 0.33 1.00 0.33 + 1.00 -0.33 0.33 -0.33 0.33 -1.00 -0.33 -1.00 + -0.33 -0.33 -1.00 -0.33 -1.00 0.33 -0.33 0.33 +) +$Triangles: ( + ; Vertical bar + 0 7 6 0 6 1 + ; Horizontal bar + 10 9 4 10 4 3 +) + +#End diff --git a/code/hud/hudbrackets.cpp b/code/hud/hudbrackets.cpp index 0a86ab592cd..5e54c1a2f0b 100644 --- a/code/hud/hudbrackets.cpp +++ b/code/hud/hudbrackets.cpp @@ -723,8 +723,8 @@ void HudGaugeBrackets::renderBoundingBrackets(int x1, int y1, int x2, int y2, in auto* cp = find_coordinate_point_by_objnum(target_objnum); if (cp != nullptr) { tinfo_name = cp->name.c_str(); - if (!cp->category.empty()) - tinfo_class = cp->category.c_str(); + if (!cp->group.empty()) + tinfo_class = cp->group.c_str(); } break; } diff --git a/code/hud/hudtargetbox.cpp b/code/hud/hudtargetbox.cpp index 4a227ecbd88..cf9e6ed47f8 100644 --- a/code/hud/hudtargetbox.cpp +++ b/code/hud/hudtargetbox.cpp @@ -1398,15 +1398,15 @@ void HudGaugeTargetBox::renderTargetCoordinatePoint(object *target_objp) return; } - // Coordinate points have no model and no integrity; just draw the frame, name, category, distance. + // Coordinate points have no model and no integrity; just draw the frame, name, group, distance. renderTargetForeground(); renderTargetIntegrity(1); setGaugeColor(); renderString(position[0] + Name_offsets[0], position[1] + Name_offsets[1], EG_TBOX_NAME, cp->name.c_str()); - if (!cp->category.empty()) { - renderString(position[0] + Class_offsets[0], position[1] + Class_offsets[1], EG_TBOX_CLASS, cp->category.c_str()); + if (!cp->group.empty()) { + renderString(position[0] + Class_offsets[0], position[1] + Class_offsets[1], EG_TBOX_CLASS, cp->group.c_str()); } float dist = Player_ai->current_target_distance; diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index dc6b3f476a3..78d20a56ea5 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -5467,8 +5467,8 @@ void parse_coordinate_point(mission* /*pm*/) required_string("$Location:"); stuff_vec3d(&cp.position); - if (optional_string("+Category:")) { - stuff_string(cp.category, F_NAME); + if (optional_string("+Group:")) { + stuff_string(cp.group, F_NAME); } if (optional_string("+Color:")) { @@ -5485,7 +5485,47 @@ void parse_coordinate_point(mission* /*pm*/) if (optional_string("+Shape:")) { SCP_string shape_name; stuff_string(shape_name, F_NAME); - cp.shape = coordinate_point_shape_from_string(shape_name.c_str()); + + // Built-in primitive kinds. + if (!stricmp(shape_name.c_str(), "NGon")) { + cp.shape_kind = CoordinatePointShapeKind::NGon; + } else if (!stricmp(shape_name.c_str(), "Star")) { + cp.shape_kind = CoordinatePointShapeKind::Star; + } else if (!stricmp(shape_name.c_str(), "Triangle")) { + // Legacy alias from before the shape-kind refactor. Equivalent to NGon(3). + cp.shape_kind = CoordinatePointShapeKind::NGon; + cp.shape_sides = 3; + } else if (!stricmp(shape_name.c_str(), "Diamond")) { + // Legacy alias: NGon(4) with point-up convention (the default). + cp.shape_kind = CoordinatePointShapeKind::NGon; + cp.shape_sides = 4; + } else if (!stricmp(shape_name.c_str(), "Pentagon")) { + cp.shape_kind = CoordinatePointShapeKind::NGon; + cp.shape_sides = 5; + } else { + // Anything else: treat as a tabled shape name; index resolved at post-process. + cp.shape_kind = CoordinatePointShapeKind::Tabled; + cp.shape_table_name = shape_name; + } + } + + if (optional_string("+Sides:")) { + stuff_int(&cp.shape_sides); + CLAMP(cp.shape_sides, NGON_SIDES_MIN, NGON_SIDES_MAX); + } + + if (optional_string("+Points:")) { + stuff_int(&cp.shape_points); + CLAMP(cp.shape_points, STAR_POINTS_MIN, STAR_POINTS_MAX); + } + + if (optional_string("+Inner Radius:")) { + stuff_float(&cp.shape_inner_radius); + CLAMP(cp.shape_inner_radius, STAR_INNER_MIN, STAR_INNER_MAX); + } + + if (optional_string("+Angle:")) { + stuff_float(&cp.shape_angle_deg); } if (optional_string("+Size:")) { diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index b20ae14a575..708049699d7 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -44,6 +44,7 @@ #include "parse/sexp_container.h" #include "prop/prop.h" #include "coordinate_points/coordinate_point.h" +#include "coordinate_points/coordinate_shapes.h" #include "sound/ds.h" #include "sound/sound.h" #include "starfield/nebula.h" @@ -5463,13 +5464,13 @@ int Fred_mission_save::save_coordinate_points() parse_comments(0); save_vector(Objects[cp.objnum].pos); - if (!cp.category.empty()) { - if (optional_string_fred("+Category:", "$Name:")) { + if (!cp.group.empty()) { + if (optional_string_fred("+Group:", "$Name:")) { parse_comments(); } else { - fout("\n+Category:"); + fout("\n+Group:"); } - fout(" %s", cp.category.c_str()); + fout(" %s", cp.group.c_str()); } const bool color_is_default = @@ -5485,13 +5486,62 @@ int Fred_mission_save::save_coordinate_points() cp.display_color.blue, cp.display_color.alpha); } - if (cp.shape != CoordinatePointShape::Diamond) { + // +Shape: omit when it's the default (NGon with default sides). For Tabled we look + // up the registered name; if the index is somehow stale, just skip the line. + const char* shape_name = nullptr; + if (cp.shape_kind == CoordinatePointShapeKind::Star) { + shape_name = "Star"; + } else if (cp.shape_kind == CoordinatePointShapeKind::Tabled && + cp.shape_table_index >= 0 && + cp.shape_table_index < static_cast(Coordinate_shapes.size())) { + shape_name = Coordinate_shapes[cp.shape_table_index].name.c_str(); + } + if (shape_name != nullptr) { if (optional_string_fred("+Shape:", "$Name:")) { parse_comments(); } else { fout("\n+Shape:"); } - fout(" %s", coordinate_point_shape_to_string(cp.shape)); + fout(" %s", shape_name); + } + + // +Sides: only emit for NGon, only when non-default. + if (cp.shape_kind == CoordinatePointShapeKind::NGon && cp.shape_sides != 4) { + if (optional_string_fred("+Sides:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Sides:"); + } + fout(" %d", cp.shape_sides); + } + + // +Points: / +Inner Radius: only for Star, only when non-default. + if (cp.shape_kind == CoordinatePointShapeKind::Star && cp.shape_points != 5) { + if (optional_string_fred("+Points:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Points:"); + } + fout(" %d", cp.shape_points); + } + + if (cp.shape_kind == CoordinatePointShapeKind::Star && cp.shape_inner_radius != STAR_INNER_DEFAULT) { + if (optional_string_fred("+Inner Radius:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Inner Radius:"); + } + fout(" %f", cp.shape_inner_radius); + } + + // +Angle: applies to every kind; emit when non-zero. + if (cp.shape_angle_deg != 0.0f) { + if (optional_string_fred("+Angle:", "$Name:")) { + parse_comments(); + } else { + fout("\n+Angle:"); + } + fout(" %f", cp.shape_angle_deg); } if (cp.size_scale != 1.0f) { diff --git a/code/scripting/api/objs/coordinatepoint.cpp b/code/scripting/api/objs/coordinatepoint.cpp index ee35a7ad2be..c72f8ea5207 100644 --- a/code/scripting/api/objs/coordinatepoint.cpp +++ b/code/scripting/api/objs/coordinatepoint.cpp @@ -39,12 +39,12 @@ ADE_VIRTVAR(Name, return ade_set_args(L, "s", cp->name.c_str()); } -ADE_VIRTVAR(Category, +ADE_VIRTVAR(Group, l_CoordinatePoint, "string", - "Designer-defined category string (may be empty).", + "Designer-defined group string (may be empty).", "string", - "Coordinate point category, or empty string if handle is invalid") + "Coordinate point group, or empty string if handle is invalid") { object_h* objh; const char* s = nullptr; @@ -59,10 +59,10 @@ ADE_VIRTVAR(Category, return ade_set_error(L, "s", ""); if (ADE_SETTING_VAR && s != nullptr) { - cp->category = s; + cp->group = s; } - return ade_set_args(L, "s", cp->category.c_str()); + return ade_set_args(L, "s", cp->group.c_str()); } ADE_VIRTVAR(EscortPriority, diff --git a/code/source_groups.cmake b/code/source_groups.cmake index 223765198de..c65c615d91a 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -311,6 +311,7 @@ add_file_folder("Default files\\\\data\\\\tables" def_files/data/tables/post_processing.tbl def_files/data/tables/species_defs.tbl def_files/data/tables/cheats.tbl + def_files/data/tables/coordinate_points.tbl ) # These files will be included in the executable but not in CFile @@ -1250,6 +1251,8 @@ add_file_folder("Coordinate Points" coordinate_points/coordinate_point_flags.h coordinate_points/coordinate_point_render.cpp coordinate_points/coordinate_point_render.h + coordinate_points/coordinate_shapes.cpp + coordinate_points/coordinate_shapes.h ) # Radar files diff --git a/fred2/management.cpp b/fred2/management.cpp index 885e561d861..376077a79cd 100644 --- a/fred2/management.cpp +++ b/fred2/management.cpp @@ -76,6 +76,7 @@ #include "scripting/scripting.h" #include "scripting/global_hooks.h" #include "utils/Random.h" +#include "coordinate_points/coordinate_shapes.h" #include "prop/prop.h" #include @@ -387,6 +388,7 @@ bool fred_init(std::unique_ptr&& graphicsOps) glowpoint_init(); ship_init(); prop_init(); + coordinate_shapes_init(); techroom_intel_init(); hud_positions_init(); diff --git a/freespace2/freespace.cpp b/freespace2/freespace.cpp index c8da60c2f6c..ff16f05053d 100644 --- a/freespace2/freespace.cpp +++ b/freespace2/freespace.cpp @@ -85,6 +85,7 @@ #include "io/mouse.h" #include "io/timer.h" #include "coordinate_points/coordinate_point_render.h" +#include "coordinate_points/coordinate_shapes.h" #include "jumpnode/jumpnode.h" #include "lab/labv2.h" #include "libs/discord/discord.h" @@ -2064,10 +2065,11 @@ void game_init() ai_profiles_init(); // Goober5000 weapon_init(); glowpoint_init(); - ship_init(); // read in ships.tbl + ship_init(); // read in ships.tbl prop_init(); + coordinate_shapes_init(); // read in coordinate_points.tbl - player_init(); + player_init(); mission_campaign_init(); // load in the default campaign anim_init(); context_help_init(); diff --git a/qtfred/help-src/doc/dialogs/CoordinatePointEditorDialog.html b/qtfred/help-src/doc/dialogs/CoordinatePointEditorDialog.html index f437e908ab5..b228f924d4e 100644 --- a/qtfred/help-src/doc/dialogs/CoordinatePointEditorDialog.html +++ b/qtfred/help-src/doc/dialogs/CoordinatePointEditorDialog.html @@ -36,12 +36,24 @@

    Fields

    NameIdentifies the point for SEXPs, Lua, and the escort list. Must be unique across coordinate points, ships, wings, waypoint paths, and jump nodes. Read-only when multiple points are selected; rename individually. - CategoryFree-form string. Shown in the HUD target box and the + GroupFree-form string. Shown in the HUD target box and the escort list. Designers can use this for any visible grouping (e.g. Combat, Nav, Objective). - ShapeOutline shape rendered in the editor (and in-mission when - the Visible flag is set). Choices: Triangle, Square, Diamond, Pentagon, - Hexagon, Cross, Star. + ShapeThe shape rendered in the editor (and in-mission when + the Visible flag is set). Two built-in kinds: NGon (regular polygon) + and Star (alternating outer / inner points). All other entries are + custom shapes loaded from coordinate_points.tbl plus any + *-cps.tbm modular tables. + SidesNumber of sides for an NGon shape (range 3-32). Only shown + when Shape is NGon. Three sides gives a triangle, four a diamond, and high + side counts approximate a circle. + PointsNumber of points for a Star shape (range 3-32). Only shown + when Shape is Star. + Inner RadiusInner-vertex radius for a Star shape, as a fraction of + the outer radius (range 0.05-0.95). Smaller values give sharper arms. Only shown + when Shape is Star. + AngleIn-plane rotation in degrees applied to the shape. 0 (the + default) places the first vertex at the top. SizeScales the rendered shape. 1.0 is the default; range 0.25-8.0. Escort Priority0 (default) means the point is not on the escort diff --git a/qtfred/src/mission/Editor.cpp b/qtfred/src/mission/Editor.cpp index f9e5ef9165e..562483c9ee7 100644 --- a/qtfred/src/mission/Editor.cpp +++ b/qtfred/src/mission/Editor.cpp @@ -982,14 +982,19 @@ int Editor::dup_object(object* objp) { auto* clone = find_coordinate_point_by_objnum(obj); if (clone != nullptr) { // Copy every field except the auto-generated unique name and the objnum. - clone->category = src->category; - clone->display_color = src->display_color; - clone->shape = src->shape; - clone->size_scale = src->size_scale; - clone->escort_priority = src->escort_priority; - clone->multi_team = src->multi_team; - clone->flags = src->flags; - clone->fred_layer = src->fred_layer; + clone->group = src->group; + clone->display_color = src->display_color; + clone->shape_kind = src->shape_kind; + clone->shape_sides = src->shape_sides; + clone->shape_points = src->shape_points; + clone->shape_inner_radius = src->shape_inner_radius; + clone->shape_table_index = src->shape_table_index; + clone->shape_angle_deg = src->shape_angle_deg; + clone->size_scale = src->size_scale; + clone->escort_priority = src->escort_priority; + clone->multi_team = src->multi_team; + clone->flags = src->flags; + clone->fred_layer = src->fred_layer; } } } diff --git a/qtfred/src/mission/FredRenderer.cpp b/qtfred/src/mission/FredRenderer.cpp index 4433d65cd3b..c74a8889596 100644 --- a/qtfred/src/mission/FredRenderer.cpp +++ b/qtfred/src/mission/FredRenderer.cpp @@ -456,8 +456,8 @@ void FredRenderer::display_ship_info(int cur_object_index) { } else if (objp->type == OBJ_COORDINATE_POINT) { auto* cp = find_coordinate_point_by_objnum(OBJ_INDEX(objp)); if (cp != nullptr) { - if (!cp->category.empty()) { - sprintf(buf, "%s\n%s", cp->name.c_str(), cp->category.c_str()); + if (!cp->group.empty()) { + sprintf(buf, "%s\n%s", cp->name.c_str(), cp->group.c_str()); } else { sprintf(buf, "%s", cp->name.c_str()); } @@ -1038,6 +1038,22 @@ void FredRenderer::render_frame(int cur_object_index, render_models(cur_object_index); render_volumetric_overlay(); + // Draw coordinate-point shapes before the text overlays so the per-object label (name, + // group, coords) lands ON TOP of the shape rather than getting covered by it. + if (view().Show_coordinate_points) { + enable_htl(); + for (const auto& cp : Coordinate_points) { + if (cp.objnum < 0) { + continue; + } + if (!_viewport->isObjectVisibleInLayer(&Objects[cp.objnum])) { + continue; + } + draw_coordinate_point_shape(cp, &_viewport->camera.eye_pos, &_viewport->camera.eye_orient); + } + disable_htl(); + } + if (view().Show_distances) { display_distances(); } @@ -1116,18 +1132,6 @@ void FredRenderer::render_frame(int cur_object_index, } } } - - if (view().Show_coordinate_points) { - for (const auto& cp : Coordinate_points) { - if (cp.objnum < 0) { - continue; - } - if (!_viewport->isObjectVisibleInLayer(&Objects[cp.objnum])) { - continue; - } - draw_coordinate_point_shape(cp, &_viewport->camera.eye_pos, &_viewport->camera.eye_orient); - } - } disable_htl(); sprintf(buf, "(%.1f,%.1f,%.1f)", _viewport->camera.eye_pos.xyz.x, _viewport->camera.eye_pos.xyz.y, _viewport->camera.eye_pos.xyz.z); diff --git a/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.cpp b/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.cpp index 50a2857b559..a1553919699 100644 --- a/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.cpp @@ -1,5 +1,6 @@ #include "mission/dialogs/CoordinatePointEditorDialogModel.h" +#include #include #include #include @@ -34,9 +35,13 @@ mission_coordinate_point* CoordinatePointEditorDialogModel::getSelected(int objn void CoordinatePointEditorDialogModel::initializeData() { _selectedObjnums.clear(); - _categoryMixed = false; + _groupMixed = false; _redMixed = _greenMixed = _blueMixed = _alphaMixed = false; - _shapeMixed = false; + _shapeKindMixed = false; + _sidesMixed = false; + _pointsMixed = false; + _innerRadiusMixed = false; + _angleMixed = false; _sizeMixed = false; _escortPriorityMixed = false; _multiTeamMixed = false; @@ -53,12 +58,17 @@ void CoordinatePointEditorDialogModel::initializeData() const mission_coordinate_point* first = getSelected(_selectedObjnums.front()); if (first != nullptr) { _currentName = first->name; - _category = first->category; + _group = first->group; _colorR = first->display_color.red; _colorG = first->display_color.green; _colorB = first->display_color.blue; _colorA = first->display_color.alpha; - _shape = first->shape; + _shapeKind = first->shape_kind; + _shapeTableIndex = first->shape_table_index; + _sides = first->shape_sides; + _points = first->shape_points; + _innerRadius = first->shape_inner_radius; + _angle = first->shape_angle_deg; _size = first->size_scale; _escortPriority = first->escort_priority; _multiTeam = first->multi_team; @@ -67,12 +77,22 @@ void CoordinatePointEditorDialogModel::initializeData() for (size_t i = 1; i < _selectedObjnums.size(); ++i) { const auto* other = getSelected(_selectedObjnums[i]); if (other == nullptr) continue; - if (other->category != _category) _categoryMixed = true; + if (other->group != _group) _groupMixed = true; if (other->display_color.red != _colorR) _redMixed = true; if (other->display_color.green != _colorG) _greenMixed = true; if (other->display_color.blue != _colorB) _blueMixed = true; if (other->display_color.alpha != _colorA) _alphaMixed = true; - if (other->shape != _shape) _shapeMixed = true; + // Shape kind is mixed if either the kind itself differs OR (for Tabled) the + // resolved table index differs -- two points both Tabled but pointing at + // different shapes are still "mixed." + if (other->shape_kind != _shapeKind) _shapeKindMixed = true; + else if (_shapeKind == CoordinatePointShapeKind::Tabled && + other->shape_table_index != _shapeTableIndex) + _shapeKindMixed = true; + if (other->shape_sides != _sides) _sidesMixed = true; + if (other->shape_points != _points) _pointsMixed = true; + if (other->shape_inner_radius != _innerRadius) _innerRadiusMixed = true; + if (other->shape_angle_deg != _angle) _angleMixed = true; if (other->size_scale != _size) _sizeMixed = true; if (other->escort_priority != _escortPriority) _escortPriorityMixed = true; if (other->multi_team != _multiTeam) _multiTeamMixed = true; @@ -82,9 +102,14 @@ void CoordinatePointEditorDialogModel::initializeData() } } else { _currentName.clear(); - _category.clear(); + _group.clear(); _colorR = _colorG = _colorB = _colorA = 255; - _shape = CoordinatePointShape::Diamond; + _shapeKind = CoordinatePointShapeKind::NGon; + _shapeTableIndex = -1; + _sides = 4; + _points = 5; + _innerRadius = STAR_INNER_DEFAULT; + _angle = 0.0f; _size = 1.0f; _escortPriority = 0; _multiTeam = -1; @@ -192,16 +217,16 @@ bool CoordinatePointEditorDialogModel::setCurrentName(const SCP_string& name) return true; } -const SCP_string& CoordinatePointEditorDialogModel::getCategory() const { return _category; } -bool CoordinatePointEditorDialogModel::isCategoryMixed() const { return _categoryMixed; } +const SCP_string& CoordinatePointEditorDialogModel::getGroup() const { return _group; } +bool CoordinatePointEditorDialogModel::isGroupMixed() const { return _groupMixed; } -void CoordinatePointEditorDialogModel::setCategory(const SCP_string& category) +void CoordinatePointEditorDialogModel::setGroup(const SCP_string& group) { - _category = category; - _categoryMixed = false; + _group = group; + _groupMixed = false; for (int objnum : _selectedObjnums) { auto* cp = getSelected(objnum); - if (cp != nullptr) cp->category = category; + if (cp != nullptr) cp->group = group; } _suppressRefresh = true; set_modified(); @@ -295,16 +320,126 @@ void CoordinatePointEditorDialogModel::setColorA(int a) _suppressRefresh = false; } -CoordinatePointShape CoordinatePointEditorDialogModel::getShape() const { return _shape; } -bool CoordinatePointEditorDialogModel::isShapeMixed() const { return _shapeMixed; } +CoordinatePointShapeKind CoordinatePointEditorDialogModel::getShapeKind() const { return _shapeKind; } +int CoordinatePointEditorDialogModel::getShapeTableIndex() const { return _shapeTableIndex; } +bool CoordinatePointEditorDialogModel::isShapeKindMixed() const { return _shapeKindMixed; } -void CoordinatePointEditorDialogModel::setShape(CoordinatePointShape shape) +int CoordinatePointEditorDialogModel::getShapeId() const { - _shape = shape; - _shapeMixed = false; + // Combo encoding: NGon=-2, Star=-1, Tabled=tableIndex. + switch (_shapeKind) { + case CoordinatePointShapeKind::NGon: return -2; + case CoordinatePointShapeKind::Star: return -1; + case CoordinatePointShapeKind::Tabled: return _shapeTableIndex; + } + return -2; +} + +void CoordinatePointEditorDialogModel::setShapeId(int shape_id) +{ + CoordinatePointShapeKind kind; + int tableIdx = -1; + if (shape_id == -2) { + kind = CoordinatePointShapeKind::NGon; + } else if (shape_id == -1) { + kind = CoordinatePointShapeKind::Star; + } else if (shape_id >= 0 && shape_id < static_cast(Coordinate_shapes.size())) { + kind = CoordinatePointShapeKind::Tabled; + tableIdx = shape_id; + } else { + return; // unknown id (mixed-state sentinel or stale) + } + + _shapeKind = kind; + _shapeTableIndex = tableIdx; + _shapeKindMixed = false; + for (int objnum : _selectedObjnums) { + auto* cp = getSelected(objnum); + if (cp == nullptr) continue; + cp->shape_kind = kind; + // Only overwrite shape_table_index for Tabled. For NGon/Star leave any prior index + // alone (defensively; it shouldn't be used anyway when kind != Tabled). + if (kind == CoordinatePointShapeKind::Tabled) { + cp->shape_table_index = tableIdx; + } + } + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +int CoordinatePointEditorDialogModel::getSides() const { return _sides; } +bool CoordinatePointEditorDialogModel::isSidesMixed() const { return _sidesMixed; } + +void CoordinatePointEditorDialogModel::setSides(int v) +{ + // Below NGON_SIDES_MIN means "mixed" sentinel from the spinbox; ignore. + if (v < NGON_SIDES_MIN) return; + if (v > NGON_SIDES_MAX) v = NGON_SIDES_MAX; + _sides = v; + _sidesMixed = false; + for (int objnum : _selectedObjnums) { + auto* cp = getSelected(objnum); + if (cp != nullptr) cp->shape_sides = v; + } + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +int CoordinatePointEditorDialogModel::getPoints() const { return _points; } +bool CoordinatePointEditorDialogModel::isPointsMixed() const { return _pointsMixed; } + +void CoordinatePointEditorDialogModel::setPoints(int v) +{ + if (v < STAR_POINTS_MIN) return; + if (v > STAR_POINTS_MAX) v = STAR_POINTS_MAX; + _points = v; + _pointsMixed = false; + for (int objnum : _selectedObjnums) { + auto* cp = getSelected(objnum); + if (cp != nullptr) cp->shape_points = v; + } + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +float CoordinatePointEditorDialogModel::getInnerRadius() const { return _innerRadius; } +bool CoordinatePointEditorDialogModel::isInnerRadiusMixed() const { return _innerRadiusMixed; } + +void CoordinatePointEditorDialogModel::setInnerRadius(float v) +{ + // Negative = "mixed" sentinel. + if (v < 0.0f) return; + CLAMP(v, STAR_INNER_MIN, STAR_INNER_MAX); + _innerRadius = v; + _innerRadiusMixed = false; + for (int objnum : _selectedObjnums) { + auto* cp = getSelected(objnum); + if (cp != nullptr) cp->shape_inner_radius = v; + } + _suppressRefresh = true; + set_modified(); + _editor->missionChanged(); + _suppressRefresh = false; +} + +float CoordinatePointEditorDialogModel::getAngle() const { return _angle; } +bool CoordinatePointEditorDialogModel::isAngleMixed() const { return _angleMixed; } + +void CoordinatePointEditorDialogModel::setAngle(float v) +{ + // Angle has no sentinel: it's a free-rotation field with no "mixed" interaction beyond + // the spinbox's specialValueText handling. The dialog filters mixed-state via its UI logic. + _angle = v; + _angleMixed = false; for (int objnum : _selectedObjnums) { auto* cp = getSelected(objnum); - if (cp != nullptr) cp->shape = shape; + if (cp != nullptr) cp->shape_angle_deg = v; } _suppressRefresh = true; set_modified(); diff --git a/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.h b/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.h index 7ed8d2c13ab..99fbd5dadbf 100644 --- a/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.h +++ b/qtfred/src/mission/dialogs/CoordinatePointEditorDialogModel.h @@ -23,10 +23,10 @@ class CoordinatePointEditorDialogModel : public AbstractDialogModel { const SCP_string& getCurrentName() const; bool setCurrentName(const SCP_string& name); - // Category (free-form string). - const SCP_string& getCategory() const; - bool isCategoryMixed() const; - void setCategory(const SCP_string& category); + // Group (free-form string). + const SCP_string& getGroup() const; + bool isGroupMixed() const; + void setGroup(const SCP_string& group); // Color, per-channel. int getColorR() const; @@ -43,10 +43,31 @@ class CoordinatePointEditorDialogModel : public AbstractDialogModel { bool isColorAMixed() const; bool hasAnyColorMixed() const; - // Shape. - CoordinatePointShape getShape() const; - bool isShapeMixed() const; - void setShape(CoordinatePointShape shape); + // Shape kind + per-kind parameters. Mixed-state is tracked separately per field. Setters + // write through to every selected point's matching field; per-kind parameters are kept on + // every point regardless of its current kind, so toggling kinds doesn't destroy the data. + CoordinatePointShapeKind getShapeKind() const; + int getShapeTableIndex() const; // valid only when kind == Tabled + bool isShapeKindMixed() const; + // shape_id is encoded for the combo: NGon = -2, Star = -1, Tabled = table index (>= 0). + int getShapeId() const; + void setShapeId(int shape_id); + + int getSides() const; + bool isSidesMixed() const; + void setSides(int v); + + int getPoints() const; + bool isPointsMixed() const; + void setPoints(int v); + + float getInnerRadius() const; + bool isInnerRadiusMixed() const; + void setInnerRadius(float v); + + float getAngle() const; + bool isAngleMixed() const; + void setAngle(float v); // Size scale (clamped to [SIZE_MIN, SIZE_MAX] on set). float getSize() const; @@ -96,17 +117,28 @@ private slots: SCP_vector _selectedObjnums; SCP_string _currentName; - SCP_string _category; + SCP_string _group; int _colorR = 255, _colorG = 255, _colorB = 255, _colorA = 255; - CoordinatePointShape _shape = CoordinatePointShape::Diamond; + + CoordinatePointShapeKind _shapeKind = CoordinatePointShapeKind::NGon; + int _shapeTableIndex = -1; + int _sides = 4; + int _points = 5; + float _innerRadius = STAR_INNER_DEFAULT; + float _angle = 0.0f; + float _size = 1.0f; int _escortPriority = 0; int _multiTeam = -1; bool _visibleInMission = false; - bool _categoryMixed = false; + bool _groupMixed = false; bool _redMixed = false, _greenMixed = false, _blueMixed = false, _alphaMixed = false; - bool _shapeMixed = false; + bool _shapeKindMixed = false; + bool _sidesMixed = false; + bool _pointsMixed = false; + bool _innerRadiusMixed = false; + bool _angleMixed = false; bool _sizeMixed = false; bool _escortPriorityMixed = false; bool _multiTeamMixed = false; diff --git a/qtfred/src/mission/management.cpp b/qtfred/src/mission/management.cpp index fc70ad4c83b..b0e8b2d839f 100644 --- a/qtfred/src/mission/management.cpp +++ b/qtfred/src/mission/management.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -228,6 +229,9 @@ initialize(const std::string& cfilepath, int argc, char* argv[], Editor* editor, //listener(Subsystem::Props); prop_init(); + //listener(Subsystem::CoordinateShapes); + coordinate_shapes_init(); + listener(SubSystem::TechroomIntel); techroom_intel_init(); diff --git a/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.cpp b/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.cpp index 982b818d40e..a3e97666445 100644 --- a/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.cpp +++ b/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.cpp @@ -2,6 +2,7 @@ #include "ui/util/SignalBlockers.h" #include "ui_CoordinatePointEditorDialog.h" +#include #include #include #include @@ -27,22 +28,35 @@ CoordinatePointEditorDialog::CoordinatePointEditorDialog(FredView* parent, Edito ui->escortPrioritySpinBox->setMinimum(-1); ui->escortPrioritySpinBox->setSpecialValueText(" "); - // Multiplayer team combo: "Any" + one entry per TVT team. Mixed-selection state is - // shown by setCurrentIndex(-1) (blank); selecting any real entry commits the value. - ui->multiTeamCombo->clear(); - ui->multiTeamCombo->addItem("Any team", -1); - for (const auto& team : Mission_event_teams_tvt) { - ui->multiTeamCombo->addItem(QString::fromStdString(team.first), team.second); - } - ui->sizeSpinBox->setMinimum(-0.01); // negative is the "mixed" sentinel - ui->sizeSpinBox->setMaximum(static_cast(COORDINATE_POINT_SIZE_MAX)); - ui->sizeSpinBox->setSingleStep(0.1); - ui->sizeSpinBox->setSpecialValueText(" "); - - // Populate the shape combo from the enum. - ui->shapeCombo->clear(); - for (int i = 0; i < static_cast(CoordinatePointShape::NUM_SHAPES); ++i) { - ui->shapeCombo->addItem(coordinate_point_shape_to_string(static_cast(i)), i); + // Block combo signals during initial populate. Without this, addItem() fires + // currentIndexChanged on the auto-connected slot and clobbers the model's value + // (e.g. a freshly-created Diamond point gets overwritten with Triangle, the first + // enum entry) before updateUi() ever reads the real state. + { + util::SignalBlockers blockers(this); + + // Multiplayer team combo: "Any" + one entry per TVT team. Mixed-selection state + // is shown by setCurrentIndex(-1) (blank); selecting any real entry commits. + ui->multiTeamCombo->clear(); + ui->multiTeamCombo->addItem("Any team", -1); + for (const auto& team : Mission_event_teams_tvt) { + ui->multiTeamCombo->addItem(QString::fromStdString(team.first), team.second); + } + + ui->sizeSpinBox->setMinimum(-0.01); // negative is the "mixed" sentinel + ui->sizeSpinBox->setMaximum(static_cast(COORDINATE_POINT_SIZE_MAX)); + ui->sizeSpinBox->setSingleStep(0.1); + ui->sizeSpinBox->setSpecialValueText(" "); + + // Populate the shape combo: NGon and Star as the two built-in primitives, then every + // tabled shape in load order. UserRole carries the "shape id" used by the model: + // NGon=-2, Star=-1, Tabled=table_index (>= 0). + ui->shapeCombo->clear(); + ui->shapeCombo->addItem("NGon", -2); + ui->shapeCombo->addItem("Star", -1); + for (int i = 0; i < static_cast(Coordinate_shapes.size()); ++i) { + ui->shapeCombo->addItem(QString::fromStdString(Coordinate_shapes[i].name), i); + } } initializeUi(); @@ -72,8 +86,14 @@ void CoordinatePointEditorDialog::initializeUi() const bool multi = _model->hasMultipleSelection(); ui->nameEdit->setEnabled(enabled && !multi); - ui->categoryEdit->setEnabled(enabled); + ui->groupEdit->setEnabled(enabled); ui->shapeCombo->setEnabled(enabled); + // Sides/Points/InnerRadius are gated per-kind in updateUi(); only the always-on rows are + // touched here, plus a default-disabled baseline so they grey out when no point is selected. + ui->sidesSpinBox->setEnabled(false); + ui->pointsSpinBox->setEnabled(false); + ui->innerRadiusSpinBox->setEnabled(false); + ui->angleSpinBox->setEnabled(enabled); ui->sizeSpinBox->setEnabled(enabled); ui->escortPrioritySpinBox->setEnabled(enabled); // Team selector is greyed out when the mission isn't a team mission. @@ -100,20 +120,50 @@ void CoordinatePointEditorDialog::updateUi() ui->nameEdit->setText(QString::fromStdString(_model->getCurrentName())); - if (_model->isCategoryMixed()) { - ui->categoryEdit->setPlaceholderText(""); - ui->categoryEdit->setText(""); + if (_model->isGroupMixed()) { + ui->groupEdit->setPlaceholderText(""); + ui->groupEdit->setText(""); } else { - ui->categoryEdit->setPlaceholderText(""); - ui->categoryEdit->setText(QString::fromStdString(_model->getCategory())); + ui->groupEdit->setPlaceholderText(""); + ui->groupEdit->setText(QString::fromStdString(_model->getGroup())); } - if (_model->isShapeMixed()) { + // Shape: combo holds the kind+table_index encoded as the "shape id" (-2 = NGon, -1 = Star, + // >= 0 = tabled). Look up by data, not by index, so the combo's order is independent of + // the encoding. + if (_model->isShapeKindMixed()) { ui->shapeCombo->setCurrentIndex(-1); } else { - ui->shapeCombo->setCurrentIndex(static_cast(_model->getShape())); + const int idx = ui->shapeCombo->findData(_model->getShapeId()); + ui->shapeCombo->setCurrentIndex(idx); } + // Per-kind parameter rows stay in place; we just disable the ones that don't apply to the + // resolved kind. When kind is mixed across a multi-selection, all three per-kind rows are + // disabled until the user picks a kind. Angle is always editable. Rows are also disabled + // up front when there's no valid selection (handled by initializeUi). + const bool kindMixed = _model->isShapeKindMixed(); + const auto kind = _model->getShapeKind(); + const bool selectionEnabled = _model->hasValidSelection(); + const bool ngonEnabled = selectionEnabled && !kindMixed && (kind == CoordinatePointShapeKind::NGon); + const bool starEnabled = selectionEnabled && !kindMixed && (kind == CoordinatePointShapeKind::Star); + ui->sidesLabel->setEnabled(ngonEnabled); + ui->sidesSpinBox->setEnabled(ngonEnabled); + ui->pointsLabel->setEnabled(starEnabled); + ui->pointsSpinBox->setEnabled(starEnabled); + ui->innerRadiusLabel->setEnabled(starEnabled); + ui->innerRadiusSpinBox->setEnabled(starEnabled); + + // Mixed sentinels: spinboxes whose minimum is set below the legal range render + // specialValueText (" ") when that minimum value is set. + ui->sidesSpinBox->setValue(_model->isSidesMixed() ? 2 : _model->getSides()); + ui->pointsSpinBox->setValue(_model->isPointsMixed() ? 2 : _model->getPoints()); + ui->innerRadiusSpinBox->setValue(_model->isInnerRadiusMixed() ? -0.01 + : static_cast(_model->getInnerRadius())); + // Angle is a free-rotation float; no sentinel needed -- on mixed we just show the first + // selection's value and let the next edit write through to all. + ui->angleSpinBox->setValue(static_cast(_model->getAngle())); + ui->sizeSpinBox->setValue(_model->isSizeMixed() ? -0.01 : static_cast(_model->getSize())); ui->escortPrioritySpinBox->setValue(_model->isEscortPriorityMixed() ? -1 : _model->getEscortPriority()); if (_model->isMultiTeamMixed()) { @@ -172,17 +222,40 @@ void CoordinatePointEditorDialog::on_nameEdit_editingFinished() } } -void CoordinatePointEditorDialog::on_categoryEdit_editingFinished() +void CoordinatePointEditorDialog::on_groupEdit_editingFinished() { - _model->setCategory(ui->categoryEdit->text().toUtf8().constData()); + _model->setGroup(ui->groupEdit->text().toUtf8().constData()); updateUi(); } void CoordinatePointEditorDialog::on_shapeCombo_currentIndexChanged(int index) { - if (index < 0 || index >= static_cast(CoordinatePointShape::NUM_SHAPES)) + if (index < 0) return; - _model->setShape(static_cast(index)); + const int shape_id = ui->shapeCombo->itemData(index).toInt(); + _model->setShapeId(shape_id); + // Toggle which per-kind parameter rows are visible. + updateUi(); +} + +void CoordinatePointEditorDialog::on_sidesSpinBox_valueChanged(int value) +{ + _model->setSides(value); +} + +void CoordinatePointEditorDialog::on_pointsSpinBox_valueChanged(int value) +{ + _model->setPoints(value); +} + +void CoordinatePointEditorDialog::on_innerRadiusSpinBox_valueChanged(double value) +{ + _model->setInnerRadius(static_cast(value)); +} + +void CoordinatePointEditorDialog::on_angleSpinBox_valueChanged(double value) +{ + _model->setAngle(static_cast(value)); } void CoordinatePointEditorDialog::on_sizeSpinBox_valueChanged(double value) diff --git a/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.h b/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.h index 6c1ad41ed03..47cb3a32a66 100644 --- a/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.h +++ b/qtfred/src/ui/dialogs/CoordinatePointEditorDialog.h @@ -19,8 +19,12 @@ private slots: void on_prevPointButton_clicked(); void on_nextPointButton_clicked(); void on_nameEdit_editingFinished(); - void on_categoryEdit_editingFinished(); + void on_groupEdit_editingFinished(); void on_shapeCombo_currentIndexChanged(int index); + void on_sidesSpinBox_valueChanged(int value); + void on_pointsSpinBox_valueChanged(int value); + void on_innerRadiusSpinBox_valueChanged(double value); + void on_angleSpinBox_valueChanged(double value); void on_sizeSpinBox_valueChanged(double value); void on_escortPrioritySpinBox_valueChanged(int value); void on_multiTeamCombo_currentIndexChanged(int index); diff --git a/qtfred/ui/CoordinatePointEditorDialog.ui b/qtfred/ui/CoordinatePointEditorDialog.ui index a98cc1d2458..40d1590d8d4 100644 --- a/qtfred/ui/CoordinatePointEditorDialog.ui +++ b/qtfred/ui/CoordinatePointEditorDialog.ui @@ -102,17 +102,17 @@ - + - &Category + &Group - categoryEdit + groupEdit - + @@ -135,6 +135,119 @@ + + + Sides + + + Number of sides for an NGon shape. Only relevant when Shape is NGon. + + + sidesSpinBox + + + + + + + 2 + + + 32 + + + + + + + + + + Points + + + Number of points for a Star shape. Only relevant when Shape is Star. + + + pointsSpinBox + + + + + + + 2 + + + 32 + + + + + + + + + + Inner Radius + + + Inner-vertex radius for a Star shape, as a fraction of the outer radius. Only relevant when Shape is Star. + + + innerRadiusSpinBox + + + + + + + 3 + + + -0.010000000000000 + + + 0.950000000000000 + + + 0.050000000000000 + + + + + + + + + + Angle + + + In-plane rotation in degrees applied to the shape. + + + angleSpinBox + + + + + + + 2 + + + -360.000000000000000 + + + 360.000000000000000 + + + 5.000000000000000 + + + + S&ize @@ -144,14 +257,14 @@ - + 2 - + &Escort Priority @@ -161,21 +274,21 @@ - + 9999 - + &Visible in mission (renders in-game, target-in-front works) - + From 60c280a5c868bad02e6933388424ebfd3ccfc0ce Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Fri, 17 Jul 2026 10:58:54 -0500 Subject: [PATCH 13/19] sexp support --- code/missioneditor/sexp_tree_opf.cpp | 23 ++++ code/missioneditor/sexp_tree_opf.h | 1 + code/parse/sexp.cpp | 164 ++++++++++++++++++++++++++- code/parse/sexp.h | 6 +- 4 files changed, 190 insertions(+), 4 deletions(-) diff --git a/code/missioneditor/sexp_tree_opf.cpp b/code/missioneditor/sexp_tree_opf.cpp index 331318f65e0..aa3e3c2d404 100644 --- a/code/missioneditor/sexp_tree_opf.cpp +++ b/code/missioneditor/sexp_tree_opf.cpp @@ -3,6 +3,7 @@ #include "parse/sexp.h" #include "globalincs/linklist.h" +#include "coordinate_points/coordinate_point.h" #include "object/object.h" #include "object/waypoint.h" #include "ship/ship.h" @@ -229,6 +230,18 @@ sexp_list_item *SexpTreeOPF::get_listing_opf_point() return head.next; } +sexp_list_item *SexpTreeOPF::get_listing_opf_coordinate_point() +{ + sexp_list_item head; + + for (const auto &cp: Coordinate_points) + { + head.add_data(cp.name.c_str()); + } + + return head.next; +} + sexp_list_item *SexpTreeOPF::get_listing_opf_iff() { sexp_list_item head; @@ -736,6 +749,7 @@ sexp_list_item *SexpTreeOPF::get_listing_opf_ship_wing_point() const head.add_list(get_listing_opf_ship()); head.add_list(get_listing_opf_wing()); head.add_list(get_listing_opf_point()); + head.add_list(get_listing_opf_coordinate_point()); return head.next; } @@ -2067,6 +2081,10 @@ sexp_list_item *SexpTreeOPF::get_listing_opf(int opf, int parent_node, int arg_i list = get_listing_opf_point(); break; + case OPF_COORDINATE_POINT: + list = get_listing_opf_coordinate_point(); + break; + case OPF_IFF: list = get_listing_opf_iff(); break; @@ -2560,6 +2578,7 @@ int SexpTreeOPF::query_default_argument_available(int op, int i) const case OPF_PROP_CLASS_NAME: case OPF_HUGE_WEAPON: case OPF_JUMP_NODE_NAME: + case OPF_COORDINATE_POINT: case OPF_AMBIGUOUS: case OPF_CARGO: case OPF_ARRIVAL_LOCATION: @@ -3182,6 +3201,10 @@ int SexpTreeOPF::get_default_value(sexp_list_item* item, int op, int i) const str = ""; break; + case OPF_COORDINATE_POINT: + str = ""; + break; + case OPF_NAV_POINT: str = "