feat: 1995
This commit is contained in:
commit
fb89263930
69 changed files with 3359 additions and 0 deletions
14
.agents/commands/sketch.md
Normal file
14
.agents/commands/sketch.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
Execute an approved OpenSpec change.
|
||||
|
||||
Arguments:
|
||||
- `$1` = OpenSpec change id.
|
||||
- `--sync` = use Forgejo sync mode.
|
||||
- `--status` = summarize state only; do not edit files.
|
||||
|
||||
Load and follow the OpenSpec apply workflow and the `forge-sync` skill.
|
||||
|
||||
Rules:
|
||||
- Treat mode as explicit. Do not ask whether git workflow should be agent-managed.
|
||||
- Apply tasks from `openspec/changes/<change-id>/tasks.md`.
|
||||
- After each completed numbered task section, run a `forge-sync` checkpoint.
|
||||
- Do not archive the OpenSpec change.
|
||||
214
.agents/resources/commitlint.md
Normal file
214
.agents/resources/commitlint.md
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
@commitlint/config-conventional
|
||||
===============================
|
||||
|
||||
Lint your conventional commits
|
||||
|
||||
Shareable `commitlint` config enforcing [conventional commits](https://conventionalcommits.org/). Use with [@commitlint/cli](https://npm.im/@commitlint/cli) and [@commitlint/prompt-cli](https://npm.im/@commitlint/prompt-cli).
|
||||
|
||||
Getting started
|
||||
---------------
|
||||
|
||||
```
|
||||
npm install --save-dev @commitlint/config-conventional @commitlint/cli
|
||||
echo "export default {extends: \['@commitlint/config-conventional'\]};" \> commitlint.config.js
|
||||
```
|
||||
|
||||
Rules
|
||||
-----
|
||||
|
||||
### Problems
|
||||
|
||||
The following rules are considered problems for `@commitlint/config-conventional` and will yield a non-zero exit code when not met.
|
||||
|
||||
Consult [Rules reference](https://commitlint.js.org/reference/rules) for a list of available rules.
|
||||
|
||||
#### type-enum
|
||||
|
||||
- **condition**: `type` is found in value
|
||||
|
||||
- **rule**: `always`
|
||||
|
||||
- **level**: `error`
|
||||
|
||||
- **value**
|
||||
|
||||
```
|
||||
[
|
||||
'build',
|
||||
'chore',
|
||||
'ci',
|
||||
'docs',
|
||||
'feat',
|
||||
'fix',
|
||||
'perf',
|
||||
'refactor',
|
||||
'revert',
|
||||
'style',
|
||||
'test'
|
||||
];
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
echo "foo: some message" # fails
|
||||
echo "fix: some message" # passes
|
||||
```
|
||||
|
||||
#### type-case
|
||||
|
||||
- **description**: `type` is in case `value`
|
||||
- **rule**: `always`
|
||||
- **level**: `error`
|
||||
- **value**
|
||||
```
|
||||
'lowerCase'
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
echo "FIX: some message" # fails
|
||||
echo "fix: some message" # passes
|
||||
```
|
||||
|
||||
#### type-empty
|
||||
|
||||
- **condition**: `type` is empty
|
||||
- **rule**: `never`
|
||||
- **level**: `error`
|
||||
|
||||
```
|
||||
echo ": some message" # fails
|
||||
echo "fix: some message" # passes
|
||||
```
|
||||
|
||||
#### subject-case
|
||||
|
||||
- **condition**: `subject` is in one of the cases `['sentence-case', 'start-case', 'pascal-case', 'upper-case']`
|
||||
- **rule**: `never`
|
||||
- **level**: `error`
|
||||
|
||||
```
|
||||
echo "fix(SCOPE): Some message" # fails
|
||||
echo "fix(SCOPE): Some Message" # fails
|
||||
echo "fix(SCOPE): SomeMessage" # fails
|
||||
echo "fix(SCOPE): SOMEMESSAGE" # fails
|
||||
echo "fix(scope): some message" # passes
|
||||
echo "fix(scope): some Message" # passes
|
||||
```
|
||||
|
||||
#### subject-empty
|
||||
|
||||
- **condition**: `subject` is empty
|
||||
- **rule**: `never`
|
||||
- **level**: `error`
|
||||
|
||||
```
|
||||
echo "fix:" # fails
|
||||
echo "fix: some message" # passes
|
||||
```
|
||||
|
||||
#### subject-full-stop
|
||||
|
||||
- **condition**: `subject` ends with `value`
|
||||
- **rule**: `never`
|
||||
- **level**: `error`
|
||||
- **value**
|
||||
|
||||
```
|
||||
'.'
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
echo "fix: some message." # fails
|
||||
echo "fix: some message" # passes
|
||||
```
|
||||
|
||||
#### header-max-length
|
||||
|
||||
- **condition**: `header` has `value` or less characters
|
||||
- **rule**: `always`
|
||||
- **level**: `error`
|
||||
- **value**
|
||||
|
||||
```
|
||||
100
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
echo "fix: some message that is way too long and breaks the line max-length by several characters" # fails
|
||||
echo "fix: some message" # passes
|
||||
```
|
||||
|
||||
#### footer-leading-blank
|
||||
|
||||
- **condition**: `footer` should have a leading blank line
|
||||
- **rule**: `always`
|
||||
- **level**: `warning`
|
||||
|
||||
```
|
||||
echo "fix: some message
|
||||
BREAKING CHANGE: It will be significant" # warning
|
||||
|
||||
echo "fix: some message
|
||||
BREAKING CHANGE: It will be significant" # passes
|
||||
```
|
||||
|
||||
#### footer-max-line-length
|
||||
|
||||
- **condition**: `footer` each line has `value` or less characters
|
||||
- **rule**: `always`
|
||||
- **level**: `error`
|
||||
- **value**
|
||||
|
||||
```
|
||||
100
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
echo "fix: some message
|
||||
BREAKING CHANGE: footer with multiple lines
|
||||
has a message that is way too long and will break the line rule 'line-max-length' by several characters" # fails
|
||||
|
||||
echo "fix: some message
|
||||
BREAKING CHANGE: footer with multiple lines
|
||||
but still no line is too long" # passes
|
||||
```
|
||||
|
||||
#### body-leading-blank
|
||||
|
||||
- **condition**: `body` should have a leading blank line
|
||||
- **rule**: `always`
|
||||
- **level**: `warning`
|
||||
|
||||
```
|
||||
echo "fix: some message
|
||||
body" # warning
|
||||
|
||||
echo "fix: some message
|
||||
body" # passes
|
||||
```
|
||||
|
||||
#### body-max-line-length
|
||||
|
||||
- **condition**: `body` each line has `value` or less characters
|
||||
- **rule**: `always`
|
||||
- **level**: `error`
|
||||
- **value**
|
||||
|
||||
```
|
||||
100
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
echo "fix: some message
|
||||
body with multiple lines
|
||||
has a message that is way too long and will break the line rule 'line-max-length' by several characters" # fails
|
||||
|
||||
echo "fix: some message
|
||||
body with multiple lines
|
||||
but still no line is too long" # passes
|
||||
```
|
||||
128
.agents/resources/conventional-commit.md
Normal file
128
.agents/resources/conventional-commit.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
Conventional Commits 1.0.0
|
||||
==========================
|
||||
|
||||
Summary
|
||||
-------
|
||||
|
||||
The Conventional Commits specification is a lightweight convention on top of commit messages. It provides an easy set of rules for creating an explicit commit history; which makes it easier to write automated tools on top of. This convention dovetails with [SemVer](http://semver.org/), by describing the features, fixes, and breaking changes made in commit messages.
|
||||
|
||||
The commit message should be structured as follows:
|
||||
|
||||
* * * *
|
||||
|
||||
```
|
||||
<type>[optional scope]: <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
|
||||
```
|
||||
|
||||
* * * *
|
||||
|
||||
The commit contains the following structural elements, to communicate intent to the consumers of your library:
|
||||
|
||||
1. **fix:** a commit of the *type* `fix` patches a bug in your codebase (this correlates with [`PATCH`](http://semver.org/#summary) in Semantic Versioning).
|
||||
2. **feat:** a commit of the *type* `feat` introduces a new feature to the codebase (this correlates with [`MINOR`](http://semver.org/#summary) in Semantic Versioning).
|
||||
3. **BREAKING CHANGE:** a commit that has a footer `BREAKING CHANGE:`, or appends a `!` after the type/scope, introduces a breaking API change (correlating with [`MAJOR`](http://semver.org/#summary) in Semantic Versioning). A BREAKING CHANGE can be part of commits of any *type*.
|
||||
4. *types* other than `fix:` and `feat:` are allowed, for example [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional) (based on the [Angular convention](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#-commit-message-guidelines)) recommends `build:`, `chore:`, `ci:`, `docs:`, `style:`, `refactor:`, `perf:`, `test:`, and others.
|
||||
5. *footers* other than `BREAKING CHANGE: <description>` may be provided and follow a convention similar to [git trailer format](https://git-scm.com/docs/git-interpret-trailers).
|
||||
|
||||
Additional types are not mandated by the Conventional Commits specification, and have no implicit effect in Semantic Versioning (unless they include a BREAKING CHANGE). A scope may be provided to a commit's type, to provide additional contextual information and is contained within parenthesis, e.g., `feat(parser): add ability to parse arrays`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
### Commit message with description and breaking change footer
|
||||
|
||||
```
|
||||
feat: allow provided config object to extend other configs
|
||||
|
||||
BREAKING CHANGE: `extends` key in config file is now used for extending other config files
|
||||
|
||||
```
|
||||
|
||||
### Commit message with `!` to draw attention to breaking change
|
||||
|
||||
```
|
||||
feat!: send an email to the customer when a product is shipped
|
||||
|
||||
```
|
||||
|
||||
### Commit message with scope and `!` to draw attention to breaking change
|
||||
|
||||
```
|
||||
feat(api)!: send an email to the customer when a product is shipped
|
||||
|
||||
```
|
||||
|
||||
### Commit message with both `!` and BREAKING CHANGE footer
|
||||
|
||||
```
|
||||
feat!: drop support for Node 6
|
||||
|
||||
BREAKING CHANGE: use JavaScript features not available in Node 6.
|
||||
|
||||
```
|
||||
|
||||
### Commit message with no body
|
||||
|
||||
```
|
||||
docs: correct spelling of CHANGELOG
|
||||
|
||||
```
|
||||
|
||||
### Commit message with scope
|
||||
|
||||
```
|
||||
feat(lang): add Polish language
|
||||
|
||||
```
|
||||
|
||||
### Commit message with multi-paragraph body and multiple footers
|
||||
|
||||
```
|
||||
fix: prevent racing of requests
|
||||
|
||||
Introduce a request id and a reference to latest request. Dismiss
|
||||
incoming responses other than from latest request.
|
||||
|
||||
Remove timeouts which were used to mitigate the racing issue but are
|
||||
obsolete now.
|
||||
|
||||
Reviewed-by: Z
|
||||
Refs: #123
|
||||
|
||||
```
|
||||
|
||||
Specification
|
||||
-------------
|
||||
|
||||
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).
|
||||
|
||||
1. Commits MUST be prefixed with a type, which consists of a noun, `feat`, `fix`, etc., followed by the OPTIONAL scope, OPTIONAL `!`, and REQUIRED terminal colon and space.
|
||||
2. The type `feat` MUST be used when a commit adds a new feature to your application or library.
|
||||
3. The type `fix` MUST be used when a commit represents a bug fix for your application.
|
||||
4. A scope MAY be provided after a type. A scope MUST consist of a noun describing a section of the codebase surrounded by parenthesis, e.g., `fix(parser):`
|
||||
5. A description MUST immediately follow the colon and space after the type/scope prefix. The description is a short summary of the code changes, e.g., *fix: array parsing issue when multiple spaces were contained in string*.
|
||||
6. A longer commit body MAY be provided after the short description, providing additional contextual information about the code changes. The body MUST begin one blank line after the description.
|
||||
7. A commit body is free-form and MAY consist of any number of newline separated paragraphs.
|
||||
8. One or more footers MAY be provided one blank line after the body. Each footer MUST consist of a word token, followed by either a `:<space>` or `<space>#` separator, followed by a string value (this is inspired by the [git trailer convention](https://git-scm.com/docs/git-interpret-trailers)).
|
||||
9. A footer's token MUST use `-` in place of whitespace characters, e.g., `Acked-by` (this helps differentiate the footer section from a multi-paragraph body). An exception is made for `BREAKING CHANGE`, which MAY also be used as a token.
|
||||
10. A footer's value MAY contain spaces and newlines, and parsing MUST terminate when the next valid footer token/separator pair is observed.
|
||||
11. Breaking changes MUST be indicated in the type/scope prefix of a commit, or as an entry in the footer.
|
||||
12. If included as a footer, a breaking change MUST consist of the uppercase text BREAKING CHANGE, followed by a colon, space, and description, e.g., *BREAKING CHANGE: environment variables now take precedence over config files*.
|
||||
13. If included in the type/scope prefix, breaking changes MUST be indicated by a `!` immediately before the `:`. If `!` is used, `BREAKING CHANGE:` MAY be omitted from the footer section, and the commit description SHALL be used to describe the breaking change.
|
||||
14. Types other than `feat` and `fix` MAY be used in your commit messages, e.g., *docs: update ref docs.*
|
||||
15. The units of information that make up Conventional Commits MUST NOT be treated as case-sensitive by implementors, with the exception of BREAKING CHANGE which MUST be uppercase.
|
||||
16. BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE, when used as a token in a footer.
|
||||
|
||||
Why Use Conventional Commits
|
||||
----------------------------
|
||||
|
||||
- Automatically generating CHANGELOGs.
|
||||
- Automatically determining a semantic version bump (based on the types of commits landed).
|
||||
- Communicating the nature of changes to teammates, the public, and other stakeholders.
|
||||
- Triggering build and publish processes.
|
||||
- Making it easier for people to contribute to your projects, by allowing them to explore a more structured commit history.
|
||||
7
.agents/rules/big-red-dog.md
Normal file
7
.agents/rules/big-red-dog.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# A way to check whether my instructions are loaded into context automatically
|
||||
|
||||
the big red dog is named Salamander
|
||||
9
.agents/rules/caveman.md
Normal file
9
.agents/rules/caveman.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
description: Always load the caveman skill for concise technical responses.
|
||||
---
|
||||
|
||||
# Caveman Mode
|
||||
|
||||
Use the `caveman` skill for every response.
|
||||
|
||||
Stop only when the user explicitly says `stop caveman` or `normal mode`.
|
||||
11
.agents/rules/commits.md
Normal file
11
.agents/rules/commits.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
condition: "(?i)git commit|commit message|conventional commit"
|
||||
---
|
||||
|
||||
# Commits
|
||||
|
||||
When commiting changes, follow these rules to write the messages:
|
||||
|
||||
- Conventional Commit (reference available at .ai/resources/conventional-commit.md)
|
||||
- `@commitlint/config-conventional` (reference available at .ai/resources/commitlint.md)
|
||||
|
||||
29
.agents/rules/forge-sync.md
Normal file
29
.agents/rules/forge-sync.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
description: Local vs sync mode rules for git and Forgejo — commit, push, and PR policy per workflow.
|
||||
---
|
||||
|
||||
# Forge Sync Rules
|
||||
|
||||
When a workflow uses explicit mode:
|
||||
|
||||
- `Mode: sync` or `--sync` means sync mode.
|
||||
- no sync marker means local mode.
|
||||
- Do not ask whether git workflow should be agent-managed.
|
||||
|
||||
Local mode:
|
||||
- No push.
|
||||
- No forge write tools.
|
||||
- No PR create/update/comment.
|
||||
|
||||
Sync mode:
|
||||
- Commit completed workflow units.
|
||||
- Push after each unit commit.
|
||||
- Create PR after first push if missing.
|
||||
- Reuse existing PR on later pushes.
|
||||
- Comment/update PR only when workflow asks for it.
|
||||
|
||||
Forbidden always:
|
||||
- No force push.
|
||||
- No branch deletion.
|
||||
- No hard reset.
|
||||
- No amending unless explicitly requested.
|
||||
19
.agents/rules/openspec.md
Normal file
19
.agents/rules/openspec.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
description: Rules for OpenSpec propose and archive workflows.
|
||||
---
|
||||
|
||||
# OpenSpec Instructions
|
||||
|
||||
## OpenSpec Propose Workflow
|
||||
|
||||
When proposing changes via `/opsx-propose`:
|
||||
|
||||
- If the change introduces or modifies user-facing behavior (API changes, conventions), include a task section for updating `README.md` in the proposal's impact assessment.
|
||||
|
||||
## OpenSpec Archive Workflow
|
||||
|
||||
When archiving tasks via `/opsx-archive`:
|
||||
|
||||
1. Automatically sync specs, do not ask the user.
|
||||
2. If mode is explicit, use `forge-sync` for archive commit, push, PR, and Forgejo behavior.
|
||||
3. If no mode is explicit, default to local mode.
|
||||
34
.agents/rules/quality.md
Normal file
34
.agents/rules/quality.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Quality Guardrails
|
||||
|
||||
Use these while building. Do not turn every task into a full review; apply as lightweight pressure before adding or changing structure.
|
||||
|
||||
## Module Shape
|
||||
|
||||
- Filenames should match the primary export exactly, including casing.
|
||||
- Exports and higher level functions should appear before private functions in a file.
|
||||
- Prefer a primary type plus matching namespace for behavior tied to that type.
|
||||
- Functions that operate on a domain type should live in that type's namespace.
|
||||
- Avoid scattered single-name function exports for domain-specific behavior.
|
||||
- Truly generic helpers may stay standalone when they do not naturally belong to a domain type.
|
||||
|
||||
## Design Pressure
|
||||
|
||||
- DRY: centralize rules and policy, not incidental similarity.
|
||||
- KISS: prefer the smallest clear structure that solves the actual problem.
|
||||
- YAGNI: do not add speculative extension points, options, compatibility layers, or abstractions.
|
||||
- SOC: keep parsing, validation, IO, orchestration, and policy separate when mixing them creates change pressure.
|
||||
- Cohesion: each module/type/function should have one clear job and one clear reason to change.
|
||||
- Coupling: avoid making callers know protocol internals, nested implementation details, or unrelated runtime policy.
|
||||
- Locality: one behavior should not require excessive jumping across unrelated files or helpers.
|
||||
- Naming/API Clarity: names should expose behavior and domain meaning; avoid vague wrappers, false promises, and boolean-blind APIs.
|
||||
|
||||
## Refactor Bias
|
||||
|
||||
- Prefer moving behavior to the domain owner over creating utility bags.
|
||||
- Prefer local private helpers over exported helpers until another real caller exists.
|
||||
- Prefer deleting compatibility code when there are no shipped consumers, persisted data, or explicit requirements.
|
||||
- Prefer small reshapes that preserve behavior over broad rewrites.
|
||||
48
.agents/skills/caveman/README.md
Normal file
48
.agents/skills/caveman/README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# caveman
|
||||
|
||||
Talk like smart caveman. Same brain, fewer tokens.
|
||||
|
||||
## What it does
|
||||
|
||||
Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts 65% of output tokens (measured) with full accuracy preserved. Mode persists for the whole session until changed or stopped.
|
||||
|
||||
Six intensity levels:
|
||||
|
||||
| Level | What change |
|
||||
|-------|-------------|
|
||||
| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. |
|
||||
| `full` | Default. Drop articles, fragments OK, short synonyms. |
|
||||
| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. |
|
||||
| `wenyan-lite` | Classical Chinese register, light compression. |
|
||||
| `wenyan-full` | Maximum 文言文. 80-90% character reduction. |
|
||||
| `wenyan-ultra` | Extreme classical compression. |
|
||||
|
||||
Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part.
|
||||
|
||||
## How to invoke
|
||||
|
||||
```
|
||||
/caveman # full mode (default)
|
||||
/caveman lite # lighter compression
|
||||
/caveman ultra # extreme compression
|
||||
/caveman wenyan # classical Chinese
|
||||
stop caveman # back to normal prose
|
||||
```
|
||||
|
||||
## Example output
|
||||
|
||||
Question: "Why does my React component re-render?"
|
||||
|
||||
Normal prose:
|
||||
> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the issue.
|
||||
|
||||
Caveman (full):
|
||||
> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`.
|
||||
|
||||
Caveman (ultra):
|
||||
> Inline obj prop → new ref → re-render. `useMemo`.
|
||||
|
||||
## See also
|
||||
|
||||
- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions
|
||||
- [Caveman README](../../README.md) — repo overview, install, benchmarks
|
||||
78
.agents/skills/caveman/SKILL.md
Normal file
78
.agents/skills/caveman/SKILL.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
name: caveman
|
||||
description: >
|
||||
Ultra-compressed communication mode. Cuts output tokens 65% (measured) by speaking like caveman
|
||||
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
|
||||
wenyan-lite, wenyan-full, wenyan-ultra.
|
||||
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
|
||||
---
|
||||
|
||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
## Persistence
|
||||
|
||||
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
|
||||
|
||||
Default: **full**. Switch: `/caveman lite|full|ultra`.
|
||||
|
||||
## Rules
|
||||
|
||||
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations (cfg/impl/req/res/fn) — tokenizer split them same as full word: zero token saved, reader still decode. Full word cheaper AND clearer. No causal arrows (→) either — own token, save nothing. Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||
|
||||
Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation.
|
||||
|
||||
No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is.
|
||||
|
||||
Pattern: `[thing] [action] [reason]. [next step].`
|
||||
|
||||
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
||||
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||
|
||||
## Intensity
|
||||
|
||||
| Level | What change |
|
||||
|-------|------------|
|
||||
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
||||
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations |
|
||||
| **ultra** | Strip conjunctions when cause-then-effect stay unambiguous. One word when one word enough. State each fact once. NO prose abbreviations (cfg/impl/req/res/fn/auth), NO arrows (X → Y) — measured zero token saving under tokenizer, cost decode clarity. Code symbols, function names, API names, error strings: never touch |
|
||||
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
|
||||
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
|
||||
|
||||
Example — "Why React component re-render?"
|
||||
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
- ultra: "Inline obj prop, new ref, re-render. `useMemo`."
|
||||
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
|
||||
- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。"
|
||||
- wenyan-ultra: "新參照則重繪。useMemo 包之。"
|
||||
|
||||
Example — "Explain database connection pooling."
|
||||
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
||||
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
||||
- ultra: "Pool reuse open DB connections. No per-request handshake."
|
||||
- wenyan-full: "池蓄已開之連,不逐請而新開,省握手之費。"
|
||||
- wenyan-ultra: "池蓄連,免逐請新開,省握手。"
|
||||
|
||||
## Auto-Clarity
|
||||
|
||||
Drop caveman when:
|
||||
- Security warnings
|
||||
- Irreversible action confirmations
|
||||
- Multi-step sequences where fragment order or omitted conjunctions risk misread
|
||||
- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions)
|
||||
- User asks to clarify or repeats question
|
||||
|
||||
Resume caveman after clear part done.
|
||||
|
||||
Example — destructive op:
|
||||
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||
> ```sql
|
||||
> DROP TABLE users;
|
||||
> ```
|
||||
> Caveman resume. Verify backup exist first.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||
47
.agents/skills/domain-modeling/ADR-FORMAT.md
Normal file
47
.agents/skills/domain-modeling/ADR-FORMAT.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# ADR Format
|
||||
|
||||
ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc.
|
||||
|
||||
Create the `docs/adr/` directory lazily — only when the first ADR is needed.
|
||||
|
||||
## Template
|
||||
|
||||
```md
|
||||
# {Short title of the decision}
|
||||
|
||||
{1-3 sentences: what's the context, what did we decide, and why.}
|
||||
```
|
||||
|
||||
That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections.
|
||||
|
||||
## Optional sections
|
||||
|
||||
Only include these when they add genuine value. Most ADRs won't need them.
|
||||
|
||||
- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited
|
||||
- **Considered Options** — only when the rejected alternatives are worth remembering
|
||||
- **Consequences** — only when non-obvious downstream effects need to be called out
|
||||
|
||||
## Numbering
|
||||
|
||||
Scan `docs/adr/` for the highest existing number and increment by one.
|
||||
|
||||
## When to offer an ADR
|
||||
|
||||
All three of these must be true:
|
||||
|
||||
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
||||
2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?"
|
||||
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
||||
|
||||
If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing."
|
||||
|
||||
### What qualifies
|
||||
|
||||
- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres."
|
||||
- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP."
|
||||
- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out.
|
||||
- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s.
|
||||
- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate.
|
||||
- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract."
|
||||
- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months.
|
||||
60
.agents/skills/domain-modeling/CONTEXT-FORMAT.md
Normal file
60
.agents/skills/domain-modeling/CONTEXT-FORMAT.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# CONTEXT.md Format
|
||||
|
||||
## Structure
|
||||
|
||||
```md
|
||||
# {Context Name}
|
||||
|
||||
{One or two sentence description of what this context is and why it exists.}
|
||||
|
||||
## Language
|
||||
|
||||
**Order**:
|
||||
{A one or two sentence description of the term}
|
||||
_Avoid_: Purchase, transaction
|
||||
|
||||
**Invoice**:
|
||||
A request for payment sent to a customer after delivery.
|
||||
_Avoid_: Bill, payment request
|
||||
|
||||
**Customer**:
|
||||
A person or organization that places orders.
|
||||
_Avoid_: Client, buyer, account
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others under `_Avoid_`.
|
||||
- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does.
|
||||
- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs.
|
||||
- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine.
|
||||
|
||||
## Single vs multi-context repos
|
||||
|
||||
**Single context (most repos):** One `CONTEXT.md` at the repo root.
|
||||
|
||||
**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other:
|
||||
|
||||
```md
|
||||
# Context Map
|
||||
|
||||
## Contexts
|
||||
|
||||
- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders
|
||||
- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments
|
||||
- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping
|
||||
|
||||
## Relationships
|
||||
|
||||
- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking
|
||||
- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices
|
||||
- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money`
|
||||
```
|
||||
|
||||
The skill infers which structure applies:
|
||||
|
||||
- If `CONTEXT-MAP.md` exists, read it to find contexts
|
||||
- If only a root `CONTEXT.md` exists, single context
|
||||
- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved
|
||||
|
||||
When multiple contexts exist, infer which one the current topic relates to. If unclear, ask.
|
||||
74
.agents/skills/domain-modeling/SKILL.md
Normal file
74
.agents/skills/domain-modeling/SKILL.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
---
|
||||
name: domain-modeling
|
||||
description: Build and sharpen a project's domain model. Use when the user wants to pin down domain terminology or a ubiquitous language, record an architectural decision, or when another skill needs to maintain the domain model.
|
||||
---
|
||||
|
||||
# Domain Modeling
|
||||
|
||||
Actively build and sharpen the project's domain model as you design. This is the *active* discipline — challenging terms, inventing edge-case scenarios, and writing the glossary and decisions down the moment they crystallise. (Merely *reading* `CONTEXT.md` for vocabulary is not this skill — that's a one-line habit any skill can do. This skill is for when you're changing the model, not just consuming it.)
|
||||
|
||||
## File structure
|
||||
|
||||
Most repos have a single context:
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/
|
||||
│ └── adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives:
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT-MAP.md
|
||||
├── docs/
|
||||
│ └── adr/ ← system-wide decisions
|
||||
├── src/
|
||||
│ ├── ordering/
|
||||
│ │ ├── CONTEXT.md
|
||||
│ │ └── docs/adr/ ← context-specific decisions
|
||||
│ └── billing/
|
||||
│ ├── CONTEXT.md
|
||||
│ └── docs/adr/
|
||||
```
|
||||
|
||||
Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed.
|
||||
|
||||
## During the session
|
||||
|
||||
### Challenge against the glossary
|
||||
|
||||
When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"
|
||||
|
||||
### Sharpen fuzzy language
|
||||
|
||||
When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."
|
||||
|
||||
### Discuss concrete scenarios
|
||||
|
||||
When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.
|
||||
|
||||
### Cross-reference with code
|
||||
|
||||
When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"
|
||||
|
||||
### Update CONTEXT.md inline
|
||||
|
||||
When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).
|
||||
|
||||
`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.
|
||||
|
||||
### Offer ADRs sparingly
|
||||
|
||||
Only offer to create an ADR when all three are true:
|
||||
|
||||
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
||||
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
|
||||
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
||||
|
||||
If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).
|
||||
3
.agents/skills/domain-modeling/agents/openai.yaml
Normal file
3
.agents/skills/domain-modeling/agents/openai.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
interface:
|
||||
display_name: "Domain Modeling"
|
||||
short_description: "Build and sharpen a domain model"
|
||||
94
.agents/skills/forge-sync/SKILL.md
Normal file
94
.agents/skills/forge-sync/SKILL.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
name: forge-sync
|
||||
description: Use for `sketch --sync`, `sketch-sync`, or workflows needing shared local-vs-forge git handling; centralizes branch, commit, push, PR, and forge comment policy.
|
||||
---
|
||||
|
||||
# Forge Sync
|
||||
|
||||
Forge Sync = shared git + forge workflow policy. It decides how completed workflow units become commits, pushes, PRs, and PR comments. It does not decide when code work is complete.
|
||||
|
||||
## Inputs
|
||||
|
||||
Caller must provide or make clear:
|
||||
|
||||
- Mode: `local` or `sync`.
|
||||
- Change ID.
|
||||
- Workflow: `sketch`, `archive`, or other.
|
||||
- Unit: task section, archive, or named completed work unit.
|
||||
- Commit message.
|
||||
- Optional PR comment body.
|
||||
|
||||
If mode is explicit, do not ask git workflow questions.
|
||||
|
||||
## Universal Rules
|
||||
|
||||
- Never force-push.
|
||||
- Never delete branches.
|
||||
- Never run destructive git commands.
|
||||
- Never amend unless explicitly requested.
|
||||
- Before committing, inspect status and diff.
|
||||
- Do not include unrelated user changes.
|
||||
- Commit messages must follow Conventional Commit and commitlint rules.
|
||||
|
||||
## Local Mode
|
||||
|
||||
Local mode means:
|
||||
|
||||
- Do not push.
|
||||
- Do not create, update, or comment on PRs.
|
||||
- Do not call forge write tools.
|
||||
- Create local commits only when caller requests a completed unit commit.
|
||||
- If unrelated changes exist, commit only files/hunks belonging to current unit.
|
||||
|
||||
## Sync Mode
|
||||
|
||||
Sync mode means:
|
||||
|
||||
- Ensure work happens on a feature branch for the change when caller has not already selected one.
|
||||
- Commit completed unit.
|
||||
- Push current branch after each unit commit.
|
||||
- Create PR after first push if no PR exists.
|
||||
- Reuse existing PR on later pushes.
|
||||
- Add or update PR comment when caller provides comment body.
|
||||
|
||||
Default branch name when creating one:
|
||||
|
||||
```txt
|
||||
feat/<change-id>
|
||||
```
|
||||
|
||||
Default PR base:
|
||||
|
||||
```txt
|
||||
main
|
||||
```
|
||||
|
||||
Do not create duplicate PRs. Check branch/PR state first when feasible.
|
||||
|
||||
## Checkpoint Procedure
|
||||
|
||||
When caller says a unit is complete:
|
||||
|
||||
1. Inspect git status and diff.
|
||||
2. Stage only relevant files.
|
||||
3. Commit with caller-provided message.
|
||||
4. If mode is local, stop.
|
||||
5. If mode is sync, push current branch.
|
||||
6. If PR comment body provided, comment or update as directed by caller.
|
||||
7. Report commit SHA, push status, PR URL/number, and comment status.
|
||||
|
||||
## PR Comment Shape
|
||||
|
||||
Use caller-provided body when available. If caller asks for a default comment:
|
||||
|
||||
```md
|
||||
## <Workflow> <Unit>
|
||||
|
||||
Change: `<change-id>`
|
||||
|
||||
Commits:
|
||||
- `<sha>` <subject>
|
||||
|
||||
Verification:
|
||||
- <summary>
|
||||
```
|
||||
7
.agents/skills/grill-with-docs/SKILL.md
Normal file
7
.agents/skills/grill-with-docs/SKILL.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
name: grill-with-docs
|
||||
description: A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Run a `/grilling` session, using the `/domain-modeling` skill.
|
||||
5
.agents/skills/grill-with-docs/agents/openai.yaml
Normal file
5
.agents/skills/grill-with-docs/agents/openai.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
interface:
|
||||
display_name: "Grill with Docs"
|
||||
short_description: "Grill a design and write its docs"
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
12
.agents/skills/grilling/SKILL.md
Normal file
12
.agents/skills/grilling/SKILL.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
name: grilling
|
||||
description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
|
||||
---
|
||||
|
||||
Interview me relentlessly about every aspect of this until we reach a shared understanding. Walk down each branch of the decision tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
|
||||
|
||||
Ask the questions one at a time, waiting for feedback on each question before continuing. Asking multiple questions at once is bewildering.
|
||||
|
||||
If a *fact* can be found by exploring the environment (filesystem, tools, etc.), look it up rather than asking me. The *decisions*, though, are mine — put each one to me and wait for my answer.
|
||||
|
||||
Do not act on it until I confirm we have reached a shared understanding.
|
||||
3
.agents/skills/grilling/agents/openai.yaml
Normal file
3
.agents/skills/grilling/agents/openai.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
interface:
|
||||
display_name: "Grilling"
|
||||
short_description: "Stress-test thinking one question at a time"
|
||||
119
.agents/skills/jerklint/SKILL.md
Normal file
119
.agents/skills/jerklint/SKILL.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
name: jerklint
|
||||
description: Use when user asks for "jerklint", "jerk lint", or strict code-quality review. Reviews code against DRY, KISS, YAGNI, SOC, cohesion, coupling, dependency direction, Law of Demeter, immutability, declarative shape, implicit contracts, abstraction pressure, naming/API clarity, and locality.
|
||||
---
|
||||
|
||||
# Jerklint
|
||||
|
||||
Jerklint = strict code-quality review. Be blunt, specific, fair. Findings must be actionable, not taste fights.
|
||||
|
||||
## Trigger
|
||||
|
||||
Use this skill when user asks for:
|
||||
- `jerklint`
|
||||
- `jerk lint`
|
||||
- strict code-quality review
|
||||
- review against DRY, KISS, YAGNI, SOC, cohesion, coupling, dependency direction, Law of Demeter, immutability, declarative programming, or maintainability principles
|
||||
|
||||
## Goal
|
||||
|
||||
Find code smells, design pressure, and maintainability risk. This is not normal bug review. Bugs matter only when they reveal deeper code-quality failure.
|
||||
|
||||
## Axes
|
||||
|
||||
- DRY: flag duplicated policy, copy-paste structure, repeated literals, or drift-prone validation. Do not centralize incidental similarity.
|
||||
- KISS: flag needless indirection, clever control flow, over-generalization, and logic that is harder than its problem.
|
||||
- YAGNI: flag speculative extension points, options, abstractions, or compatibility layers with no concrete need.
|
||||
- SOC: flag mixed responsibilities, especially parsing + validation + IO + orchestration + policy in one unit.
|
||||
- Cohesion: flag functions/types/modules that do not have one clear job or reason to change.
|
||||
- Coupling: flag unnecessary knowledge between layers, callers, protocols, domains, or runtime details.
|
||||
- Dependency Direction: flag lower-level code depending on higher-level policy, domain declarations depending on adapters, or circular conceptual flow.
|
||||
- Law of Demeter: flag long object walks, dependency spelunking, and callers that know too much about nested internals.
|
||||
- Immutability / State Discipline: prefer immutable boundaries and local mutation only. Flag shared mutable state, hidden mutation, aliasing risk, and mutation that creates temporal coupling.
|
||||
- Declarative Shape: favor data/config descriptions for policy and protocol surfaces; keep execution/IO separate. Flag imperative branching where a small table/schema/declaration would clarify rules.
|
||||
- Implicit Contracts / Temporal Coupling: flag hidden ordering requirements, call rituals, required prior validation, or invariants not encoded in type/name/API.
|
||||
- Abstraction Pressure: flag both over-centralization and under-centralization. Centralize rules, not whole workflows.
|
||||
- Naming/API Clarity: flag names that hide behavior, false promises, vague abstractions, boolean blindness, or weak error messages.
|
||||
- Locality: flag code that requires excessive jumping across files/functions to understand one behavior.
|
||||
- Testability: flag structure that forces brittle tests, excessive mocking, or untestable policy logic.
|
||||
- File/API Shape: flag domain-specific function bags, filenames that do not match primary exports, and behavior that should live under a matching type namespace.
|
||||
- Predicate Accuracy: flag boolean predicates/guards that check partial or wrong shape. Flag `in` checks without type narrowing, truthiness checks that miss falsy valid values, and type guards that accept broader input than their name promises. Prefer predicates that validate the full claimed shape.
|
||||
- Construction Phase Separation: flag builder/factory functions that execute more than three distinct sequential phases in one function body without named phase boundaries. Flag protocol creation, config extraction, normalization, wiring, and return shaping collapsed into one function. Prefer named phase functions even when each is small.
|
||||
|
||||
## Project Style Conventions
|
||||
|
||||
Apply these conventions when reviewing module/API shape:
|
||||
- File/Export Match: filenames should match the primary export exactly, including casing. Example: `Field.ts` exports `Field`.
|
||||
- Type Namespace Cohesion: prefer a primary type plus matching namespace for behavior tied to that type. Example: `Field` + `namespace Field`.
|
||||
- Domain Function Locality: functions that operate on a domain type should live in that type's namespace instead of as loose exports.
|
||||
- Avoid Function Bags: avoid scattered single-name function exports for domain-specific behavior. Group them under the relevant domain type.
|
||||
- Generic Helper Exception: truly generic helpers may remain standalone when they do not naturally belong to a domain type.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Do not focus on:
|
||||
- formatting nits
|
||||
- style preferences without maintenance impact
|
||||
- security bugs unless structure caused them
|
||||
- correctness bugs unless they reveal quality smell
|
||||
- broad rewrites without concrete pressure
|
||||
- purity dogma: mutation and imperative code are fine when local, clear, and bounded
|
||||
|
||||
## Method
|
||||
|
||||
1. Read target file first.
|
||||
2. Read direct collaborators only when needed to validate design pressure.
|
||||
3. Prefer evidence from code over principle recitation.
|
||||
4. Rank findings by maintainability impact.
|
||||
5. Cite file/line refs.
|
||||
6. Suggest smallest useful direction, not full rewrites.
|
||||
7. If no findings, say so and name strongest qualities.
|
||||
|
||||
## Output
|
||||
|
||||
Findings first. Keep summary secondary.
|
||||
|
||||
```markdown
|
||||
**Jerklint Findings**
|
||||
1. **High** `path:line`: Smell. Why it hurts. Better direction.
|
||||
|
||||
**Scorecard**
|
||||
- DRY: pass/concern/fail - one phrase.
|
||||
- KISS: pass/concern/fail - one phrase.
|
||||
- YAGNI: pass/concern/fail - one phrase.
|
||||
- SOC: pass/concern/fail - one phrase.
|
||||
- Cohesion: pass/concern/fail - one phrase.
|
||||
- Coupling: pass/concern/fail - one phrase.
|
||||
- Dependency Direction: pass/concern/fail - one phrase.
|
||||
- Law of Demeter: pass/concern/fail - one phrase.
|
||||
- Immutability: pass/concern/fail - one phrase.
|
||||
- Declarative Shape: pass/concern/fail - one phrase.
|
||||
- Implicit Contracts: pass/concern/fail - one phrase.
|
||||
- Abstraction Pressure: pass/concern/fail - one phrase.
|
||||
- Naming/API Clarity: pass/concern/fail - one phrase.
|
||||
- Locality: pass/concern/fail - one phrase.
|
||||
- Predicate Accuracy: pass/concern/fail - one phrase.
|
||||
- Construction Phase Separation: pass/concern/fail - one phrase.
|
||||
|
||||
**Verdict**
|
||||
Keep / minor refactor / refactor soon / rethink.
|
||||
```
|
||||
|
||||
## Severity
|
||||
|
||||
- High: smell creates likely drift, hard-to-change design, boundary violation, or hidden invariant across callers.
|
||||
- Medium: smell adds avoidable complexity or makes future work risky but is local.
|
||||
- Low: polish-level maintainability concern worth noting only if concrete.
|
||||
|
||||
## Calibration
|
||||
|
||||
- Do not chant DRY. Duplication can be clearer than wrong abstraction.
|
||||
- Do not chant KISS. Simpler locally can be worse globally if it duplicates policy.
|
||||
- Do not chant YAGNI. Keep extension points when existing architecture already requires them.
|
||||
- Do not chant immutability. Local accumulators are fine when ownership is clear.
|
||||
- Do not chant declarative programming. Imperative steps are fine when sequencing is core behavior.
|
||||
- Prefer "centralize this rule" over "make a manager".
|
||||
- Prefer "split parsing from execution" over "add layers".
|
||||
- Do not limit DRY to textual duplication. Flag structural duplication: two functions that recursively traverse the same tree shape with different leaf transforms.
|
||||
- Under Implicit Contracts, flag magic DI object shapes where binder and consumer agree on structure by convention without an exported type or binding token.
|
||||
- Under Implicit Contracts, flag whitelist-vs-blocklist asymmetry where two code paths encoding the same conceptual boundary use different inclusion strategies.
|
||||
1
.claude/skills
Symbolic link
1
.claude/skills
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../.agents/skills
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
*.tsbuildinfo
|
||||
dist
|
||||
.opencode
|
||||
builds
|
||||
1
.husky/commit-msg
Normal file
1
.husky/commit-msg
Normal file
|
|
@ -0,0 +1 @@
|
|||
bunx --no-install -- commitlint --edit $1
|
||||
7
CLAUDE.md
Normal file
7
CLAUDE.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
@AGENTS.md
|
||||
@.agents/rules/big-red-dog.md
|
||||
@.agents/rules/caveman.md
|
||||
@.agents/rules/commits.md
|
||||
@.agents/rules/forge-sync.md
|
||||
@.agents/rules/openspec.md
|
||||
@.agents/rules/quality.md
|
||||
137
LICENSE.md
Normal file
137
LICENSE.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Proprietary Software License
|
||||
|
||||
Copyright © 2026 SIGITEX. All rights reserved.
|
||||
|
||||
## 1. Proprietary and Confidential Material
|
||||
|
||||
This software, including all source code, object code, documentation, configuration files, data models, specifications, designs, interfaces, scripts, assets, and related materials (collectively, the **“Software”**), is the proprietary and confidential property of SIGITEX (**“Organization”**).
|
||||
|
||||
The Software contains confidential information and trade secrets belonging to the Organization. It is provided solely for authorized internal use and must be handled in accordance with the Organization’s information-security, confidentiality, data-protection, and acceptable-use policies.
|
||||
|
||||
## 2. Authorized Users
|
||||
|
||||
Access to and use of the Software are limited to:
|
||||
|
||||
1. employees of the Organization;
|
||||
2. contractors, consultants, and service providers who have been expressly authorized by the Organization; and
|
||||
3. other persons who have received prior written authorization from the Organization.
|
||||
|
||||
Authorization is limited to the scope, purpose, systems, and duration approved by the Organization.
|
||||
|
||||
## 3. Limited Internal-Use License
|
||||
|
||||
Subject to continued authorization and compliance with this license, the Organization grants Authorized Users a limited, revocable, non-exclusive, non-transferable, and non-sublicensable right to access, execute, reproduce, and modify the Software solely:
|
||||
|
||||
1. for the Organization’s internal business purposes;
|
||||
2. within systems, environments, accounts, and repositories approved by the Organization; and
|
||||
3. as necessary to perform work for the Organization.
|
||||
|
||||
No ownership rights are transferred under this license.
|
||||
|
||||
## 4. Restrictions
|
||||
|
||||
Except where expressly authorized in writing by the Organization, no person may:
|
||||
|
||||
1. disclose, publish, distribute, transmit, sell, license, sublicense, lease, assign, transfer, or otherwise make the Software available to any external person or entity;
|
||||
2. upload or copy the Software to any public repository, personal repository, public file-sharing service, unapproved cloud service, or externally accessible system;
|
||||
3. use the Software for personal purposes or for the benefit of any third party;
|
||||
4. copy the Software except as reasonably necessary for an authorized internal purpose;
|
||||
5. remove, obscure, or alter any copyright, confidentiality, attribution, ownership, or proprietary-rights notice;
|
||||
6. reverse engineer, decompile, disassemble, or otherwise attempt to derive components of the Software, except to the extent required for authorized development or expressly permitted by applicable law;
|
||||
7. incorporate the Software into any externally distributed product, service, deliverable, or open-source project;
|
||||
8. use the Software to create, train, improve, evaluate, or supply an external artificial-intelligence or machine-learning system without prior written approval;
|
||||
9. provide the Software to an external vendor or automated service unless the Organization has approved both the vendor and the specific use; or
|
||||
10. use the Software in violation of applicable law, contractual obligations, or Organization policy.
|
||||
|
||||
## 5. Confidentiality
|
||||
|
||||
Authorized Users must:
|
||||
|
||||
1. protect the Software using at least the same degree of care used to protect the Organization’s other confidential information, and no less than reasonable care;
|
||||
2. disclose the Software only to persons who are authorized and have a legitimate need to know;
|
||||
3. promptly report any suspected loss, unauthorized access, disclosure, copying, or distribution; and
|
||||
4. comply with all applicable confidentiality agreements and Organization policies.
|
||||
|
||||
The confidentiality obligations in this license survive termination of access, employment, engagement, or authorization.
|
||||
|
||||
## 6. Third-Party Components
|
||||
|
||||
The Software may include third-party materials governed by separate licenses. Those licenses apply only to the relevant third-party materials.
|
||||
|
||||
Nothing in this license restricts rights granted directly under an applicable third-party license. All original portions of the Software created or owned by the Organization remain subject to this proprietary license.
|
||||
|
||||
Authorized Users must not introduce third-party code, data, models, assets, or dependencies into the Software unless their use has been reviewed and approved under the Organization’s applicable policies.
|
||||
|
||||
## 7. Ownership
|
||||
|
||||
The Organization retains all rights, title, and interest in and to the Software, including all copyrights, patent rights, trade-secret rights, trademarks, database rights, and other intellectual-property rights.
|
||||
|
||||
To the extent permitted by applicable law and any governing employment or contractor agreement, all modifications, enhancements, derivative works, fixes, documentation, and other contributions made in connection with authorized work for the Organization are owned exclusively by the Organization.
|
||||
|
||||
## 8. Security and Access Control
|
||||
|
||||
Authorized Users must not:
|
||||
|
||||
1. share credentials or access tokens;
|
||||
2. circumvent access controls, monitoring systems, technical restrictions, or security measures;
|
||||
3. retain unauthorized local copies, backups, exports, credentials, secrets, or production data; or
|
||||
4. access the Software after authorization has expired or been revoked.
|
||||
|
||||
The Organization may monitor, audit, limit, suspend, or revoke access to the Software at any time.
|
||||
|
||||
## 9. Termination and Return of Materials
|
||||
|
||||
Authorization under this license terminates immediately when:
|
||||
|
||||
1. the Organization revokes access;
|
||||
2. the Authorized User’s employment, engagement, or approved role ends;
|
||||
3. the authorized purpose ends; or
|
||||
4. the Authorized User breaches this license or an applicable Organization policy.
|
||||
|
||||
Upon termination, the Authorized User must immediately stop using the Software and, as directed by the Organization, return or permanently delete all copies in the Authorized User’s possession or control, subject to applicable legal-retention requirements.
|
||||
|
||||
## 10. No External Rights
|
||||
|
||||
Possession of or access to the Software does not grant any right to use, copy, modify, disclose, or distribute it beyond the limited authorization expressly stated in this license.
|
||||
|
||||
No rights are granted by implication, estoppel, exhaustion, or otherwise.
|
||||
|
||||
Any external use, disclosure, licensing, distribution, or commercialization requires a separate written agreement signed by an authorized representative of the Organization.
|
||||
|
||||
## 11. Disclaimer
|
||||
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, THE SOFTWARE IS PROVIDED **“AS IS”** AND **“AS AVAILABLE,”** WITHOUT WARRANTIES OF ANY KIND, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE.
|
||||
|
||||
THE ORGANIZATION DISCLAIMS ALL IMPLIED WARRANTIES, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, ACCURACY, SECURITY, AND NON-INFRINGEMENT.
|
||||
|
||||
This disclaimer does not limit obligations that the Organization cannot lawfully exclude.
|
||||
|
||||
## 12. Limitation of Liability
|
||||
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, THE ORGANIZATION AND ITS AFFILIATES, OFFICERS, DIRECTORS, EMPLOYEES, AND AGENTS WILL NOT BE LIABLE UNDER THIS LICENSE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR FOR ANY LOSS OF DATA, PROFITS, REVENUE, BUSINESS, OR GOODWILL.
|
||||
|
||||
Nothing in this section excludes or limits liability that cannot lawfully be excluded or limited.
|
||||
|
||||
## 13. Remedies
|
||||
|
||||
Unauthorized use or disclosure of the Software may cause irreparable harm for which monetary damages may be inadequate.
|
||||
|
||||
The Organization may seek injunctive or equitable relief, in addition to any other remedies available under contract, statute, common law, or Organization policy.
|
||||
|
||||
## 14. Governing Law
|
||||
|
||||
This license is governed by the laws of The United States, without regard to its conflict-of-laws rules.
|
||||
|
||||
## 15. General Terms
|
||||
|
||||
If any provision of this license is held unenforceable, that provision will be enforced to the maximum extent permitted, and the remaining provisions will remain in effect.
|
||||
|
||||
A failure by the Organization to enforce any provision is not a waiver of that provision or any other right.
|
||||
|
||||
In the event of a conflict between this license and a separately executed written agreement, the separately executed agreement controls to the extent of the conflict.
|
||||
|
||||
---
|
||||
|
||||
**NOTICE: PROPRIETARY AND CONFIDENTIAL**
|
||||
|
||||
Unauthorized access, use, copying, modification, disclosure, or distribution of this Software is prohibited.
|
||||
2
README.md
Normal file
2
README.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# MEAT
|
||||
|
||||
45
app/assets.ts
Normal file
45
app/assets.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { Texture } from "../engine/render/Texture"
|
||||
import crateUrl from "../assets/crate.png"
|
||||
import floorUrl from "../assets/floor.png"
|
||||
import npcUrl from "../assets/npc.png"
|
||||
import wallUrl from "../assets/wall.png"
|
||||
|
||||
export type Textures = {
|
||||
floor: Texture
|
||||
wall: Texture
|
||||
crate: Texture
|
||||
npc: Texture
|
||||
}
|
||||
|
||||
/** Load every game texture up front. Call once before starting the loop. */
|
||||
export async function loadTextures(): Promise<Textures> {
|
||||
const [floor, wall, crate, npc] = await Promise.all([
|
||||
loadTexture(floorUrl),
|
||||
loadTexture(wallUrl),
|
||||
loadTexture(crateUrl),
|
||||
loadTexture(npcUrl),
|
||||
])
|
||||
return { floor, wall, crate, npc }
|
||||
}
|
||||
|
||||
function loadTexture(url: string): Promise<Texture> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.addEventListener("load", () => resolve(toTexture(image)))
|
||||
image.addEventListener("error", () => reject(new Error(`failed to load ${url}`)))
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
/** Draw a loaded image into a canvas and read its pixels back as a Texture.
|
||||
* ImageData bytes are RGBA, identical to how Color packs a Uint32, so the
|
||||
* buffer is reused directly with no per-pixel conversion. */
|
||||
function toTexture(image: HTMLImageElement): Texture {
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
const ctx = canvas.getContext("2d")!
|
||||
ctx.drawImage(image, 0, 0)
|
||||
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
return { width: pixels.width, height: pixels.height, data: new Uint32Array(pixels.data.buffer) }
|
||||
}
|
||||
140
app/level.ts
Normal file
140
app/level.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { Color } from "../engine/render/Color"
|
||||
import type { SkyConfig } from "../engine/render/Sky"
|
||||
import type { Mesh, Vertex } from "../engine/scene/Mesh"
|
||||
|
||||
type Corner = [number, number, number]
|
||||
|
||||
/** Axis-aligned solid. Blocks the player horizontally while their feet are
|
||||
* below `top`; if `standable`, its `top` also counts as ground to land on. */
|
||||
export type Aabb = {
|
||||
minX: number
|
||||
maxX: number
|
||||
minZ: number
|
||||
maxZ: number
|
||||
top: number
|
||||
standable: boolean
|
||||
}
|
||||
|
||||
/** The playground: geometry split by texture, its collision solids, where the
|
||||
* NPC stands, and the sky to draw behind it. */
|
||||
export type Level = {
|
||||
floor: Mesh
|
||||
walls: Mesh
|
||||
crate: Mesh
|
||||
colliders: Aabb[]
|
||||
npcPosition: { x: number; y: number; z: number }
|
||||
sky: SkyConfig
|
||||
}
|
||||
|
||||
const ARENA = 12
|
||||
const WALL_HEIGHT = 5
|
||||
const CRATE = { x: -2, z: -2, half: 1, top: 1 }
|
||||
|
||||
export function buildLevel(): Level {
|
||||
const floor = mesh()
|
||||
quadGrid(floor, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [-ARENA, 0, ARENA], 12, 12, 16)
|
||||
|
||||
const walls = mesh()
|
||||
const h = WALL_HEIGHT
|
||||
// Inward-facing perimeter, no ceiling so the sky shows above.
|
||||
quadGrid(walls, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, h, -ARENA], [-ARENA, h, -ARENA], 12, 2.5, 12)
|
||||
quadGrid(walls, [ARENA, 0, ARENA], [-ARENA, 0, ARENA], [-ARENA, h, ARENA], [ARENA, h, ARENA], 12, 2.5, 12)
|
||||
quadGrid(walls, [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [ARENA, h, ARENA], [ARENA, h, -ARENA], 12, 2.5, 12)
|
||||
quadGrid(walls, [-ARENA, 0, ARENA], [-ARENA, 0, -ARENA], [-ARENA, h, -ARENA], [-ARENA, h, ARENA], 12, 2.5, 12)
|
||||
|
||||
const crate = mesh()
|
||||
box(crate, CRATE.x, CRATE.z, CRATE.half, CRATE.top)
|
||||
|
||||
const colliders: Aabb[] = [
|
||||
wall(-ARENA, ARENA, -ARENA, -ARENA + 1),
|
||||
wall(-ARENA, ARENA, ARENA - 1, ARENA),
|
||||
wall(ARENA - 1, ARENA, -ARENA, ARENA),
|
||||
wall(-ARENA, -ARENA + 1, -ARENA, ARENA),
|
||||
{
|
||||
minX: CRATE.x - CRATE.half,
|
||||
maxX: CRATE.x + CRATE.half,
|
||||
minZ: CRATE.z - CRATE.half,
|
||||
maxZ: CRATE.z + CRATE.half,
|
||||
top: CRATE.top,
|
||||
standable: true,
|
||||
},
|
||||
]
|
||||
|
||||
const sky: SkyConfig = {
|
||||
zenith: Color.rgb(58, 108, 196),
|
||||
horizon: Color.rgb(178, 198, 226),
|
||||
sun: Color.rgb(255, 246, 214),
|
||||
sunDir: { x: 0.3, y: 0.5, z: -0.8 },
|
||||
sunSize: 0.04,
|
||||
}
|
||||
|
||||
return { floor, walls, crate, colliders, npcPosition: { x: 2, y: 0, z: -1 }, sky }
|
||||
}
|
||||
|
||||
function mesh(): Mesh {
|
||||
return { vertices: [], indices: [] }
|
||||
}
|
||||
|
||||
function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb {
|
||||
return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: false }
|
||||
}
|
||||
|
||||
/** A quad tessellated into an n*n grid so affine texture warp stays per-tile.
|
||||
* Corners run a (uv 0,0) -> b (us,0) -> c (us,vs) -> d (0,vs). */
|
||||
function quadGrid(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number, n: number): void {
|
||||
const base = m.vertices.length
|
||||
const row = n + 1
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const t = i / n
|
||||
for (let j = 0; j <= n; j++) {
|
||||
const s = j / n
|
||||
const wa = (1 - s) * (1 - t)
|
||||
const wb = s * (1 - t)
|
||||
const wc = s * t
|
||||
const wd = (1 - s) * t
|
||||
m.vertices.push({
|
||||
pos: {
|
||||
x: a[0] * wa + b[0] * wb + c[0] * wc + d[0] * wd,
|
||||
y: a[1] * wa + b[1] * wb + c[1] * wc + d[1] * wd,
|
||||
z: a[2] * wa + b[2] * wb + c[2] * wc + d[2] * wd,
|
||||
},
|
||||
uv: { x: us * s, y: vs * t },
|
||||
})
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = 0; j < n; j++) {
|
||||
const p = base + i * row + j
|
||||
m.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A box centered at (cx, cz) on the floor: top face plus four sides, one uv
|
||||
* tile per face. No bottom (never seen). */
|
||||
function box(m: Mesh, cx: number, cz: number, half: number, top: number): void {
|
||||
const x0 = cx - half
|
||||
const x1 = cx + half
|
||||
const z0 = cz - half
|
||||
const z1 = cz + half
|
||||
quad(m, [x0, top, z0], [x1, top, z0], [x1, top, z1], [x0, top, z1])
|
||||
quad(m, [x0, 0, z0], [x1, 0, z0], [x1, top, z0], [x0, top, z0])
|
||||
quad(m, [x1, 0, z1], [x0, 0, z1], [x0, top, z1], [x1, top, z1])
|
||||
quad(m, [x1, 0, z0], [x1, 0, z1], [x1, top, z1], [x1, top, z0])
|
||||
quad(m, [x0, 0, z1], [x0, 0, z0], [x0, top, z0], [x0, top, z1])
|
||||
}
|
||||
|
||||
function quad(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner): void {
|
||||
const base = m.vertices.length
|
||||
const corners: [Corner, [number, number]][] = [
|
||||
[a, [0, 0]],
|
||||
[b, [1, 0]],
|
||||
[c, [1, 1]],
|
||||
[d, [0, 1]],
|
||||
]
|
||||
for (const [pos, uv] of corners) {
|
||||
const vertex: Vertex = { pos: { x: pos[0], y: pos[1], z: pos[2] }, uv: { x: uv[0], y: uv[1] } }
|
||||
m.vertices.push(vertex)
|
||||
}
|
||||
m.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
|
||||
}
|
||||
112
app/main.ts
Normal file
112
app/main.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { Framebuffer } from "../engine/render/Framebuffer"
|
||||
import { Rasterizer } from "../engine/render/Rasterizer"
|
||||
import { RenderConfig } from "../engine/render/RenderConfig"
|
||||
import { Sky } from "../engine/render/Sky"
|
||||
import { Camera } from "../engine/scene/Camera"
|
||||
import { Sprite } from "../engine/scene/Sprite"
|
||||
import { loadTextures } from "./assets"
|
||||
import { buildLevel } from "./level"
|
||||
import { EYE_HEIGHT, Player } from "./player"
|
||||
|
||||
const FOV = Math.PI / 3
|
||||
|
||||
const screen = document.querySelector<HTMLCanvasElement>("#screen")!
|
||||
const ctx = screen.getContext("2d")!
|
||||
const back = document.createElement("canvas")
|
||||
const backCtx = back.getContext("2d")!
|
||||
|
||||
let config: RenderConfig = RenderConfig.psxish
|
||||
let fb = Framebuffer.create(1, 1)
|
||||
let image = new ImageData(1, 1)
|
||||
|
||||
function useConfig(next: RenderConfig): void {
|
||||
config = next
|
||||
fb = Framebuffer.create(config.internalWidth, config.internalHeight)
|
||||
back.width = fb.width
|
||||
back.height = fb.height
|
||||
image = new ImageData(new Uint8ClampedArray(fb.color.buffer as ArrayBuffer), fb.width, fb.height)
|
||||
}
|
||||
|
||||
function resize(): void {
|
||||
screen.width = globalThis.innerWidth
|
||||
screen.height = globalThis.innerHeight
|
||||
}
|
||||
|
||||
function present(): void {
|
||||
backCtx.putImageData(image, 0, 0)
|
||||
const scale = Math.max(1, Math.floor(Math.min(screen.width / fb.width, screen.height / fb.height)))
|
||||
const w = fb.width * scale
|
||||
const h = fb.height * scale
|
||||
const x = (screen.width - w) >> 1
|
||||
const y = (screen.height - h) >> 1
|
||||
ctx.imageSmoothingEnabled = config.upscaleFilter === "linear"
|
||||
ctx.clearRect(0, 0, screen.width, screen.height)
|
||||
ctx.drawImage(back, x, y, w, h)
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const textures = await loadTextures()
|
||||
const level = buildLevel()
|
||||
const npc: Sprite = { position: level.npcPosition, size: { x: 1.1, y: 1.5 }, texture: textures.npc }
|
||||
const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true }
|
||||
|
||||
const keys = new Set<string>()
|
||||
globalThis.addEventListener("keydown", (e) => {
|
||||
keys.add(e.code)
|
||||
if (e.code === "Digit1") {
|
||||
useConfig(RenderConfig.psxish)
|
||||
}
|
||||
if (e.code === "Digit2") {
|
||||
useConfig(RenderConfig.soft)
|
||||
}
|
||||
if (e.code === "Digit3") {
|
||||
useConfig(RenderConfig.clean)
|
||||
}
|
||||
})
|
||||
globalThis.addEventListener("keyup", (e) => {
|
||||
keys.delete(e.code)
|
||||
})
|
||||
screen.addEventListener("click", () => {
|
||||
screen.requestPointerLock()
|
||||
})
|
||||
globalThis.addEventListener("mousemove", (e) => {
|
||||
if (document.pointerLockElement !== screen) {
|
||||
return
|
||||
}
|
||||
player.yaw += e.movementX * 0.0025
|
||||
player.pitch = Math.max(-1.4, Math.min(1.4, player.pitch - e.movementY * 0.0025))
|
||||
})
|
||||
|
||||
useConfig(config)
|
||||
globalThis.addEventListener("resize", resize)
|
||||
resize()
|
||||
|
||||
let last = performance.now()
|
||||
function frame(now: number): void {
|
||||
const dt = Math.min(0.05, (now - last) / 1000)
|
||||
last = now
|
||||
Player.update(player, keys, dt, level)
|
||||
|
||||
const camera: Camera = {
|
||||
position: { x: player.position.x, y: player.position.y + EYE_HEIGHT, z: player.position.z },
|
||||
yaw: player.yaw,
|
||||
pitch: player.pitch,
|
||||
fov: FOV,
|
||||
}
|
||||
const viewProj = Camera.viewProjection(camera, fb.width / fb.height)
|
||||
|
||||
Sky.render(fb, camera, level.sky)
|
||||
Rasterizer.draw(fb, level.floor, textures.floor, viewProj, config)
|
||||
Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config)
|
||||
Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config)
|
||||
Rasterizer.draw(fb, Sprite.billboard(npc, camera), textures.npc, viewProj, config)
|
||||
Framebuffer.quantize(fb, config)
|
||||
present()
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
146
app/player.ts
Normal file
146
app/player.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import type { Vec3 } from "../engine/math/Vec3"
|
||||
import type { Aabb, Level } from "./level"
|
||||
|
||||
/** The player as a vertical cylinder. `position` is at the feet; the camera
|
||||
* eye sits EYE_HEIGHT above it. */
|
||||
export type Player = {
|
||||
position: Vec3
|
||||
yaw: number
|
||||
pitch: number
|
||||
velocityY: number
|
||||
onGround: boolean
|
||||
}
|
||||
|
||||
export const EYE_HEIGHT = 1.6
|
||||
const RADIUS = 0.35
|
||||
const SPEED = 4
|
||||
const GRAVITY = 22
|
||||
const JUMP_SPEED = 8
|
||||
const NPC_RADIUS = 0.5
|
||||
|
||||
export namespace Player {
|
||||
/** Advance the player one frame: jump, horizontal move + collision, gravity. */
|
||||
export function update(player: Player, keys: Set<string>, dt: number, level: Level): void {
|
||||
if (keys.has("Space") && player.onGround) {
|
||||
player.velocityY = JUMP_SPEED
|
||||
player.onGround = false
|
||||
}
|
||||
moveHorizontal(player, keys, dt)
|
||||
collide(player, level)
|
||||
fall(player, dt, level)
|
||||
}
|
||||
|
||||
function moveHorizontal(player: Player, keys: Set<string>, dt: number): void {
|
||||
const speed = SPEED * dt
|
||||
const fx = Math.sin(player.yaw)
|
||||
const fz = -Math.cos(player.yaw)
|
||||
const rx = Math.cos(player.yaw)
|
||||
const rz = Math.sin(player.yaw)
|
||||
const p = player.position
|
||||
if (keys.has("KeyW")) {
|
||||
p.x += fx * speed
|
||||
p.z += fz * speed
|
||||
}
|
||||
if (keys.has("KeyS")) {
|
||||
p.x -= fx * speed
|
||||
p.z -= fz * speed
|
||||
}
|
||||
if (keys.has("KeyD")) {
|
||||
p.x += rx * speed
|
||||
p.z += rz * speed
|
||||
}
|
||||
if (keys.has("KeyA")) {
|
||||
p.x -= rx * speed
|
||||
p.z -= rz * speed
|
||||
}
|
||||
}
|
||||
|
||||
/** Push the player's circle out of any solid it overlaps: level colliders it
|
||||
* is not standing above, and the NPC. This is what makes walls and the NPC
|
||||
* impassable while still letting you stand on the crate. */
|
||||
function collide(player: Player, level: Level): void {
|
||||
for (const aabb of level.colliders) {
|
||||
if (player.position.y < aabb.top - 0.01) {
|
||||
pushFromAabb(player.position, aabb)
|
||||
}
|
||||
}
|
||||
pushFromCircle(player.position, level.npcPosition.x, level.npcPosition.z, NPC_RADIUS)
|
||||
}
|
||||
|
||||
/** Apply gravity and land on the highest ground under the player. */
|
||||
function fall(player: Player, dt: number, level: Level): void {
|
||||
player.velocityY -= GRAVITY * dt
|
||||
player.position.y += player.velocityY * dt
|
||||
const ground = groundHeight(player.position, level)
|
||||
if (player.position.y <= ground) {
|
||||
player.position.y = ground
|
||||
player.velocityY = 0
|
||||
player.onGround = true
|
||||
} else {
|
||||
player.onGround = false
|
||||
}
|
||||
}
|
||||
|
||||
function groundHeight(position: Vec3, level: Level): number {
|
||||
let ground = 0
|
||||
for (const aabb of level.colliders) {
|
||||
if (
|
||||
aabb.standable &&
|
||||
position.x >= aabb.minX &&
|
||||
position.x <= aabb.maxX &&
|
||||
position.z >= aabb.minZ &&
|
||||
position.z <= aabb.maxZ
|
||||
) {
|
||||
ground = Math.max(ground, aabb.top)
|
||||
}
|
||||
}
|
||||
return ground
|
||||
}
|
||||
|
||||
function pushFromAabb(position: Vec3, aabb: Aabb): void {
|
||||
const cx = Math.max(aabb.minX, Math.min(aabb.maxX, position.x))
|
||||
const cz = Math.max(aabb.minZ, Math.min(aabb.maxZ, position.z))
|
||||
const dx = position.x - cx
|
||||
const dz = position.z - cz
|
||||
const d2 = dx * dx + dz * dz
|
||||
if (d2 >= RADIUS * RADIUS) {
|
||||
return
|
||||
}
|
||||
if (d2 > 1e-6) {
|
||||
const d = Math.sqrt(d2)
|
||||
const push = (RADIUS - d) / d
|
||||
position.x += dx * push
|
||||
position.z += dz * push
|
||||
return
|
||||
}
|
||||
// Center is inside the box: eject through the nearest face.
|
||||
const left = position.x - aabb.minX
|
||||
const rightSide = aabb.maxX - position.x
|
||||
const near = position.z - aabb.minZ
|
||||
const far = aabb.maxZ - position.z
|
||||
const m = Math.min(left, rightSide, near, far)
|
||||
if (m === left) {
|
||||
position.x = aabb.minX - RADIUS
|
||||
} else if (m === rightSide) {
|
||||
position.x = aabb.maxX + RADIUS
|
||||
} else if (m === near) {
|
||||
position.z = aabb.minZ - RADIUS
|
||||
} else {
|
||||
position.z = aabb.maxZ + RADIUS
|
||||
}
|
||||
}
|
||||
|
||||
function pushFromCircle(position: Vec3, cx: number, cz: number, otherRadius: number): void {
|
||||
const dx = position.x - cx
|
||||
const dz = position.z - cz
|
||||
const reach = RADIUS + otherRadius
|
||||
const d2 = dx * dx + dz * dz
|
||||
if (d2 >= reach * reach || d2 < 1e-6) {
|
||||
return
|
||||
}
|
||||
const d = Math.sqrt(d2)
|
||||
const push = (reach - d) / d
|
||||
position.x += dx * push
|
||||
position.z += dz * push
|
||||
}
|
||||
}
|
||||
BIN
assets/crate.png
Normal file
BIN
assets/crate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
BIN
assets/floor.png
Normal file
BIN
assets/floor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
assets/npc.png
Normal file
BIN
assets/npc.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/wall.png
Normal file
BIN
assets/wall.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
400
bun.lock
Normal file
400
bun.lock
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "meat",
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.5.3",
|
||||
"@commitlint/config-conventional": "^20.5.3",
|
||||
"@types/bun": "^1.3.13",
|
||||
"husky": "^9.1.7",
|
||||
"oxfmt": "^0.47.0",
|
||||
"oxlint": "^1.62.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@commitlint/cli": ["@commitlint/cli@20.5.3", "", { "dependencies": { "@commitlint/format": "^20.5.0", "@commitlint/lint": "^20.5.3", "@commitlint/load": "^20.5.3", "@commitlint/read": "^20.5.0", "@commitlint/types": "^20.5.0", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA=="],
|
||||
|
||||
"@commitlint/config-conventional": ["@commitlint/config-conventional@20.5.3", "", { "dependencies": { "@commitlint/types": "^20.5.0", "conventional-changelog-conventionalcommits": "^9.2.0" } }, "sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ=="],
|
||||
|
||||
"@commitlint/config-validator": ["@commitlint/config-validator@20.5.0", "", { "dependencies": { "@commitlint/types": "^20.5.0", "ajv": "^8.11.0" } }, "sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw=="],
|
||||
|
||||
"@commitlint/ensure": ["@commitlint/ensure@20.5.3", "", { "dependencies": { "@commitlint/types": "^20.5.0", "es-toolkit": "^1.46.0" } }, "sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw=="],
|
||||
|
||||
"@commitlint/execute-rule": ["@commitlint/execute-rule@20.0.0", "", {}, "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw=="],
|
||||
|
||||
"@commitlint/format": ["@commitlint/format@20.5.0", "", { "dependencies": { "@commitlint/types": "^20.5.0", "picocolors": "^1.1.1" } }, "sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q=="],
|
||||
|
||||
"@commitlint/is-ignored": ["@commitlint/is-ignored@20.5.0", "", { "dependencies": { "@commitlint/types": "^20.5.0", "semver": "^7.6.0" } }, "sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg=="],
|
||||
|
||||
"@commitlint/lint": ["@commitlint/lint@20.5.3", "", { "dependencies": { "@commitlint/is-ignored": "^20.5.0", "@commitlint/parse": "^20.5.0", "@commitlint/rules": "^20.5.3", "@commitlint/types": "^20.5.0" } }, "sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw=="],
|
||||
|
||||
"@commitlint/load": ["@commitlint/load@20.5.3", "", { "dependencies": { "@commitlint/config-validator": "^20.5.0", "@commitlint/execute-rule": "^20.0.0", "@commitlint/resolve-extends": "^20.5.3", "@commitlint/types": "^20.5.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "es-toolkit": "^1.46.0", "is-plain-obj": "^4.1.0", "picocolors": "^1.1.1" } }, "sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ=="],
|
||||
|
||||
"@commitlint/message": ["@commitlint/message@20.4.3", "", {}, "sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ=="],
|
||||
|
||||
"@commitlint/parse": ["@commitlint/parse@20.5.0", "", { "dependencies": { "@commitlint/types": "^20.5.0", "conventional-changelog-angular": "^8.2.0", "conventional-commits-parser": "^6.3.0" } }, "sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA=="],
|
||||
|
||||
"@commitlint/read": ["@commitlint/read@20.5.0", "", { "dependencies": { "@commitlint/top-level": "^20.4.3", "@commitlint/types": "^20.5.0", "git-raw-commits": "^5.0.0", "minimist": "^1.2.8", "tinyexec": "^1.0.0" } }, "sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w=="],
|
||||
|
||||
"@commitlint/resolve-extends": ["@commitlint/resolve-extends@20.5.3", "", { "dependencies": { "@commitlint/config-validator": "^20.5.0", "@commitlint/types": "^20.5.0", "es-toolkit": "^1.46.0", "global-directory": "^5.0.0", "import-meta-resolve": "^4.0.0", "resolve-from": "^5.0.0" } }, "sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew=="],
|
||||
|
||||
"@commitlint/rules": ["@commitlint/rules@20.5.3", "", { "dependencies": { "@commitlint/ensure": "^20.5.3", "@commitlint/message": "^20.4.3", "@commitlint/to-lines": "^20.0.0", "@commitlint/types": "^20.5.0" } }, "sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ=="],
|
||||
|
||||
"@commitlint/to-lines": ["@commitlint/to-lines@20.0.0", "", {}, "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw=="],
|
||||
|
||||
"@commitlint/top-level": ["@commitlint/top-level@20.4.3", "", { "dependencies": { "escalade": "^3.2.0" } }, "sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ=="],
|
||||
|
||||
"@commitlint/types": ["@commitlint/types@20.5.0", "", { "dependencies": { "conventional-commits-parser": "^6.3.0", "picocolors": "^1.1.1" } }, "sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA=="],
|
||||
|
||||
"@conventional-changelog/git-client": ["@conventional-changelog/git-client@2.7.0", "", { "dependencies": { "@simple-libs/child-process-utils": "^1.0.0", "@simple-libs/stream-utils": "^1.2.0", "semver": "^7.5.2" }, "peerDependencies": { "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.4.0" }, "optionalPeers": ["conventional-commits-filter", "conventional-commits-parser"] }, "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@2.0.0-alpha.3", "", { "dependencies": { "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@2.0.0-alpha.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@2.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.142.0", "", {}, "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.47.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw=="],
|
||||
|
||||
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.47.0", "", { "os": "android", "cpu": "arm64" }, "sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ=="],
|
||||
|
||||
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.47.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g=="],
|
||||
|
||||
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.47.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg=="],
|
||||
|
||||
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.47.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw=="],
|
||||
|
||||
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.47.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.47.0", "", { "os": "linux", "cpu": "none" }, "sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.47.0", "", { "os": "linux", "cpu": "none" }, "sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g=="],
|
||||
|
||||
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.47.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ=="],
|
||||
|
||||
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.47.0", "", { "os": "none", "cpu": "arm64" }, "sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ=="],
|
||||
|
||||
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.47.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg=="],
|
||||
|
||||
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.47.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw=="],
|
||||
|
||||
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.47.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.76.0", "", { "os": "android", "cpu": "arm" }, "sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.76.0", "", { "os": "android", "cpu": "arm64" }, "sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.76.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.76.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.76.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.76.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.76.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw=="],
|
||||
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.76.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.76.0", "", { "os": "linux", "cpu": "none" }, "sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.76.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ=="],
|
||||
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.76.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.76.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.76.0", "", { "os": "linux", "cpu": "x64" }, "sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.76.0", "", { "os": "none", "cpu": "arm64" }, "sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.76.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw=="],
|
||||
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.76.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ=="],
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.76.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.1", "", { "os": "none", "cpu": "arm64" }, "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.2.1", "", { "dependencies": { "@emnapi/core": "2.0.0-alpha.3", "@emnapi/runtime": "2.0.0-alpha.3", "@napi-rs/wasm-runtime": "^1.2.0" } }, "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@1.0.2", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0" } }, "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw=="],
|
||||
|
||||
"@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
|
||||
|
||||
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||
|
||||
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||
|
||||
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
|
||||
|
||||
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
|
||||
|
||||
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
|
||||
|
||||
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
|
||||
|
||||
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
|
||||
|
||||
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
|
||||
|
||||
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
|
||||
|
||||
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
|
||||
|
||||
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
|
||||
|
||||
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
|
||||
|
||||
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
|
||||
|
||||
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
|
||||
|
||||
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
|
||||
|
||||
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
|
||||
|
||||
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
|
||||
|
||||
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
|
||||
|
||||
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
|
||||
|
||||
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
|
||||
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="],
|
||||
|
||||
"conventional-changelog-angular": ["conventional-changelog-angular@8.3.1", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg=="],
|
||||
|
||||
"conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@9.3.1", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw=="],
|
||||
|
||||
"conventional-commits-parser": ["conventional-commits-parser@6.4.0", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { "conventional-commits-parser": "dist/cli/index.js" } }, "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw=="],
|
||||
|
||||
"cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="],
|
||||
|
||||
"cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@6.3.0", "", { "dependencies": { "jiti": "2.6.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.50.0", "", {}, "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"git-raw-commits": ["git-raw-commits@5.0.1", "", { "dependencies": { "@conventional-changelog/git-client": "^2.6.0", "meow": "^13.0.0" }, "bin": { "git-raw-commits": "src/cli.js" } }, "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ=="],
|
||||
|
||||
"global-directory": ["global-directory@5.0.0", "", { "dependencies": { "ini": "6.0.0" } }, "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w=="],
|
||||
|
||||
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||
|
||||
"ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||
|
||||
"oxfmt": ["oxfmt@0.47.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.47.0", "@oxfmt/binding-android-arm64": "0.47.0", "@oxfmt/binding-darwin-arm64": "0.47.0", "@oxfmt/binding-darwin-x64": "0.47.0", "@oxfmt/binding-freebsd-x64": "0.47.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.47.0", "@oxfmt/binding-linux-arm-musleabihf": "0.47.0", "@oxfmt/binding-linux-arm64-gnu": "0.47.0", "@oxfmt/binding-linux-arm64-musl": "0.47.0", "@oxfmt/binding-linux-ppc64-gnu": "0.47.0", "@oxfmt/binding-linux-riscv64-gnu": "0.47.0", "@oxfmt/binding-linux-riscv64-musl": "0.47.0", "@oxfmt/binding-linux-s390x-gnu": "0.47.0", "@oxfmt/binding-linux-x64-gnu": "0.47.0", "@oxfmt/binding-linux-x64-musl": "0.47.0", "@oxfmt/binding-openharmony-arm64": "0.47.0", "@oxfmt/binding-win32-arm64-msvc": "0.47.0", "@oxfmt/binding-win32-ia32-msvc": "0.47.0", "@oxfmt/binding-win32-x64-msvc": "0.47.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA=="],
|
||||
|
||||
"oxlint": ["oxlint@1.76.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.76.0", "@oxlint/binding-android-arm64": "1.76.0", "@oxlint/binding-darwin-arm64": "1.76.0", "@oxlint/binding-darwin-x64": "1.76.0", "@oxlint/binding-freebsd-x64": "1.76.0", "@oxlint/binding-linux-arm-gnueabihf": "1.76.0", "@oxlint/binding-linux-arm-musleabihf": "1.76.0", "@oxlint/binding-linux-arm64-gnu": "1.76.0", "@oxlint/binding-linux-arm64-musl": "1.76.0", "@oxlint/binding-linux-ppc64-gnu": "1.76.0", "@oxlint/binding-linux-riscv64-gnu": "1.76.0", "@oxlint/binding-linux-riscv64-musl": "1.76.0", "@oxlint/binding-linux-s390x-gnu": "1.76.0", "@oxlint/binding-linux-x64-gnu": "1.76.0", "@oxlint/binding-linux-x64-musl": "1.76.0", "@oxlint/binding-openharmony-arm64": "1.76.0", "@oxlint/binding-win32-arm64-msvc": "1.76.0", "@oxlint/binding-win32-ia32-msvc": "1.76.0", "@oxlint/binding-win32-x64-msvc": "1.76.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.1", "", { "dependencies": { "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.1", "@rolldown/binding-darwin-arm64": "1.2.1", "@rolldown/binding-darwin-x64": "1.2.1", "@rolldown/binding-freebsd-x64": "1.2.1", "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", "@rolldown/binding-linux-arm64-gnu": "1.2.1", "@rolldown/binding-linux-arm64-musl": "1.2.1", "@rolldown/binding-linux-ppc64-gnu": "1.2.1", "@rolldown/binding-linux-s390x-gnu": "1.2.1", "@rolldown/binding-linux-x64-gnu": "1.2.1", "@rolldown/binding-linux-x64-musl": "1.2.1", "@rolldown/binding-openharmony-arm64": "1.2.1", "@rolldown/binding-wasm32-wasi": "1.2.1", "@rolldown/binding-win32-arm64-msvc": "1.2.1", "@rolldown/binding-win32-x64-msvc": "1.2.1" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw=="],
|
||||
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
"vite": ["vite@8.2.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.23", "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
}
|
||||
}
|
||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[test]
|
||||
pathIgnorePatterns = ["dist/**"]
|
||||
3
commitlint.config.ts
Normal file
3
commitlint.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
extends: ["@commitlint/config-conventional"]
|
||||
}
|
||||
72
engine/math/Mat4.ts
Normal file
72
engine/math/Mat4.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Vec3 } from "./Vec3"
|
||||
|
||||
/** 4x4 matrix in column-major storage: index = col * 4 + row, matching OpenGL
|
||||
* conventions so the standard perspective/lookAt formulas apply directly. */
|
||||
export type Mat4 = Float32Array
|
||||
|
||||
export namespace Mat4 {
|
||||
/** Matrix product A * B (apply B first, then A). */
|
||||
export function multiply(a: Mat4, b: Mat4): Mat4 {
|
||||
const out = new Float32Array(16)
|
||||
for (let col = 0; col < 4; col++) {
|
||||
for (let row = 0; row < 4; row++) {
|
||||
let sum = 0
|
||||
for (let k = 0; k < 4; k++) {
|
||||
sum += a[k * 4 + row] * b[col * 4 + k]
|
||||
}
|
||||
out[col * 4 + row] = sum
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Right-handed perspective projection (camera looks down -Z). Maps the view
|
||||
* frustum to clip space; the -1 in row 3 copies -z into w, so the later
|
||||
* divide by w is what produces foreshortening. */
|
||||
export function perspective(fovY: number, aspect: number, near: number, far: number): Mat4 {
|
||||
const f = 1 / Math.tan(fovY / 2)
|
||||
const out = new Float32Array(16)
|
||||
out[0] = f / aspect
|
||||
out[5] = f
|
||||
out[10] = (far + near) / (near - far)
|
||||
out[11] = -1
|
||||
out[14] = (2 * far * near) / (near - far)
|
||||
return out
|
||||
}
|
||||
|
||||
/** View matrix looking from `eye` toward `center`, with `up` roughly up.
|
||||
* Builds an orthonormal camera basis (s = right, u = up, f = forward) and
|
||||
* packs it as the inverse camera transform. */
|
||||
export function lookAt(eye: Vec3, center: Vec3, up: Vec3): Mat4 {
|
||||
const f = Vec3.normalize(Vec3.sub(center, eye))
|
||||
const s = Vec3.normalize(Vec3.cross(f, up))
|
||||
const u = Vec3.cross(s, f)
|
||||
const out = new Float32Array(16)
|
||||
out[0] = s.x
|
||||
out[1] = u.x
|
||||
out[2] = -f.x
|
||||
out[4] = s.y
|
||||
out[5] = u.y
|
||||
out[6] = -f.y
|
||||
out[8] = s.z
|
||||
out[9] = u.z
|
||||
out[10] = -f.z
|
||||
out[12] = -Vec3.dot(s, eye)
|
||||
out[13] = -Vec3.dot(u, eye)
|
||||
out[14] = Vec3.dot(f, eye)
|
||||
out[15] = 1
|
||||
return out
|
||||
}
|
||||
|
||||
/** Transform a point, returning homogeneous coords. `w` is kept (not divided
|
||||
* out) because the rasterizer needs it for near-clipping and the perspective
|
||||
* divide/depth; for a perspective matrix w equals the view-space distance. */
|
||||
export function transform(m: Mat4, v: Vec3): { x: number; y: number; z: number; w: number } {
|
||||
return {
|
||||
x: m[0] * v.x + m[4] * v.y + m[8] * v.z + m[12],
|
||||
y: m[1] * v.x + m[5] * v.y + m[9] * v.z + m[13],
|
||||
z: m[2] * v.x + m[6] * v.y + m[10] * v.z + m[14],
|
||||
w: m[3] * v.x + m[7] * v.y + m[11] * v.z + m[15],
|
||||
}
|
||||
}
|
||||
}
|
||||
2
engine/math/Vec2.ts
Normal file
2
engine/math/Vec2.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/** 2D vector, used for texture coordinates. */
|
||||
export type Vec2 = { x: number; y: number }
|
||||
38
engine/math/Vec3.ts
Normal file
38
engine/math/Vec3.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
export type Vec3 = { x: number; y: number; z: number }
|
||||
|
||||
/** Plain 3D vector math. Every operation returns a fresh object (no in-place
|
||||
* mutation) to keep call sites easy to reason about. */
|
||||
export namespace Vec3 {
|
||||
export function add(a: Vec3, b: Vec3): Vec3 {
|
||||
return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }
|
||||
}
|
||||
|
||||
export function sub(a: Vec3, b: Vec3): Vec3 {
|
||||
return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z }
|
||||
}
|
||||
|
||||
export function scale(v: Vec3, s: number): Vec3 {
|
||||
return { x: v.x * s, y: v.y * s, z: v.z * s }
|
||||
}
|
||||
|
||||
export function dot(a: Vec3, b: Vec3): number {
|
||||
return a.x * b.x + a.y * b.y + a.z * b.z
|
||||
}
|
||||
|
||||
export function cross(a: Vec3, b: Vec3): Vec3 {
|
||||
return {
|
||||
x: a.y * b.z - a.z * b.y,
|
||||
y: a.z * b.x - a.x * b.z,
|
||||
z: a.x * b.y - a.y * b.x,
|
||||
}
|
||||
}
|
||||
|
||||
export function length(v: Vec3): number {
|
||||
return Math.sqrt(dot(v, v))
|
||||
}
|
||||
|
||||
export function normalize(v: Vec3): Vec3 {
|
||||
const len = length(v)
|
||||
return len === 0 ? v : scale(v, 1 / len)
|
||||
}
|
||||
}
|
||||
45
engine/render/Color.ts
Normal file
45
engine/render/Color.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* A color packed into 32 bits as RGBA in little-endian byte order, i.e. the
|
||||
* bytes in memory run R, G, B, A. That is exactly the layout a canvas
|
||||
* ImageData expects, so the `Uint32Array` framebuffer can be reinterpreted as
|
||||
* an ImageData with zero per-pixel conversion at blit time.
|
||||
*/
|
||||
export type Color = number
|
||||
|
||||
export namespace Color {
|
||||
export function rgb(r: number, g: number, b: number, a = 255): Color {
|
||||
// The shifts coerce the (possibly fractional) inputs to int32 and pack the
|
||||
// channels; `>>> 0` forces an unsigned result so it stays a valid Color.
|
||||
return ((a << 24) | (b << 16) | (g << 8) | r) >>> 0
|
||||
}
|
||||
|
||||
export function r(c: Color): number {
|
||||
return c & 0xFF
|
||||
}
|
||||
|
||||
export function g(c: Color): number {
|
||||
return (c >>> 8) & 0xFF
|
||||
}
|
||||
|
||||
export function b(c: Color): number {
|
||||
return (c >>> 16) & 0xFF
|
||||
}
|
||||
|
||||
export function a(c: Color): number {
|
||||
return (c >>> 24) & 0xFF
|
||||
}
|
||||
|
||||
/** Multiply RGB by a scalar (for shading), keeping alpha. */
|
||||
export function scale(c: Color, s: number): Color {
|
||||
return rgb(r(c) * s, g(c) * s, b(c) * s, a(c))
|
||||
}
|
||||
|
||||
/** Linear blend between two colors, t in 0..1. Used for fog and bilinear. */
|
||||
export function lerp(from: Color, to: Color, t: number): Color {
|
||||
return rgb(
|
||||
r(from) + (r(to) - r(from)) * t,
|
||||
g(from) + (g(to) - g(from)) * t,
|
||||
b(from) + (b(to) - b(from)) * t,
|
||||
)
|
||||
}
|
||||
}
|
||||
75
engine/render/Framebuffer.ts
Normal file
75
engine/render/Framebuffer.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { Color } from "./Color"
|
||||
import type { RenderConfig } from "./RenderConfig"
|
||||
|
||||
/** CPU color + depth buffer the renderer writes into before it is blitted to a
|
||||
* canvas. Kept as flat typed arrays so it needs no DOM and can also run
|
||||
* headless (server-side rendering, tests, baking). */
|
||||
export type Framebuffer = {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
/** Packed RGBA pixels; see Color. Aliased as an ImageData at blit time. */
|
||||
readonly color: Uint32Array
|
||||
/** Per-pixel depth stored as 1/w. 1/w (unlike w) interpolates linearly in
|
||||
* screen space, so it is both cheap and correct to compare. Larger = nearer;
|
||||
* cleared to 0 = infinitely far. */
|
||||
readonly depth: Float32Array
|
||||
}
|
||||
|
||||
export namespace Framebuffer {
|
||||
export function create(width: number, height: number): Framebuffer {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
color: new Uint32Array(width * height),
|
||||
depth: new Float32Array(width * height),
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset every pixel to `color` and depth to 0 (farthest). Call once a frame
|
||||
* before drawing; `color` should match the fog color so uncovered pixels
|
||||
* (gaps past the geometry) blend seamlessly. */
|
||||
export function clear(fb: Framebuffer, color: Color): void {
|
||||
fb.color.fill(color)
|
||||
fb.depth.fill(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Posterize the color buffer to `config.colorDepth` bits per channel with a
|
||||
* Bayer 4x4 ordered dither, in place. This is a post-process over the whole
|
||||
* frame (run after all geometry), reproducing the PS1's banded-yet-dithered
|
||||
* 15-bit output. Skipped entirely when it would be a no-op (full depth, no
|
||||
* dither).
|
||||
*/
|
||||
export function quantize(fb: Framebuffer, config: RenderConfig): void {
|
||||
const levels = (1 << config.colorDepth) - 1
|
||||
if (levels >= 255 && config.dither === 0) {
|
||||
return
|
||||
}
|
||||
const { width, height, color } = fb
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
// Per-pixel threshold from the tiled Bayer matrix, centered on 0 and
|
||||
// scaled by strength, nudges each channel before it snaps to a level.
|
||||
const threshold = (BAYER4[(y & 3) * 4 + (x & 3)] / 16 - 0.5) * config.dither
|
||||
const i = y * width + x
|
||||
const c = color[i]
|
||||
color[i] = Color.rgb(
|
||||
channel(Color.r(c), threshold, levels),
|
||||
channel(Color.g(c), threshold, levels),
|
||||
channel(Color.b(c), threshold, levels),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bayer 4x4 threshold map (values 0..15), read modulo 4 in x and y. */
|
||||
const BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5]
|
||||
|
||||
/** Snap one 0..255 channel to `levels` steps after applying the dither
|
||||
* threshold, then expand back to 0..255. */
|
||||
function channel(value: number, threshold: number, levels: number): number {
|
||||
const n = value / 255 + threshold / levels
|
||||
const q = Math.min(levels, Math.max(0, Math.round(n * levels)))
|
||||
return Math.round((q / levels) * 255)
|
||||
}
|
||||
}
|
||||
217
engine/render/Rasterizer.ts
Normal file
217
engine/render/Rasterizer.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { Color } from "./Color"
|
||||
import type { Framebuffer } from "./Framebuffer"
|
||||
import type { RenderConfig } from "./RenderConfig"
|
||||
import { Texture } from "./Texture"
|
||||
import { Mat4 } from "../math/Mat4"
|
||||
import { Vec3 } from "../math/Vec3"
|
||||
import type { Mesh, Vertex } from "../scene/Mesh"
|
||||
|
||||
/** Vertex in clip space, carrying the texture coords that must survive
|
||||
* near-plane clipping (which splits triangles and creates new vertices). */
|
||||
type ClipVertex = { x: number; y: number; w: number; u: number; v: number }
|
||||
|
||||
/** Vertex after the perspective divide, in framebuffer pixels. `invW` (= 1/w)
|
||||
* is kept per vertex because it drives both the depth test and perspective-
|
||||
* correct texturing. */
|
||||
type ScreenVertex = { sx: number; sy: number; invW: number; u: number; v: number }
|
||||
|
||||
/** Anything with w below this is treated as behind the camera and clipped. */
|
||||
const NEAR_W = 0.01
|
||||
/** Fixed world-space directional light. Normalized once at module load. */
|
||||
const LIGHT = Vec3.normalize({ x: 0.4, y: 1, z: 0.35 })
|
||||
const AMBIENT = 0.4
|
||||
const DIFFUSE = 0.6
|
||||
|
||||
/**
|
||||
* Software triangle rasterizer — the heart of the PS1 look.
|
||||
*
|
||||
* Per triangle the pipeline is: transform to clip space, clip against the near
|
||||
* plane, perspective-divide to screen pixels (optionally snapping vertices to a
|
||||
* grid), then fill with an edge-function / barycentric scan. Per pixel it
|
||||
* interpolates depth as 1/w, texture coords (affine or perspective-correct, see
|
||||
* `fillTriangle`), and applies flat shading plus distance fog.
|
||||
*
|
||||
* The period-accurate rough edges are deliberate, not unfinished: no mipmaps
|
||||
* (so distant textures shimmer/moire), no antialiasing (jagged silhouettes),
|
||||
* and affine texturing by default (the texture "swim"). Depth is a plain 1/w
|
||||
* z-buffer and triangles are drawn double-sided (no backface culling), so mesh
|
||||
* winding can never cause surfaces to drop out.
|
||||
*/
|
||||
export namespace Rasterizer {
|
||||
/** Draw an indexed mesh into the framebuffer through a view-projection
|
||||
* matrix. Shading is flat (one normal per face), so it is computed once per
|
||||
* triangle here and shared by every pixel the triangle covers. */
|
||||
export function draw(
|
||||
fb: Framebuffer,
|
||||
mesh: Mesh,
|
||||
texture: Texture,
|
||||
viewProj: Mat4,
|
||||
config: RenderConfig,
|
||||
): void {
|
||||
const { vertices, indices } = mesh
|
||||
for (let t = 0; t + 2 < indices.length; t += 3) {
|
||||
const a = vertices[indices[t]]
|
||||
const b = vertices[indices[t + 1]]
|
||||
const c = vertices[indices[t + 2]]
|
||||
const shade = config.lighting === "flat" ? flatShade(a, b, c) : 1
|
||||
// Near-clipping can turn one triangle into a quad; fan it back to tris.
|
||||
const poly = clipNear([project(viewProj, a), project(viewProj, b), project(viewProj, c)])
|
||||
for (let k = 1; k + 1 < poly.length; k++) {
|
||||
fillTriangle(fb, poly[0], poly[k], poly[k + 1], shade, texture, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function project(m: Mat4, vertex: Vertex): ClipVertex {
|
||||
const p = Mat4.transform(m, vertex.pos)
|
||||
return { x: p.x, y: p.y, w: p.w, u: vertex.uv.x, v: vertex.uv.y }
|
||||
}
|
||||
|
||||
/** Flat (per-face) directional shade in 0..1: ambient plus diffuse from the
|
||||
* face normal. `abs()` makes it two-sided so back-facing tris still light. */
|
||||
function flatShade(a: Vertex, b: Vertex, c: Vertex): number {
|
||||
const normal = Vec3.normalize(Vec3.cross(Vec3.sub(b.pos, a.pos), Vec3.sub(c.pos, a.pos)))
|
||||
return Math.min(1, AMBIENT + DIFFUSE * Math.abs(Vec3.dot(normal, LIGHT)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip a polygon against the camera plane (w = NEAR_W) with a single
|
||||
* Sutherland-Hodgman pass, returning its vertices as a fan (0, 3, or 4).
|
||||
*
|
||||
* This matters even when standing inside the room: a wall to your side has
|
||||
* vertices both in front of and behind the eye. Without clipping, the behind
|
||||
* vertices have w <= 0 and invert under the perspective divide, smearing the
|
||||
* triangle across the whole screen (and risking divide-by-zero). Clipping
|
||||
* trims the triangle to just the visible part instead of dropping it.
|
||||
*/
|
||||
function clipNear(poly: ClipVertex[]): ClipVertex[] {
|
||||
const out: ClipVertex[] = []
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const cur = poly[i]
|
||||
const prev = poly[(i + poly.length - 1) % poly.length]
|
||||
const curIn = cur.w >= NEAR_W
|
||||
const prevIn = prev.w >= NEAR_W
|
||||
// Crossing the plane emits the intersection point before the inside one.
|
||||
if (curIn !== prevIn) {
|
||||
out.push(intersectNear(prev, cur))
|
||||
}
|
||||
if (curIn) {
|
||||
out.push(cur)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Point where edge a->b crosses w = NEAR_W, with every attribute lerped. */
|
||||
function intersectNear(a: ClipVertex, b: ClipVertex): ClipVertex {
|
||||
const t = (NEAR_W - a.w) / (b.w - a.w)
|
||||
return {
|
||||
x: a.x + (b.x - a.x) * t,
|
||||
y: a.y + (b.y - a.y) * t,
|
||||
w: a.w + (b.w - a.w) * t,
|
||||
u: a.u + (b.u - a.u) * t,
|
||||
v: a.v + (b.v - a.v) * t,
|
||||
}
|
||||
}
|
||||
|
||||
/** Perspective-divide a clip vertex into framebuffer pixels.
|
||||
*
|
||||
* Vertex snap: real PS1 hardware transformed vertices in low-precision fixed
|
||||
* point, so screen positions landed on a coarse grid and visibly popped
|
||||
* between pixels as the camera moved — the trademark "vertex wobble". We
|
||||
* emulate it by snapping to a `snap`-pixel grid. 0 disables it (smooth). */
|
||||
function toScreen(fb: Framebuffer, c: ClipVertex, snap: number): ScreenVertex {
|
||||
const invW = 1 / c.w
|
||||
let sx = (c.x * invW * 0.5 + 0.5) * fb.width
|
||||
let sy = (1 - (c.y * invW * 0.5 + 0.5)) * fb.height
|
||||
if (snap > 0) {
|
||||
sx = Math.round(sx / snap) * snap
|
||||
sy = Math.round(sy / snap) * snap
|
||||
}
|
||||
return { sx, sy, invW, u: c.u, v: c.v }
|
||||
}
|
||||
|
||||
/** Signed area of the triangle (a, b, point) times two. Its sign tells which
|
||||
* side of edge a->b the point is on; the three edge values are the
|
||||
* (unnormalized) barycentric weights. */
|
||||
function edge(a: ScreenVertex, b: ScreenVertex, px: number, py: number): number {
|
||||
return (b.sx - a.sx) * (py - a.sy) - (b.sy - a.sy) * (px - a.sx)
|
||||
}
|
||||
|
||||
/** Scan-convert one clip-space triangle into the framebuffer. */
|
||||
function fillTriangle(
|
||||
fb: Framebuffer,
|
||||
va: ClipVertex,
|
||||
vb: ClipVertex,
|
||||
vc: ClipVertex,
|
||||
shade: number,
|
||||
texture: Texture,
|
||||
config: RenderConfig,
|
||||
): void {
|
||||
const a = toScreen(fb, va, config.vertexSnap)
|
||||
const b = toScreen(fb, vb, config.vertexSnap)
|
||||
const c = toScreen(fb, vc, config.vertexSnap)
|
||||
const area = edge(a, b, c.sx, c.sy)
|
||||
if (area === 0) {
|
||||
return
|
||||
}
|
||||
const minX = Math.max(0, Math.floor(Math.min(a.sx, b.sx, c.sx)))
|
||||
const maxX = Math.min(fb.width - 1, Math.ceil(Math.max(a.sx, b.sx, c.sx)))
|
||||
const minY = Math.max(0, Math.floor(Math.min(a.sy, b.sy, c.sy)))
|
||||
const maxY = Math.min(fb.height - 1, Math.ceil(Math.max(a.sy, b.sy, c.sy)))
|
||||
const pc = config.perspectiveCorrect
|
||||
const fog = config.fog
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
const px = x + 0.5
|
||||
const py = y + 0.5
|
||||
// Barycentric weights, normalized by area so they sum to 1. Dividing by
|
||||
// a signed area accepts either winding, which is why culling is unneeded.
|
||||
const w0 = edge(b, c, px, py) / area
|
||||
const w1 = edge(c, a, px, py) / area
|
||||
const w2 = edge(a, b, px, py) / area
|
||||
if (w0 < 0 || w1 < 0 || w2 < 0) {
|
||||
continue
|
||||
}
|
||||
// 1/w interpolates linearly in screen space, so this is exact. Larger =
|
||||
// nearer; the z-buffer keeps the max seen per pixel.
|
||||
const invW = w0 * a.invW + w1 * b.invW + w2 * c.invW
|
||||
const idx = y * fb.width + x
|
||||
if (invW <= fb.depth[idx]) {
|
||||
continue
|
||||
}
|
||||
// Two ways to interpolate texture coords across the triangle:
|
||||
// affine - linear in screen space. This is what hardware without a
|
||||
// perspective divide does. It is exact ONLY when the three vertices
|
||||
// share a depth (a face viewed head-on). On a receding surface (the
|
||||
// floor, or a wall turned into the periphery) the depth gradient
|
||||
// makes it diverge, bending the texture along the triangle diagonal
|
||||
// -- the signature PS1 "texture swim".
|
||||
// persp - divide the interpolated u/w by the interpolated 1/w to
|
||||
// undo foreshortening. Geometrically correct, no swim.
|
||||
// perspectiveCorrect (0..1) lerps between them, so the look is a dial.
|
||||
const uAff = w0 * a.u + w1 * b.u + w2 * c.u
|
||||
const vAff = w0 * a.v + w1 * b.v + w2 * c.v
|
||||
const uPer = (w0 * a.u * a.invW + w1 * b.u * b.invW + w2 * c.u * c.invW) / invW
|
||||
const vPer = (w0 * a.v * a.invW + w1 * b.v * b.invW + w2 * c.v * c.invW) / invW
|
||||
const u = uAff + (uPer - uAff) * pc
|
||||
const v = vAff + (vPer - vAff) * pc
|
||||
// Alpha cutout: discard transparent texels so sprites read as cutouts,
|
||||
// not rectangles. Opaque world textures are alpha 255, so unaffected.
|
||||
const texel = Texture.sample(texture, u, v, config.textureFilter)
|
||||
if (Color.a(texel) < 128) {
|
||||
continue
|
||||
}
|
||||
let color = Color.scale(texel, shade)
|
||||
if (fog !== null) {
|
||||
// dist == w (view-space depth); fade from full color to fog color.
|
||||
const dist = 1 / invW
|
||||
const f = Math.min(1, Math.max(0, (fog.far - dist) / (fog.far - fog.near)))
|
||||
color = Color.lerp(fog.color, color, f)
|
||||
}
|
||||
fb.color[idx] = color
|
||||
fb.depth[idx] = invW
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
engine/render/RenderConfig.ts
Normal file
116
engine/render/RenderConfig.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Color } from "./Color"
|
||||
|
||||
/** Linear distance fog: pixels are untouched at/before `near`, fully `color`
|
||||
* at/after `far`, and blended in between. */
|
||||
export type Fog = {
|
||||
color: Color
|
||||
near: number
|
||||
far: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Every PS1-look trait as a live dial. Nothing here is baked into the renderer;
|
||||
* the same scene rendered with two configs gives two eras of hardware, so this
|
||||
* is the object you tweak to experiment. Presets live in the namespace below.
|
||||
*/
|
||||
export type RenderConfig = {
|
||||
/** Internal render resolution before upscaling. The core chunkiness dial:
|
||||
* the whole frame is drawn at this size then scaled up to the display, so
|
||||
* lower numbers mean bigger, blockier pixels. PS1 output was ~320x240. */
|
||||
internalWidth: number
|
||||
internalHeight: number
|
||||
/** How the low-res buffer is scaled to the screen. `nearest` keeps crisp,
|
||||
* blocky pixels (authentic); `linear` smooths them into a soft blur. */
|
||||
upscaleFilter: "nearest" | "linear"
|
||||
/** Bits per color channel. The PS1 framebuffer was 15-bit (5 bits each),
|
||||
* which steps smooth gradients into visible bands. 8 = full 24-bit color,
|
||||
* no banding. */
|
||||
colorDepth: number
|
||||
/** Ordered (Bayer 4x4) dither strength, 0..1. Trades color banding for a
|
||||
* fixed crosshatch of alternating pixels, exactly how the PS1 masked its
|
||||
* 15-bit output. 0 = no dithering. */
|
||||
dither: number
|
||||
/** Screen-space vertex snap grid in pixels. The PS1 transformed vertices in
|
||||
* low-precision fixed point, so they popped between pixels and models
|
||||
* jittered as the camera moved. 0 = off (smooth), 1 = one-pixel snap,
|
||||
* higher = coarser and more pronounced wobble. */
|
||||
vertexSnap: number
|
||||
/**
|
||||
* Texture-mapping correction, 0..1. At 0, texture coords interpolate linearly
|
||||
* in screen space (affine): geometrically wrong on any receding surface, so
|
||||
* the texture bends and swims along triangle diagonals -- the classic PS1
|
||||
* artifact. Faces viewed head-on still look perfect because their depth is
|
||||
* constant. At 1, coords are perspective-correct and everything is straight.
|
||||
* Values in between soften the swim; subdividing geometry reduces it too,
|
||||
* because each smaller triangle spans less depth.
|
||||
*/
|
||||
perspectiveCorrect: number
|
||||
/** Texture sampling. `nearest` point-samples for crunchy PS1 texels;
|
||||
* `linear` does bilinear smoothing (cleaner, but not period-accurate).
|
||||
* Neither uses mipmaps, so distant textures shimmer regardless. */
|
||||
textureFilter: "nearest" | "linear"
|
||||
/** `flat` gives one directional shade per face (the PS1 used cheap flat /
|
||||
* per-vertex lighting); `none` draws the texture unlit at full brightness. */
|
||||
lighting: "none" | "flat"
|
||||
/** Distance fog, or null to disable. PS1 games leaned on fog to hide the
|
||||
* short draw distance and the shimmer of far geometry. It also colors pixels
|
||||
* no triangle covers, so the frame's clear color should match `fog.color`. */
|
||||
fog: Fog | null
|
||||
}
|
||||
|
||||
/** Ready-made looks. The demo binds keys 1/2/3 to these, and they intentionally
|
||||
* sweep `perspectiveCorrect` 0 -> 0.5 -> 1 so you can watch the texture swim
|
||||
* straighten out as you press through them. */
|
||||
export namespace RenderConfig {
|
||||
export const psxish: RenderConfig = {
|
||||
internalWidth: 384,
|
||||
internalHeight: 216,
|
||||
upscaleFilter: "nearest",
|
||||
colorDepth: 5,
|
||||
dither: 1,
|
||||
vertexSnap: 1,
|
||||
perspectiveCorrect: 0.25,
|
||||
textureFilter: "nearest",
|
||||
lighting: "flat",
|
||||
fog: { color: Color.rgb(150, 170, 200), near: 6, far: 22 },
|
||||
}
|
||||
|
||||
export const ps1: RenderConfig = {
|
||||
internalWidth: 320,
|
||||
internalHeight: 240,
|
||||
upscaleFilter: "nearest",
|
||||
colorDepth: 5,
|
||||
dither: 1,
|
||||
vertexSnap: 1,
|
||||
perspectiveCorrect: 0.25,
|
||||
textureFilter: "nearest",
|
||||
lighting: "flat",
|
||||
fog: { color: Color.rgb(150, 170, 200), near: 6, far: 22 },
|
||||
}
|
||||
|
||||
export const soft: RenderConfig = {
|
||||
internalWidth: 480,
|
||||
internalHeight: 270,
|
||||
upscaleFilter: "nearest",
|
||||
colorDepth: 6,
|
||||
dither: 0.5,
|
||||
vertexSnap: 0.5,
|
||||
perspectiveCorrect: 0.5,
|
||||
textureFilter: "nearest",
|
||||
lighting: "flat",
|
||||
fog: { color: Color.rgb(170, 190, 215), near: 10, far: 40 },
|
||||
}
|
||||
|
||||
export const clean: RenderConfig = {
|
||||
internalWidth: 960,
|
||||
internalHeight: 540,
|
||||
upscaleFilter: "linear",
|
||||
colorDepth: 8,
|
||||
dither: 0,
|
||||
vertexSnap: 0,
|
||||
perspectiveCorrect: 1,
|
||||
textureFilter: "linear",
|
||||
lighting: "flat",
|
||||
fog: null,
|
||||
}
|
||||
}
|
||||
64
engine/render/Sky.ts
Normal file
64
engine/render/Sky.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { Color } from "./Color"
|
||||
import type { Framebuffer } from "./Framebuffer"
|
||||
import { Camera } from "../scene/Camera"
|
||||
import { Vec3 } from "../math/Vec3"
|
||||
|
||||
/** Procedural sky: a vertical gradient plus a sun disc. No texture needed. */
|
||||
export type SkyConfig = {
|
||||
zenith: Color
|
||||
horizon: Color
|
||||
sun: Color
|
||||
/** World-space direction toward the sun (need not be normalized). */
|
||||
sunDir: Vec3
|
||||
/** Angular radius of the sun's core, in radians. */
|
||||
sunSize: number
|
||||
}
|
||||
|
||||
const UP: Vec3 = { x: 0, y: 1, z: 0 }
|
||||
|
||||
export namespace Sky {
|
||||
/**
|
||||
* Fill the whole framebuffer with the sky and reset depth to 0. Run first each
|
||||
* frame in place of Framebuffer.clear; opaque geometry then overwrites the sky
|
||||
* wherever it is nearer.
|
||||
*
|
||||
* Per pixel it reconstructs the view ray from the camera basis, shades a
|
||||
* horizon->zenith gradient by the ray's elevation (so it pans with pitch and
|
||||
* yaw), and brightens toward `sun` where the ray points near `sunDir`.
|
||||
*/
|
||||
export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig): void {
|
||||
const { width, height, color, depth } = fb
|
||||
const forward = Camera.forward(camera)
|
||||
const right = Vec3.normalize(Vec3.cross(forward, UP))
|
||||
const up = Vec3.cross(right, forward)
|
||||
const tanY = Math.tan(camera.fov / 2)
|
||||
const tanX = tanY * (width / height)
|
||||
const sun = Vec3.normalize(sky.sunDir)
|
||||
const cosSun = Math.cos(sky.sunSize)
|
||||
for (let y = 0; y < height; y++) {
|
||||
const ndcY = 1 - ((y + 0.5) / height) * 2
|
||||
for (let x = 0; x < width; x++) {
|
||||
const ndcX = ((x + 0.5) / width) * 2 - 1
|
||||
// View ray = forward + right*ndcX*tanX + up*ndcY*tanY, then normalized.
|
||||
let dx = forward.x + right.x * ndcX * tanX + up.x * ndcY * tanY
|
||||
let dy = forward.y + right.y * ndcX * tanX + up.y * ndcY * tanY
|
||||
let dz = forward.z + right.z * ndcX * tanX + up.z * ndcY * tanY
|
||||
const inv = 1 / Math.hypot(dx, dy, dz)
|
||||
dx *= inv
|
||||
dy *= inv
|
||||
dz *= inv
|
||||
// dy is the ray elevation: 0 at the horizon, 1 straight up.
|
||||
const t = Math.max(0, Math.min(1, dy))
|
||||
let c = Color.lerp(sky.horizon, sky.zenith, t)
|
||||
const facing = dx * sun.x + dy * sun.y + dz * sun.z
|
||||
if (facing > cosSun) {
|
||||
const glow = Math.min(1, ((facing - cosSun) / (1 - cosSun)) * 1.5)
|
||||
c = Color.lerp(c, sky.sun, glow)
|
||||
}
|
||||
const i = y * width + x
|
||||
color[i] = c
|
||||
depth[i] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
engine/render/Texture.ts
Normal file
72
engine/render/Texture.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Color } from "./Color"
|
||||
|
||||
/** A square-or-rectangular image of packed RGBA texels, row-major. */
|
||||
export type Texture = {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly data: Uint32Array
|
||||
}
|
||||
|
||||
export namespace Texture {
|
||||
/** Generate a `size`x`size` checkerboard of `cells` squares per axis, using
|
||||
* colors `a` (top-left) and `b`. A stand-in until real textures load. */
|
||||
export function checker(size: number, cells: number, a: Color, b: Color): Texture {
|
||||
const data = new Uint32Array(size * size)
|
||||
const cell = size / cells
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const on = (Math.floor(x / cell) + Math.floor(y / cell)) % 2 === 0
|
||||
data[y * size + x] = on ? a : b
|
||||
}
|
||||
}
|
||||
return { width: size, height: size, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample a texel. Coordinates wrap (tile) outside 0..1. `nearest` point-
|
||||
* samples for crunchy PS1 texels; `linear` bilinearly blends the four
|
||||
* neighbors for a smooth (non-period) result.
|
||||
*
|
||||
* Note there are no mipmaps: when a textured surface is minified in the
|
||||
* distance, many texels fall inside one pixel and point sampling picks an
|
||||
* essentially random one, so the pattern aliases into a crawling moire as the
|
||||
* camera moves. That shimmer is itself part of the PS1 look here; the usual
|
||||
* cure (mipmaps) is a deliberate future step, not a bug.
|
||||
*/
|
||||
export function sample(tex: Texture, u: number, v: number, filter: "nearest" | "linear"): Color {
|
||||
return filter === "linear" ? bilinear(tex, u, v) : nearest(tex, u, v)
|
||||
}
|
||||
|
||||
function nearest(tex: Texture, u: number, v: number): Color {
|
||||
const x = wrap(Math.floor(frac(u) * tex.width), tex.width)
|
||||
const y = wrap(Math.floor(frac(v) * tex.height), tex.height)
|
||||
return tex.data[y * tex.width + x]
|
||||
}
|
||||
|
||||
function bilinear(tex: Texture, u: number, v: number): Color {
|
||||
// -0.5 aligns the sample grid to texel centers before blending.
|
||||
const fx = frac(u) * tex.width - 0.5
|
||||
const fy = frac(v) * tex.height - 0.5
|
||||
const x0 = Math.floor(fx)
|
||||
const y0 = Math.floor(fy)
|
||||
const tx = fx - x0
|
||||
const ty = fy - y0
|
||||
const top = Color.lerp(texel(tex, x0, y0), texel(tex, x0 + 1, y0), tx)
|
||||
const bottom = Color.lerp(texel(tex, x0, y0 + 1), texel(tex, x0 + 1, y0 + 1), tx)
|
||||
return Color.lerp(top, bottom, ty)
|
||||
}
|
||||
|
||||
function texel(tex: Texture, x: number, y: number): Color {
|
||||
return tex.data[wrap(y, tex.height) * tex.width + wrap(x, tex.width)]
|
||||
}
|
||||
|
||||
/** Fractional part in 0..1 (handles negatives), for uv tiling. */
|
||||
function frac(n: number): number {
|
||||
return n - Math.floor(n)
|
||||
}
|
||||
|
||||
/** Wrap an index into 0..size-1, staying non-negative for negative inputs. */
|
||||
function wrap(n: number, size: number): number {
|
||||
return ((n % size) + size) % size
|
||||
}
|
||||
}
|
||||
35
engine/scene/Camera.ts
Normal file
35
engine/scene/Camera.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Mat4 } from "../math/Mat4"
|
||||
import { Vec3 } from "../math/Vec3"
|
||||
|
||||
/** First-person camera. Orientation is Euler yaw/pitch (no roll), which is all
|
||||
* an FPS needs and avoids gimbal bookkeeping. */
|
||||
export type Camera = {
|
||||
position: Vec3
|
||||
/** Rotation around +Y, radians. 0 looks toward -Z; increasing turns right. */
|
||||
yaw: number
|
||||
/** Look up/down, radians. Positive looks up. Clamp near +-pi/2 to avoid flip. */
|
||||
pitch: number
|
||||
/** Vertical field of view, radians. */
|
||||
fov: number
|
||||
}
|
||||
|
||||
export namespace Camera {
|
||||
/** Unit forward direction implied by yaw/pitch. */
|
||||
export function forward(cam: Camera): Vec3 {
|
||||
const cp = Math.cos(cam.pitch)
|
||||
return {
|
||||
x: cp * Math.sin(cam.yaw),
|
||||
y: Math.sin(cam.pitch),
|
||||
z: -cp * Math.cos(cam.yaw),
|
||||
}
|
||||
}
|
||||
|
||||
/** Combined projection * view matrix for the given viewport aspect ratio.
|
||||
* Near/far are fixed for now; far only needs to exceed the fog distance. */
|
||||
export function viewProjection(cam: Camera, aspect: number): Mat4 {
|
||||
const eye = cam.position
|
||||
const view = Mat4.lookAt(eye, Vec3.add(eye, forward(cam)), { x: 0, y: 1, z: 0 })
|
||||
const proj = Mat4.perspective(cam.fov, aspect, 0.05, 100)
|
||||
return Mat4.multiply(proj, view)
|
||||
}
|
||||
}
|
||||
11
engine/scene/Mesh.ts
Normal file
11
engine/scene/Mesh.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { Vec2 } from "../math/Vec2"
|
||||
import type { Vec3 } from "../math/Vec3"
|
||||
|
||||
/** One mesh vertex: a world-space position and its texture coordinate. uv is in
|
||||
* tile units, not 0..1, so values >1 repeat the texture (see Texture.sample). */
|
||||
export type Vertex = { pos: Vec3; uv: Vec2 }
|
||||
|
||||
/** Indexed triangle mesh in world space. `indices` holds three entries per
|
||||
* triangle, each indexing into `vertices`; sharing vertices between triangles
|
||||
* keeps seams welded and shrinks the data. */
|
||||
export type Mesh = { vertices: Vertex[]; indices: number[] }
|
||||
45
engine/scene/Sprite.ts
Normal file
45
engine/scene/Sprite.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { Vec2 } from "../math/Vec2"
|
||||
import type { Vec3 } from "../math/Vec3"
|
||||
import type { Texture } from "../render/Texture"
|
||||
import { Camera } from "./Camera"
|
||||
import type { Mesh } from "./Mesh"
|
||||
|
||||
/** A flat image standing in the world, always turned to face the camera --
|
||||
* how the PS1 drew most enemies and props instead of 3D models. */
|
||||
export type Sprite = {
|
||||
/** World anchor at the base (feet) center. */
|
||||
position: Vec3
|
||||
/** World-space width and height. */
|
||||
size: Vec2
|
||||
texture: Texture
|
||||
}
|
||||
|
||||
export namespace Sprite {
|
||||
/**
|
||||
* Build the sprite's quad as a Y-axis billboard: it spins around vertical to
|
||||
* face the camera but stays upright, so characters never tilt. Draw the
|
||||
* result with Rasterizer.draw (its alpha cutout hides transparent texels).
|
||||
*/
|
||||
export function billboard(sprite: Sprite, camera: Camera): Mesh {
|
||||
const forward = Camera.forward(camera)
|
||||
// Camera right projected onto the ground plane (== normalize(-fz, 0, fx)).
|
||||
const len = Math.hypot(forward.x, forward.z) || 1
|
||||
const rx = -forward.z / len
|
||||
const rz = forward.x / len
|
||||
const hw = sprite.size.x / 2
|
||||
const p = sprite.position
|
||||
const y0 = p.y
|
||||
const y1 = p.y + sprite.size.y
|
||||
const lx = p.x - rx * hw
|
||||
const lz = p.z - rz * hw
|
||||
const gx = p.x + rx * hw
|
||||
const gz = p.z + rz * hw
|
||||
const vertices = [
|
||||
{ pos: { x: lx, y: y0, z: lz }, uv: { x: 0, y: 1 } },
|
||||
{ pos: { x: gx, y: y0, z: gz }, uv: { x: 1, y: 1 } },
|
||||
{ pos: { x: gx, y: y1, z: gz }, uv: { x: 1, y: 0 } },
|
||||
{ pos: { x: lx, y: y1, z: lz }, uv: { x: 0, y: 0 } },
|
||||
]
|
||||
return { vertices, indices: [0, 1, 2, 0, 2, 3] }
|
||||
}
|
||||
}
|
||||
25
index.html
Normal file
25
index.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MEAT</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
#screen {
|
||||
display: block;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="screen"></canvas>
|
||||
<script type="module" src="/app/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
meat.code-workspace
Normal file
26
meat.code-workspace
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"name": "meat",
|
||||
"path": ".",
|
||||
},
|
||||
],
|
||||
"settings": {
|
||||
"oxc.path.oxfmt": "node_modules/.bin/oxfmt",
|
||||
"oxc.path.oxlint": "node_modules/.bin/oxlint",
|
||||
"files.exclude": {
|
||||
"**/.git": true,
|
||||
"**/node_modules": true,
|
||||
"**/.temp": true,
|
||||
"**/dist": true,
|
||||
"**/*.tsbuildinfo": true,
|
||||
},
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"tsconfig.json": "tsconfig.*.json",
|
||||
"package.json": "bun.lock, bunfig.toml, *.bun.plugin.ts, ox*.config.ts, .gitignore, commitlint.config.*, release.config.*js, hypeup.config.*",
|
||||
"vite.config.ts": "*.vite.plugin.ts",
|
||||
"README.md": "LICENSE, LICENSE.md, AGENTS.md, CLAUDE.md, CONTEXT.md",
|
||||
},
|
||||
},
|
||||
}
|
||||
24
opencode.json
Normal file
24
opencode.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"instructions": [
|
||||
"AGENTS.md",
|
||||
".agents/rules/**/*.md"
|
||||
],
|
||||
"skills": {
|
||||
"paths": [
|
||||
".agents/skills"
|
||||
]
|
||||
},
|
||||
"command": {
|
||||
"sketch": {
|
||||
"description": "Apply an approved OpenSpec change",
|
||||
"template": "@.ai/commands/sketch.md\n\nArguments: $ARGUMENTS",
|
||||
"agent": "build"
|
||||
},
|
||||
"sketch-sync": {
|
||||
"description": "Apply an approved OpenSpec change and sync Forgejo",
|
||||
"template": "@.ai/commands/sketch.md\n\nMode: sync\nArguments: $ARGUMENTS",
|
||||
"agent": "build"
|
||||
}
|
||||
}
|
||||
}
|
||||
30
oxfmt.config.ts
Normal file
30
oxfmt.config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { defineConfig } from "oxfmt"
|
||||
|
||||
export default defineConfig({
|
||||
useTabs: false,
|
||||
tabWidth: 2,
|
||||
printWidth: 80,
|
||||
singleQuote: false,
|
||||
jsxSingleQuote: false,
|
||||
quoteProps: "as-needed",
|
||||
trailingComma: "all",
|
||||
semi: false,
|
||||
arrowParens: "always",
|
||||
bracketSameLine: false,
|
||||
bracketSpacing: true,
|
||||
ignorePatterns: ["**/*.gen.ts"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["**/ui/**/*.ts"],
|
||||
options: {
|
||||
printWidth: 60,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.tsx"],
|
||||
options: {
|
||||
printWidth: 70,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
93
oxlint.config.ts
Normal file
93
oxlint.config.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { defineConfig } from "oxlint"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: ["typescript", "unicorn", "oxc"],
|
||||
ignorePatterns: ["**/*.gen.ts", "node_modules/**/*"],
|
||||
categories: {
|
||||
correctness: "error",
|
||||
suspicious: "warn",
|
||||
perf: "warn",
|
||||
style: "warn",
|
||||
restriction: "error",
|
||||
},
|
||||
rules: {
|
||||
"capitalized-comments": "off",
|
||||
"default-case": "off",
|
||||
"filename-case": "off",
|
||||
"func-style": ["error", "declaration", { allowArrowFunctions: true }],
|
||||
"id-length": "off",
|
||||
"init-declarations": "off",
|
||||
"max-params": "off",
|
||||
"max-statements": "off",
|
||||
"new-cap": "off",
|
||||
"no-array-for-each": "off",
|
||||
"no-async-await": "off",
|
||||
"no-await-expression-member": "off",
|
||||
"no-await-in-loop": "off",
|
||||
"no-bitwise": "off",
|
||||
"no-console": "off",
|
||||
"no-continue": "off",
|
||||
"no-dynamic-delete": "off",
|
||||
"no-empty-file": "off",
|
||||
"no-empty-function": "off",
|
||||
"no-eq-null": "warn",
|
||||
"no-implicit-coercion": "off",
|
||||
"no-magic-numbers": "off",
|
||||
"no-multi-assign": "off",
|
||||
"no-nested-ternary": "off",
|
||||
"unicorn/no-nested-ternary": "off",
|
||||
"no-null": "off",
|
||||
"no-optional-chaining": "off",
|
||||
"no-plusplus": "off",
|
||||
"no-rest-spread-properties": "off",
|
||||
"no-shadow-restricted-names": "off",
|
||||
"no-shadow": "off",
|
||||
"no-ternary": "off",
|
||||
"no-undefined": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
"no-use-before-define": "off",
|
||||
"unicorn/numeric-separators-style": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-for-of": "off",
|
||||
"prefer-template": "off",
|
||||
"prefer-ternary": "off",
|
||||
"require-module-specifiers": "off",
|
||||
"sort-imports": "off",
|
||||
"sort-keys": "off",
|
||||
"switch-case-braces": "off",
|
||||
"typescript/consistent-indexed-object-style": "off",
|
||||
"typescript/consistent-type-definitions": ["error", "type"],
|
||||
"typescript/explicit-function-return-type": "off",
|
||||
"typescript/explicit-member-accessibility": "off",
|
||||
"typescript/explicit-module-boundary-types": "off",
|
||||
"typescript/no-empty-interface": "off",
|
||||
"typescript/no-empty-object-type": "off",
|
||||
"typescript/no-namespace": "off",
|
||||
"typescript/no-non-null-assertion": "off",
|
||||
"typescript/prefer-function-type": "off",
|
||||
"unicorn/no-process-exit": "off",
|
||||
"unicorn/prefer-string-raw": "off",
|
||||
"unicorn/text-encoding-identifier-case": "off",
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["*.test.ts"],
|
||||
rules: {
|
||||
"typescript/no-explicit-any": "off",
|
||||
"typescript/no-require-imports": "off",
|
||||
"typescript/no-var-requires": "off",
|
||||
"unicorn/prefer-module": "off",
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"func-names": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["*.d.ts"],
|
||||
rules: {
|
||||
"typescript/no-explicit-any": "off",
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"typescript/consistent-type-definitions": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
40
package.json
Normal file
40
package.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "meat",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Sigitex",
|
||||
"url": "https://sigitex.com"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/sigitex/meat.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.2",
|
||||
"@types/bun": "^1.3.13",
|
||||
"oxfmt": "^0.47.0",
|
||||
"oxlint": "^1.62.0",
|
||||
"@commitlint/cli": "^20.5.3",
|
||||
"@commitlint/config-conventional": "^20.5.3",
|
||||
"husky": "^9.1.7",
|
||||
"vite": "^8.1.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bunx --bun vite --host",
|
||||
"build": "bunx --bun vite build",
|
||||
"assets": "bun scripts/gen-assets.ts",
|
||||
"publish:builds": "rsync -avz builds/ sigitex.com:~/meat.errilaz.org/builds",
|
||||
"publish:current": "rsync -avz --delete --exclude builds dist/ sigitex.com:~/meat.errilaz.org",
|
||||
"publish": "bun run publish:current && bun run publish:builds",
|
||||
"snapshot": "bun run build && cp -r dist/ builds/$(uuidgen -t)",
|
||||
"serve": "bun --watch --tsconfig-override tsconfig.server.json server/server.ts",
|
||||
"check": "tsc --build",
|
||||
"test": "bun test --pass-with-no-tests --tsconfig-override tsconfig.test.json",
|
||||
"lint": "oxlint",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
19
regime.config.json
Normal file
19
regime.config.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"sources": {
|
||||
"sigitex": "/sig/templates"
|
||||
},
|
||||
"templates": [
|
||||
"sigitex:project/package",
|
||||
"sigitex:project/library",
|
||||
"sigitex:project/workspace",
|
||||
"sigitex:tool/oxc",
|
||||
"sigitex:tool/commitlint",
|
||||
"sigitex:tool/husky",
|
||||
"sigitex:tool/vibes"
|
||||
],
|
||||
"vars": {
|
||||
"repo": "meat",
|
||||
"copyright": "Sigitex",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
173
scripts/gen-assets.ts
Normal file
173
scripts/gen-assets.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { deflateSync } from "node:zlib"
|
||||
|
||||
// Procedurally draw the placeholder textures and write them to /assets as PNGs.
|
||||
// One-shot dev tool: `bun run assets`. Swap the output files for real art
|
||||
// anytime; the filenames are the contract the game loads.
|
||||
|
||||
const CRC_TABLE = buildCrcTable()
|
||||
|
||||
function buildCrcTable(): Uint32Array {
|
||||
const table = new Uint32Array(256)
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = (c & 1) === 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1
|
||||
}
|
||||
table[n] = c >>> 0
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
function crc32(bytes: Uint8Array): number {
|
||||
let c = 0xFFFFFFFF
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8)
|
||||
}
|
||||
return (c ^ 0xFFFFFFFF) >>> 0
|
||||
}
|
||||
|
||||
function chunk(type: string, data: Uint8Array): Uint8Array {
|
||||
const body = new Uint8Array(4 + data.length)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
body[i] = type.charCodeAt(i)
|
||||
}
|
||||
body.set(data, 4)
|
||||
const out = new Uint8Array(8 + body.length)
|
||||
const view = new DataView(out.buffer)
|
||||
view.setUint32(0, data.length)
|
||||
out.set(body, 4)
|
||||
view.setUint32(4 + body.length, crc32(body))
|
||||
return out
|
||||
}
|
||||
|
||||
function concat(parts: Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((n, p) => n + p.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let offset = 0
|
||||
for (const part of parts) {
|
||||
out.set(part, offset)
|
||||
offset += part.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Encode 8-bit RGBA pixels as a PNG byte stream. */
|
||||
function encodePng(width: number, height: number, rgba: Uint8Array): Uint8Array {
|
||||
const signature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
const ihdr = new Uint8Array(13)
|
||||
const view = new DataView(ihdr.buffer)
|
||||
view.setUint32(0, width)
|
||||
view.setUint32(4, height)
|
||||
ihdr[8] = 8 // bit depth
|
||||
ihdr[9] = 6 // color type: RGBA
|
||||
const stride = width * 4
|
||||
const raw = new Uint8Array((stride + 1) * height)
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (stride + 1)] = 0 // filter type: none
|
||||
raw.set(rgba.subarray(y * stride, y * stride + stride), y * (stride + 1) + 1)
|
||||
}
|
||||
const idat = new Uint8Array(deflateSync(raw))
|
||||
return concat([signature, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))])
|
||||
}
|
||||
|
||||
type Shade = (x: number, y: number) => [number, number, number, number]
|
||||
|
||||
function draw(width: number, height: number, shade: Shade): Uint8Array {
|
||||
const rgba = new Uint8Array(width * height * 4)
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const [r, g, b, a] = shade(x, y)
|
||||
const i = (y * width + x) * 4
|
||||
rgba[i] = clamp(r)
|
||||
rgba[i + 1] = clamp(g)
|
||||
rgba[i + 2] = clamp(b)
|
||||
rgba[i + 3] = clamp(a)
|
||||
}
|
||||
}
|
||||
return rgba
|
||||
}
|
||||
|
||||
function clamp(v: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(v)))
|
||||
}
|
||||
|
||||
/** Deterministic value noise in roughly [-1, 1] from integer coords. */
|
||||
function noise(x: number, y: number): number {
|
||||
const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
|
||||
return (n - Math.floor(n)) * 2 - 1
|
||||
}
|
||||
|
||||
// --- Textures ------------------------------------------------------------
|
||||
|
||||
const floor: Shade = (x, y) => {
|
||||
const grout = x % 32 < 2 || y % 32 < 2
|
||||
if (grout) {
|
||||
return [58, 58, 70, 255]
|
||||
}
|
||||
const n = noise(x, y) * 14
|
||||
return [132 + n, 130 + n, 120 + n, 255]
|
||||
}
|
||||
|
||||
const wall: Shade = (x, y) => {
|
||||
const row = Math.floor(y / 16)
|
||||
const bx = (x + (row % 2) * 16) % 32
|
||||
const mortar = y % 16 < 2 || bx < 2
|
||||
if (mortar) {
|
||||
return [48, 44, 44, 255]
|
||||
}
|
||||
const n = noise(x, y) * 12
|
||||
return [150 + n, 72 + n, 56 + n, 255]
|
||||
}
|
||||
|
||||
const crate: Shade = (x, y) => {
|
||||
const edge = x < 3 || x > 60 || y < 3 || y > 60
|
||||
const band = (x > 29 && x < 35) || (y > 29 && y < 35)
|
||||
const bolt = (x - 8) ** 2 + (y - 8) ** 2 < 6 || (x - 55) ** 2 + (y - 55) ** 2 < 6
|
||||
const n = noise(x, y) * 10 + Math.sin(y * 0.5) * 8
|
||||
if (bolt) {
|
||||
return [60, 46, 26, 255]
|
||||
}
|
||||
if (edge || band) {
|
||||
return [96, 62, 30, 255]
|
||||
}
|
||||
return [140 + n, 96 + n, 46 + n, 255]
|
||||
}
|
||||
|
||||
// 48x64, transparent background, a simple round-topped figure with eyes.
|
||||
const npc: Shade = (x, y) => {
|
||||
const dx = (x - 24) / 17
|
||||
const dy = (y - 34) / 24
|
||||
const d = dx * dx + dy * dy
|
||||
if (d > 1) {
|
||||
return [0, 0, 0, 0]
|
||||
}
|
||||
const outline = d > 0.82
|
||||
const eye = (x - 17) ** 2 + (y - 28) ** 2 < 9 || (x - 31) ** 2 + (y - 28) ** 2 < 9
|
||||
const pupil = (x - 17) ** 2 + (y - 29) ** 2 < 2 || (x - 31) ** 2 + (y - 29) ** 2 < 2
|
||||
if (pupil) {
|
||||
return [20, 20, 30, 255]
|
||||
}
|
||||
if (eye) {
|
||||
return [235, 235, 240, 255]
|
||||
}
|
||||
if (outline) {
|
||||
return [30, 70, 50, 255]
|
||||
}
|
||||
const n = noise(x, y) * 12
|
||||
return [70 + n, 165 + n, 110 + n, 255]
|
||||
}
|
||||
|
||||
// --- Write ---------------------------------------------------------------
|
||||
|
||||
const assets: Array<[string, number, number, Shade]> = [
|
||||
["floor", 64, 64, floor],
|
||||
["wall", 64, 64, wall],
|
||||
["crate", 64, 64, crate],
|
||||
["npc", 48, 64, npc],
|
||||
]
|
||||
|
||||
for (const [name, w, h, shade] of assets) {
|
||||
const png = encodePng(w, h, draw(w, h, shade))
|
||||
await Bun.write(`assets/${name}.png`, png)
|
||||
console.log(`assets/${name}.png (${w}x${h}, ${png.length} bytes)`)
|
||||
}
|
||||
1
server/server.ts
Normal file
1
server/server.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
console.log("hello!")
|
||||
3
shared/Game.ts
Normal file
3
shared/Game.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export type Game = {
|
||||
version: number
|
||||
}
|
||||
14
tsconfig.app.json
Normal file
14
tsconfig.app.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"references": [
|
||||
{ "path": "./tsconfig.engine.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"lib": ["esnext", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": [
|
||||
"app",
|
||||
]
|
||||
}
|
||||
16
tsconfig.base.json
Normal file
16
tsconfig.base.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"lib": [
|
||||
"esnext"
|
||||
],
|
||||
"types": [],
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"outDir": "dist"
|
||||
}
|
||||
}
|
||||
14
tsconfig.config.json
Normal file
14
tsconfig.config.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": [
|
||||
"bun"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"*.config.ts",
|
||||
"*.config.cjs"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
9
tsconfig.engine.json
Normal file
9
tsconfig.engine.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"include": [
|
||||
"engine"
|
||||
]
|
||||
}
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.test.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.config.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.engine.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
tsconfig.server.json
Normal file
12
tsconfig.server.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": [
|
||||
"bun"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"server"
|
||||
]
|
||||
}
|
||||
9
tsconfig.shared.json
Normal file
9
tsconfig.shared.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"include": [
|
||||
"shared"
|
||||
]
|
||||
}
|
||||
18
tsconfig.test.json
Normal file
18
tsconfig.test.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.src.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": [
|
||||
"bun"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"tests"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue