From 7262752e167e768916c4e21dae8893adaa382ac5 Mon Sep 17 00:00:00 2001 From: Schluffe Date: Sat, 11 Jul 2026 17:48:28 +0200 Subject: [PATCH] time for a snapshot --- .vscode/settings.json | 1 - biome.json | 6 +- bun.lock | 124 ++++++++---- engine/system/physics.ts | 24 ++- package.json | 12 +- tsconfig.json | 1 + workshop/README.md | 57 ++++++ workshop/steps/01-vectors/README.md | 31 +++ workshop/steps/01-vectors/vector.test.ts | 25 +++ workshop/steps/01-vectors/vector.ts | 30 +++ workshop/steps/02-vectors-length/README.md | 39 ++++ workshop/steps/02-vectors-length/given.ts | 6 + .../steps/02-vectors-length/mathv.test.ts | 27 +++ workshop/steps/02-vectors-length/mathv.ts | 23 +++ workshop/steps/03-integration/README.md | 35 ++++ workshop/steps/03-integration/given.ts | 5 + workshop/steps/03-integration/motion.test.ts | 27 +++ workshop/steps/03-integration/motion.ts | 9 + workshop/steps/04-aabb/README.md | 47 +++++ workshop/steps/04-aabb/aabb.test.ts | 28 +++ workshop/steps/04-aabb/aabb.ts | 18 ++ workshop/steps/05-sweep-1d/README.md | 59 ++++++ workshop/steps/05-sweep-1d/sweep1d.test.ts | 29 +++ workshop/steps/05-sweep-1d/sweep1d.ts | 42 ++++ workshop/steps/06-ray-vs-aabb/README.md | 73 +++++++ workshop/steps/06-ray-vs-aabb/given.ts | 28 +++ workshop/steps/06-ray-vs-aabb/ray.test.ts | 37 ++++ workshop/steps/06-ray-vs-aabb/ray.ts | 52 +++++ workshop/steps/07-swept-aabb/README.md | 68 +++++++ workshop/steps/07-swept-aabb/given.ts | 54 ++++++ workshop/steps/07-swept-aabb/swept.test.ts | 35 ++++ workshop/steps/07-swept-aabb/swept.ts | 20 ++ workshop/steps/08-resolve/README.md | 43 +++++ workshop/steps/08-resolve/given.ts | 8 + workshop/steps/08-resolve/resolve.test.ts | 19 ++ workshop/steps/08-resolve/resolve.ts | 11 ++ workshop/steps/09-slide/README.md | 58 ++++++ workshop/steps/09-slide/given.ts | 6 + workshop/steps/09-slide/slide.test.ts | 24 +++ workshop/steps/09-slide/slide.ts | 11 ++ workshop/steps/10-move-and-slide/README.md | 75 ++++++++ workshop/steps/10-move-and-slide/given.ts | 65 +++++++ .../10-move-and-slide/moveAndSlide.test.ts | 37 ++++ .../steps/10-move-and-slide/moveAndSlide.ts | 59 ++++++ workshop/steps/11-capstone/README.md | 45 +++++ workshop/steps/11-capstone/game.js | 179 ++++++++++++++++++ workshop/steps/11-capstone/index.html | 61 ++++++ 47 files changed, 1712 insertions(+), 61 deletions(-) create mode 100644 workshop/README.md create mode 100644 workshop/steps/01-vectors/README.md create mode 100644 workshop/steps/01-vectors/vector.test.ts create mode 100644 workshop/steps/01-vectors/vector.ts create mode 100644 workshop/steps/02-vectors-length/README.md create mode 100644 workshop/steps/02-vectors-length/given.ts create mode 100644 workshop/steps/02-vectors-length/mathv.test.ts create mode 100644 workshop/steps/02-vectors-length/mathv.ts create mode 100644 workshop/steps/03-integration/README.md create mode 100644 workshop/steps/03-integration/given.ts create mode 100644 workshop/steps/03-integration/motion.test.ts create mode 100644 workshop/steps/03-integration/motion.ts create mode 100644 workshop/steps/04-aabb/README.md create mode 100644 workshop/steps/04-aabb/aabb.test.ts create mode 100644 workshop/steps/04-aabb/aabb.ts create mode 100644 workshop/steps/05-sweep-1d/README.md create mode 100644 workshop/steps/05-sweep-1d/sweep1d.test.ts create mode 100644 workshop/steps/05-sweep-1d/sweep1d.ts create mode 100644 workshop/steps/06-ray-vs-aabb/README.md create mode 100644 workshop/steps/06-ray-vs-aabb/given.ts create mode 100644 workshop/steps/06-ray-vs-aabb/ray.test.ts create mode 100644 workshop/steps/06-ray-vs-aabb/ray.ts create mode 100644 workshop/steps/07-swept-aabb/README.md create mode 100644 workshop/steps/07-swept-aabb/given.ts create mode 100644 workshop/steps/07-swept-aabb/swept.test.ts create mode 100644 workshop/steps/07-swept-aabb/swept.ts create mode 100644 workshop/steps/08-resolve/README.md create mode 100644 workshop/steps/08-resolve/given.ts create mode 100644 workshop/steps/08-resolve/resolve.test.ts create mode 100644 workshop/steps/08-resolve/resolve.ts create mode 100644 workshop/steps/09-slide/README.md create mode 100644 workshop/steps/09-slide/given.ts create mode 100644 workshop/steps/09-slide/slide.test.ts create mode 100644 workshop/steps/09-slide/slide.ts create mode 100644 workshop/steps/10-move-and-slide/README.md create mode 100644 workshop/steps/10-move-and-slide/given.ts create mode 100644 workshop/steps/10-move-and-slide/moveAndSlide.test.ts create mode 100644 workshop/steps/10-move-and-slide/moveAndSlide.ts create mode 100644 workshop/steps/11-capstone/README.md create mode 100644 workshop/steps/11-capstone/game.js create mode 100644 workshop/steps/11-capstone/index.html diff --git a/.vscode/settings.json b/.vscode/settings.json index c401c63..fe8fc43 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,4 @@ { - "bun.runtime": "~/.bun/bin/bun", "bun.debugTerminal.enabled": true, "bun.debugTerminal.stopOnEntry": false, "editor.formatOnSave": true, diff --git a/biome.json b/biome.json index f9074b9..a9af090 100644 --- a/biome.json +++ b/biome.json @@ -8,7 +8,11 @@ "rules": { "a11y": "off", "style": { - "noNonNullAssertion": "off" + "noNonNullAssertion": "off", + "useConst": { + "fix": "none", + "level": "warn" + } }, "suspicious": { "noTsIgnore": "off", diff --git a/bun.lock b/bun.lock index 3e5dc58..b6b57bf 100644 --- a/bun.lock +++ b/bun.lock @@ -4,17 +4,15 @@ "workspaces": { "": { "name": "nage", - "devDependencies": { + "dependencies": { "@biomejs/biome": "latest", "@types/bun": "latest", "lz-string": "latest", "solid-js": "latest", + "typescript": "latest", "vite": "latest", "vite-plugin-solid": "latest", }, - "peerDependencies": { - "typescript": "latest", - }, }, }, "packages": { @@ -54,29 +52,29 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@biomejs/biome": ["@biomejs/biome@2.5.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.0", "@biomejs/cli-darwin-x64": "2.5.0", "@biomejs/cli-linux-arm64": "2.5.0", "@biomejs/cli-linux-arm64-musl": "2.5.0", "@biomejs/cli-linux-x64": "2.5.0", "@biomejs/cli-linux-x64-musl": "2.5.0", "@biomejs/cli-win32-arm64": "2.5.0", "@biomejs/cli-win32-x64": "2.5.0" }, "bin": { "biome": "bin/biome" } }, "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw=="], + "@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -88,43 +86,43 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -138,6 +136,46 @@ "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@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=="], + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="], "babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], @@ -220,19 +258,19 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], - "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="], + "seroval": ["seroval@1.5.5", "", {}, "sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw=="], - "seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="], + "seroval-plugins": ["seroval-plugins@1.5.5", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-+BDhqYM6CEn3x09v44dpa9p6974FuUB2dxk+Ctn04k0cO1Zt6QODTXfmEZK0eBaTe/fJBvP4NMGuNJ+R8T+QMg=="], - "solid-js": ["solid-js@1.9.13", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ=="], + "solid-js": ["solid-js@1.9.14", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ=="], "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], @@ -242,13 +280,13 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "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@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "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-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + "vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.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-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="], "vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], @@ -257,5 +295,7 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + + "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], } } diff --git a/engine/system/physics.ts b/engine/system/physics.ts index ee75a19..05bb164 100644 --- a/engine/system/physics.ts +++ b/engine/system/physics.ts @@ -137,18 +137,22 @@ function sweptAABB( positionB: Vector, delta: number, ): null | number { - const ax = positionA.x + colliderA.offset.x; - const ay = positionA.y + colliderA.offset.y; - const bx = positionB.x + colliderB.offset.x; - const by = positionB.y + colliderB.offset.y; + // setup the "real" position for collider a + const ax = positionA.x + colliderA.offset.x; // calc the x position of the collider a + const ay = positionA.y + colliderA.offset.y; // calc the y position of the collider a + // setup the "real" position for collider b + const bx = positionB.x + colliderB.offset.x; // calc the x positoin of the collider b + const by = positionB.y + colliderB.offset.y; // calc the y position of the collider b - inflAABB.x = bx - colliderA.width; - inflAABB.y = by - colliderA.height; - inflAABB.width = colliderB.width + colliderA.width; - inflAABB.height = colliderB.height + colliderA.height; + // setup the inflated AABB + inflAABB.x = bx - colliderA.width; // calc the x position of the inflated AABB + inflAABB.y = by - colliderA.height; // calc the y position of the inflated AABB + inflAABB.width = colliderB.width + colliderA.width; // calc the width of the inflated AABB + inflAABB.height = colliderB.height + colliderA.height; // calc the height of the inflated AABB - const vx = velocityA.x * delta; - const vy = velocityA.y * delta; + // full-frame displacement for collider a + const vx = velocityA.x * delta; // scale x by delta for collider a + const vy = velocityA.y * delta; // scale y by delta for collider a let txNear = (inflAABB.x - ax) / vx; let txFar = (inflAABB.x + inflAABB.width - ax) / vx; diff --git a/package.json b/package.json index 84bef96..0a0dbff 100644 --- a/package.json +++ b/package.json @@ -7,17 +7,15 @@ "preview": "vite build && vite preview", "check": "biome check --write" }, - "devDependencies": { - "@biomejs/biome": "2.5.0", + "dependencies": { + "@biomejs/biome": "2.5.3", "@types/bun": "1.3.14", "lz-string": "1.5.0", - "solid-js": "1.9.13", - "vite": "8.0.16", + "solid-js": "1.9.14", + "typescript": "7.0.2", + "vite": "8.1.4", "vite-plugin-solid": "2.11.12" }, - "peerDependencies": { - "typescript": "6.0.3" - }, "imports": { "#/": "./*" } diff --git a/tsconfig.json b/tsconfig.json index 64c79cc..f5fc3c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "lib": ["ESNext", "DOM", "DOM.Iterable"], "target": "ESNext", "module": "ESNext", + "types": ["bun"], "moduleDetection": "force", "jsx": "preserve", "jsxImportSource": "solid-js", diff --git a/workshop/README.md b/workshop/README.md new file mode 100644 index 0000000..0427868 --- /dev/null +++ b/workshop/README.md @@ -0,0 +1,57 @@ +# Physics Kernel Workshop + +A from-scratch, test-driven path to a working 2D **swept collision** engine — +the same kind that lives in `nage`, but stripped of rendering, scenes, ECS, and +every other distraction. Just the physics, one tiny step at a time. + +## How it works (LeetCode style) + +Each folder under `steps/` is one self-contained kata: + +- `README.md` — the concept (taught from zero) and the task. +- a stub file — the function(s) **you** implement. They start by `throw`ing. +- a `*.test.ts` file — the validator. Red until you implement it, green when you're done. +- sometimes a `given.ts` — prerequisites from earlier steps, already finished, so + you only ever implement the **one new idea** of this step. + +### Run one step + +From the project root: + +```sh +bun test workshop/steps/01-vectors +``` + +Red (failing) is the starting state. Implement the stub until it goes green, then +ping me and I'll validate + unlock the next batch. + +> Skip nothing silently, but blast through what you already know — the early steps +> are deliberately trivial so the test loop becomes muscle memory before the hard +> rungs. + +## The ladder + +Each rung is a concept that the real `nage` physics depends on. We climb until +the top rung *is* a working engine. + +**Batch 1 — foundations (these files exist now):** +- [x] `01-vectors` — vectors as pairs of numbers: add, sub, scale +- [x] `02-vectors-length` — length, normalize (and the zero-vector trap), dot +- [x] `03-integration` — `pos += vel * delta`, and why framerate independence matters +- [x] `04-aabb` — axis-aligned boxes, point-in-box, box overlap (the *discrete* test) +- [x] `05-sweep-1d` — the **entry/exit time** of a moving point against an interval. The seed of everything. + +**Batch 2 — the swept core (these files exist now):** +- [x] `06-ray-vs-aabb` — combine two 1D sweeps into one: ray vs box, with the surface **normal** +- [x] `07-swept-aabb` — the **Minkowski** trick: shrink the moving box to a point, reuse step 06 + +**Batch 3 — response (these files exist now):** +- [x] `08-resolve` — stop at the moment of contact (`t`), not after +- [x] `09-slide` — subtract the into-the-wall part of velocity and keep going along the wall + +**Batch 4 — the real loop + payoff (these files exist now):** +- [x] `10-move-and-slide` — the full loop: multiple obstacles, iteration cap (relative velocity explained) +- [x] `11-capstone` — a canvas demo (no test — just run it and play) + +When step 11 is green you'll have re-derived your own engine's heart — and walking +back into `sweptAABB` should feel like reading your own handwriting again. diff --git a/workshop/steps/01-vectors/README.md b/workshop/steps/01-vectors/README.md new file mode 100644 index 0000000..0c55e53 --- /dev/null +++ b/workshop/steps/01-vectors/README.md @@ -0,0 +1,31 @@ +# Step 01 — Vectors + +> You know this cold. The point of this step is to make the red→green loop +> automatic, not to teach you arithmetic. Blast through it. + +## Concept + +A **vector** here is nothing mystical: a pair of numbers `(x, y)`. We use the +same value to mean two different things depending on context: + +- a **position** — a point in the world. +- a **displacement / velocity** — an arrow: "move this much in x, this much in y." + +That's it. All of 2D physics is built on adding, subtracting, and scaling these +pairs. + +- `add(a, b)` → `(a.x + b.x, a.y + b.y)` — apply an arrow to a point. +- `sub(a, b)` → `(a.x - b.x, a.y - b.y)` — the arrow that points *from b to a*. +- `scale(a, s)` → `(a.x * s, a.y * s)` — make an arrow longer/shorter. + +> Note: we return **new** objects (pure functions) here for clarity. Your real +> engine mutates vectors in place to avoid garbage collection — that's a speed +> optimization we'll deliberately ignore until the very end. + +## Task + +Implement `vec`, `add`, `sub`, and `scale` in `vector.ts`. + +```sh +bun test workshop/steps/01-vectors +``` diff --git a/workshop/steps/01-vectors/vector.test.ts b/workshop/steps/01-vectors/vector.test.ts new file mode 100644 index 0000000..28b18fd --- /dev/null +++ b/workshop/steps/01-vectors/vector.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { add, scale, sub, vec } from "./vector.ts"; + +test("vec builds a vector", () => { + expect(vec(2, 3)).toEqual({ x: 2, y: 3 }); +}); + +test("add is component-wise", () => { + expect(add(vec(1, 2), vec(3, 4))).toEqual({ x: 4, y: 6 }); +}); + +test("sub is component-wise", () => { + expect(sub(vec(5, 5), vec(1, 2))).toEqual({ x: 4, y: 3 }); +}); + +test("scale multiplies both components", () => { + expect(scale(vec(2, 3), 2)).toEqual({ x: 4, y: 6 }); + expect(scale(vec(2, 3), 0)).toEqual({ x: 0, y: 0 }); +}); + +test("inputs are not mutated (pure functions)", () => { + const a = vec(1, 2); + add(a, vec(3, 4)); + expect(a).toEqual({ x: 1, y: 2 }); +}); diff --git a/workshop/steps/01-vectors/vector.ts b/workshop/steps/01-vectors/vector.ts new file mode 100644 index 0000000..3bb545a --- /dev/null +++ b/workshop/steps/01-vectors/vector.ts @@ -0,0 +1,30 @@ +export type Vec = { x: number; y: number }; + +/** Build a vector from two numbers. */ +export function vec(x: number, y: number): Vec { + return { x, y }; +} + +/** a + b (component-wise) */ +export function add(a: Vec, b: Vec): Vec { + return { + x: a.x + b.x, + y: a.y + b.y, + }; +} + +/** a - b (component-wise) */ +export function sub(a: Vec, b: Vec): Vec { + return { + x: a.x - b.x, + y: a.y - b.y, + }; +} + +/** a scaled by the scalar s */ +export function scale(a: Vec, s: number): Vec { + return { + x: a.x * s, + y: a.y * s, + }; +} diff --git a/workshop/steps/02-vectors-length/README.md b/workshop/steps/02-vectors-length/README.md new file mode 100644 index 0000000..6490077 --- /dev/null +++ b/workshop/steps/02-vectors-length/README.md @@ -0,0 +1,39 @@ +# Step 02 — Length, Normalize, Dot + +## Concept + +### Length (magnitude) + +An arrow `(x, y)` has a length: how far it reaches. Pythagoras: +`length = sqrt(x*x + y*y)`. A velocity's length is its **speed**. + +### Normalize + +Often you want *just the direction* of an arrow, with length exactly 1 (a "unit +vector"). You get it by dividing the arrow by its own length: +`(x / len, y / len)`. + +This is how your engine turns "the player pressed up+left" into a clean diagonal +direction before multiplying by a speed — so diagonal movement isn't faster than +straight movement. + +> ⚠️ **The zero-vector trap.** What is the length of `(0, 0)`? Zero. What is +> `0 / 0`? `NaN`. If you normalize a zero vector naively, you poison it with +> `NaN`, and `NaN` spreads through every later calculation silently. A correct +> `normalize` must check for zero length and return `(0, 0)` instead of dividing. +> Remember this trap — it is exactly the kind of bug that hides in a real engine. + +### Dot product + +`dot(a, b) = a.x*b.x + a.y*b.y`. One number out of two vectors. For now just +implement it; in step 09 you'll learn that it answers "how much of arrow A points +along arrow B?" — the key to sliding along a wall. + +## Task + +Implement `length`, `normalize` (zero-safe!), and `dot` in `mathv.ts`. +`vec`/`add`/`sub`/`scale` are already provided in `given.ts`. + +```sh +bun test workshop/steps/02-vectors-length +``` diff --git a/workshop/steps/02-vectors-length/given.ts b/workshop/steps/02-vectors-length/given.ts new file mode 100644 index 0000000..7e291ac --- /dev/null +++ b/workshop/steps/02-vectors-length/given.ts @@ -0,0 +1,6 @@ +// Finished in step 01 — provided so you only implement the new ideas here. +export type Vec = { x: number; y: number }; +export const vec = (x: number, y: number): Vec => ({ x, y }); +export const add = (a: Vec, b: Vec): Vec => ({ x: a.x + b.x, y: a.y + b.y }); +export const sub = (a: Vec, b: Vec): Vec => ({ x: a.x - b.x, y: a.y - b.y }); +export const scale = (a: Vec, s: number): Vec => ({ x: a.x * s, y: a.y * s }); diff --git a/workshop/steps/02-vectors-length/mathv.test.ts b/workshop/steps/02-vectors-length/mathv.test.ts new file mode 100644 index 0000000..80f58c7 --- /dev/null +++ b/workshop/steps/02-vectors-length/mathv.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { vec } from "./given.ts"; +import { dot, length, normalize } from "./mathv.ts"; + +test("length uses Pythagoras", () => { + expect(length(vec(3, 4))).toBe(5); + expect(length(vec(0, 0))).toBe(0); +}); + +test("normalize gives a unit vector in the same direction", () => { + const n = normalize(vec(3, 4)); + expect(n.x).toBeCloseTo(0.6); + expect(n.y).toBeCloseTo(0.8); + expect(length(n)).toBeCloseTo(1); +}); + +test("normalize of the zero vector is (0,0), not NaN", () => { + const n = normalize(vec(0, 0)); + expect(n).toEqual({ x: 0, y: 0 }); + expect(Number.isNaN(n.x)).toBe(false); + expect(Number.isNaN(n.y)).toBe(false); +}); + +test("dot product", () => { + expect(dot(vec(1, 0), vec(0, 1))).toBe(0); // perpendicular + expect(dot(vec(2, 3), vec(4, 5))).toBe(23); // 8 + 15 +}); diff --git a/workshop/steps/02-vectors-length/mathv.ts b/workshop/steps/02-vectors-length/mathv.ts new file mode 100644 index 0000000..99e03b1 --- /dev/null +++ b/workshop/steps/02-vectors-length/mathv.ts @@ -0,0 +1,23 @@ +import type { Vec } from "./given.ts"; + +/** The length (magnitude) of the arrow. */ +export function length(a: Vec): number { + return Math.sqrt(a.x * a.x + a.y * a.y); +} + +/** + * A unit-length vector pointing the same way as `a`. + * MUST return (0, 0) when `a` is the zero vector — do not divide by zero. + */ +export function normalize(a: Vec): Vec { + const len = length(a); + const x = a.x === 0 ? 0 : a.x / len; + const y = a.y === 0 ? 0 : a.y / len; + + return { x, y }; +} + +/** The dot product a·b. */ +export function dot(a: Vec, b: Vec): number { + return a.x * b.x + a.y * b.y; +} diff --git a/workshop/steps/03-integration/README.md b/workshop/steps/03-integration/README.md new file mode 100644 index 0000000..9cf099f --- /dev/null +++ b/workshop/steps/03-integration/README.md @@ -0,0 +1,35 @@ +# Step 03 — Integration (moving over time) + +## Concept + +"Integration" sounds like calculus, but the version we need is one line. An +object has a **position** and a **velocity**. Each frame we advance the position +by the velocity, scaled by how much time passed: + +``` +newPosition = position + velocity * delta +``` + +That's `add(pos, scale(vel, delta))`. This single step is the beating heart of +every game's update loop. + +### Why `delta`? + +`delta` is the number of **milliseconds since the last frame**. Frames are not +evenly spaced — a busy frame takes longer. If you moved a fixed amount *per +frame* instead of *per millisecond*, your game would run faster on a fast +computer and slower on a slow one. + +By storing velocity as **units-per-millisecond** and multiplying by `delta`, the +distance travelled over a given stretch of real time is the same no matter how +the frames are chopped up. That property is called **framerate independence**, +and one of the tests below proves it: moving once with `delta = 16` lands in the +same place as moving sixteen times with `delta = 1`. + +## Task + +Implement `integrate(pos, vel, delta)` in `motion.ts`. + +```sh +bun test workshop/steps/03-integration +``` diff --git a/workshop/steps/03-integration/given.ts b/workshop/steps/03-integration/given.ts new file mode 100644 index 0000000..c0e5ed5 --- /dev/null +++ b/workshop/steps/03-integration/given.ts @@ -0,0 +1,5 @@ +// Finished in earlier steps. +export type Vec = { x: number; y: number }; +export const vec = (x: number, y: number): Vec => ({ x, y }); +export const add = (a: Vec, b: Vec): Vec => ({ x: a.x + b.x, y: a.y + b.y }); +export const scale = (a: Vec, s: number): Vec => ({ x: a.x * s, y: a.y * s }); diff --git a/workshop/steps/03-integration/motion.test.ts b/workshop/steps/03-integration/motion.test.ts new file mode 100644 index 0000000..8662ef2 --- /dev/null +++ b/workshop/steps/03-integration/motion.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { vec } from "./given.ts"; +import { integrate } from "./motion.ts"; + +test("integrate advances position by velocity * delta", () => { + // velocity 0.1 units/ms, 100ms => move 10 units + expect(integrate(vec(0, 0), vec(0.1, 0), 100)).toEqual({ x: 10, y: 0 }); +}); + +test("zero delta does not move", () => { + expect(integrate(vec(5, 5), vec(0.1, 0.1), 0)).toEqual({ x: 5, y: 5 }); +}); + +test("framerate independence: one big step == many small steps", () => { + const start = vec(0, 0); + const vel = vec(0.1, -0.05); + + const oneBigStep = integrate(start, vel, 16); + + let manySmall = start; + for (let i = 0; i < 16; i++) { + manySmall = integrate(manySmall, vel, 1); + } + + expect(manySmall.x).toBeCloseTo(oneBigStep.x); + expect(manySmall.y).toBeCloseTo(oneBigStep.y); +}); diff --git a/workshop/steps/03-integration/motion.ts b/workshop/steps/03-integration/motion.ts new file mode 100644 index 0000000..ae5f7c5 --- /dev/null +++ b/workshop/steps/03-integration/motion.ts @@ -0,0 +1,9 @@ +import { add, scale, type Vec } from "./given.ts"; + +/** + * Advance a position by a velocity over `delta` milliseconds. + * newPosition = position + velocity * delta + */ +export function integrate(pos: Vec, vel: Vec, delta: number): Vec { + return add(pos, scale(vel, delta)); +} diff --git a/workshop/steps/04-aabb/README.md b/workshop/steps/04-aabb/README.md new file mode 100644 index 0000000..a2cb2f7 --- /dev/null +++ b/workshop/steps/04-aabb/README.md @@ -0,0 +1,47 @@ +# Step 04 — AABB & the discrete overlap test + +## Concept + +**AABB** = **A**xis-**A**ligned **B**ounding **B**ox: a rectangle whose sides are +parallel to the x and y axes (never rotated). They're cheap to test, which is why +almost every 2D engine — including yours — uses them as the base collision shape. + +We represent one as a corner plus a size: + +``` +type AABB = { x, y, w, h } // (x,y) = top-left corner, w = width, h = height +``` + +So the box spans `x .. x+w` horizontally and `y .. y+h` vertically. + +### Two boxes overlap when they overlap on BOTH axes + +This is the key insight you'll reuse for the rest of the workshop. Think of each +box as a **shadow on the x-axis** and a **shadow on the y-axis**. Two boxes +intersect only if *both* pairs of shadows intersect: + +``` +overlapX: a.x < b.x + b.w AND b.x < a.x + a.w +overlapY: a.y < b.y + b.h AND b.y < a.y + a.h +overlap = overlapX AND overlapY +``` + +Hold onto "collision = the AND of two 1D tests." In a few steps you'll do the +exact same thing, but with **time** instead of space, and that's the whole trick +behind swept collision. + +### The discrete trap (why this test alone isn't enough) + +`aabbOverlap` only answers "are they overlapping *right now*?" If a fast object +jumps from one side of a thin wall to the other in a single frame, it never +overlaps the wall at any sampled instant — so this test says "no collision" and +the object tunnels straight through. Steps 05+ fix that by testing the *path*, +not the endpoints. Feel the gap here first; it's why everything after exists. + +## Task + +Implement `pointInAABB` and `aabbOverlap` in `aabb.ts`. + +```sh +bun test workshop/steps/04-aabb +``` diff --git a/workshop/steps/04-aabb/aabb.test.ts b/workshop/steps/04-aabb/aabb.test.ts new file mode 100644 index 0000000..26f2220 --- /dev/null +++ b/workshop/steps/04-aabb/aabb.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { type AABB, aabbOverlap, pointInAABB } from "./aabb.ts"; + +const box: AABB = { x: 10, y: 10, w: 20, h: 20 }; // spans 10..30 in both axes + +test("point inside the box", () => { + expect(pointInAABB({ x: 15, y: 15 }, box)).toBe(true); +}); + +test("point outside the box", () => { + expect(pointInAABB({ x: 5, y: 15 }, box)).toBe(false); + expect(pointInAABB({ x: 15, y: 35 }, box)).toBe(false); +}); + +test("overlapping boxes", () => { + const other: AABB = { x: 20, y: 20, w: 20, h: 20 }; + expect(aabbOverlap(box, other)).toBe(true); +}); + +test("separated on the x axis only -> no overlap", () => { + const other: AABB = { x: 40, y: 10, w: 5, h: 20 }; + expect(aabbOverlap(box, other)).toBe(false); +}); + +test("separated on the y axis only -> no overlap", () => { + const other: AABB = { x: 10, y: 40, w: 20, h: 5 }; + expect(aabbOverlap(box, other)).toBe(false); +}); diff --git a/workshop/steps/04-aabb/aabb.ts b/workshop/steps/04-aabb/aabb.ts new file mode 100644 index 0000000..058871d --- /dev/null +++ b/workshop/steps/04-aabb/aabb.ts @@ -0,0 +1,18 @@ +export type Vec = { x: number; y: number }; + +/** Axis-aligned box: (x,y) is the top-left corner, w/h are width/height. */ +export type AABB = { x: number; y: number; w: number; h: number }; + +/** Is the point inside (or on the edge of) the box? */ +export function pointInAABB(p: Vec, box: AABB): boolean { + return ( + p.x >= box.x && p.x <= box.x + box.w && p.y >= box.y && p.y <= box.y + box.h + ); +} + +/** Do the two boxes overlap right now? (discrete test) */ +export function aabbOverlap(a: AABB, b: AABB): boolean { + return ( + a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h + ); +} diff --git a/workshop/steps/05-sweep-1d/README.md b/workshop/steps/05-sweep-1d/README.md new file mode 100644 index 0000000..7f7f4cd --- /dev/null +++ b/workshop/steps/05-sweep-1d/README.md @@ -0,0 +1,59 @@ +# Step 05 — Sweeping in 1D (entry & exit time) + +This is the seed of the whole engine. Get this one *in your bones* and the scary +2D `sweptAABB` becomes "do this twice and combine." + +## Concept + +Forget 2D. Forget boxes. We have: + +- a **point** sitting at position `p` on a number line, +- moving with velocity `v` — meaning over this one frame it travels a total of + `v` units (so at fraction `t` of the frame, it's at `p + v*t`, for `t` from 0 to 1), +- and a static **interval** `[min, max]` on that same line. + +Question: **during this frame, for which `t` is the point inside `[min, max]`?** + +### The slab math + +The point reaches `min` when `p + v*t = min`, i.e. `t = (min - p) / v`. +Likewise it reaches `max` at `t = (max - p) / v`. +``` +t1 = (min - p) / v +t2 = (max - p) / v +``` + +If `v` is **negative** (moving left), the point hits `max` *before* `min`, so +`t1 > t2`. We always want `entry` to be the smaller and `exit` the larger, so +**swap them if they're out of order**. Then: + +- `entry` = the time the point *enters* the interval, +- `exit` = the time it *leaves*. + +> These can be negative or greater than 1 — that just means the crossing happens +> before this frame started or after it ends. Don't clamp here; the caller (step +> 06/08) decides whether `entry` falls within `[0, 1]`. Keeping the raw numbers +> is what lets us combine axes later. + +### The `v == 0` edge case + +If the point isn't moving (`v == 0`), it never *crosses* an edge — dividing by +zero is meaningless. Instead: it's either already inside the interval for the +whole frame, or never. So: + +- if `min <= p <= max`: it's inside the entire time → `entry = -Infinity`, + `exit = +Infinity`. +- otherwise: it never overlaps → return `null`. + +(Those infinities are deliberate: in 2D they let a non-moving axis say "I'm not +the axis that limits the collision," without breaking the `max`/`min` combine +step. You'll see why in step 06.) + +## Task + +Implement `sweepInterval(p, v, min, max)` in `sweep1d.ts`. Return +`{ entry, exit }`, or `null` only in the not-moving-and-outside case. + +```sh +bun test workshop/steps/05-sweep-1d +``` diff --git a/workshop/steps/05-sweep-1d/sweep1d.test.ts b/workshop/steps/05-sweep-1d/sweep1d.test.ts new file mode 100644 index 0000000..1f8b210 --- /dev/null +++ b/workshop/steps/05-sweep-1d/sweep1d.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { sweepInterval } from "./sweep1d.ts"; + +test("moving right into the interval", () => { + // point at 0, moves +10 this frame, interval [5, 8] + // enters at (5-0)/10 = 0.5, exits at (8-0)/10 = 0.8 + const s = sweepInterval(0, 10, 5, 8)!; + expect(s.entry).toBeCloseTo(0.5); + expect(s.exit).toBeCloseTo(0.8); +}); + +test("moving left: entry/exit are swapped into order", () => { + // point at 10, moves -10, interval [5, 8] + // crosses 8 at t=0.2, crosses 5 at t=0.5 -> entry 0.2, exit 0.5 + const s = sweepInterval(10, -10, 5, 8)!; + expect(s.entry).toBeCloseTo(0.2); + expect(s.exit).toBeCloseTo(0.5); + expect(s.entry).toBeLessThanOrEqual(s.exit); +}); + +test("not moving but already inside -> infinite span", () => { + const s = sweepInterval(6, 0, 5, 8)!; + expect(s.entry).toBe(-Infinity); + expect(s.exit).toBe(Infinity); +}); + +test("not moving and outside -> null", () => { + expect(sweepInterval(2, 0, 5, 8)).toBeNull(); +}); diff --git a/workshop/steps/05-sweep-1d/sweep1d.ts b/workshop/steps/05-sweep-1d/sweep1d.ts new file mode 100644 index 0000000..d2d3184 --- /dev/null +++ b/workshop/steps/05-sweep-1d/sweep1d.ts @@ -0,0 +1,42 @@ +export type Span = { entry: number; exit: number }; + +/** + * When is a point at `p`, moving by `v` over the frame, inside [min, max]? + * + * Returns the entry/exit times (t, where the position is p + v*t). + * `entry` is always <= `exit`. Values may be < 0 or > 1. + * + * Special case: if v === 0, return {entry:-Infinity, exit:Infinity} when the + * point is already inside [min, max], otherwise return null. + */ +export function sweepInterval( + p: number, + v: number, + min: number, + max: number, +): Span | null { + if (v === 0) { + return isInBetween(p, min, max) + ? { + entry: -Infinity, + exit: +Infinity, + } + : null; + } + + let t1 = (min - p) / v; + let t2 = (max - p) / v; + + if (t1 > t2) { + [t1, t2] = [t2, t1]; + } + + return { + entry: t1, + exit: t2, + }; +} + +function isInBetween(p: number, min: number, max: number): boolean { + return min <= p && p <= max; +} diff --git a/workshop/steps/06-ray-vs-aabb/README.md b/workshop/steps/06-ray-vs-aabb/README.md new file mode 100644 index 0000000..c7c56b9 --- /dev/null +++ b/workshop/steps/06-ray-vs-aabb/README.md @@ -0,0 +1,73 @@ +# Step 06 — Ray vs AABB (two sweeps become one hit) + +This is where step 04 ("collision = the AND of two 1D tests") and step 05 ("the +entry/exit time of one sweep") finally fuse. A **moving point vs a static box**. + +## Concept + +A point at `p` moves by `v` over the frame. A static box has a left/right edge +(its x-interval) and a top/bottom edge (its y-interval). The point is inside the +**box** only while it's inside the x-interval **and** the y-interval *at the same +time*. + +So run `sweepInterval` twice: + +``` +spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w) // the x-edges +spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h) // the y-edges +``` + +Each gives you a time-window `[entry, exit]` during which the point is inside +*that one axis's* strip. You're inside the box during the **overlap of the two +windows**: + +``` +entry = max(spanX.entry, spanY.entry) // inside the box once you're inside the LAST axis +exit = min(spanX.exit, spanY.exit) // out of the box once you leave the FIRST axis +``` + +Read those two lines until they feel obvious — they're the whole algorithm: + +- You're only truly *inside the box* once you've entered **both** strips, so the + real entry is the **later** of the two entries → `max`. +- You **leave** the box the instant you exit **either** strip → the **earlier** + exit → `min`. + +### When is there NO hit? + +1. **A span is `null`** — on some axis the point isn't moving and is already + outside that strip. It can never be inside the box. Return `null` immediately. +2. **`entry > exit`** — the two windows never overlap. The point is inside one + strip, then the other, but never both at once. That's the classic "flies past + the corner" miss. +3. **`entry >= 1` or `exit <= 0`** — the windows overlap, but not *during this + frame* (it's entirely in the future, or entirely in the past). Not our problem + this frame. + +### The normal (which wall did we hit?) + +When you do collide, you also want to know **which face** you hit, so the response +later can push you back the right way. That's the `normal` — a unit vector +pointing out of the surface you struck. + +The trick: **the axis you entered *last* is the axis you actually hit.** Compare +the two entry times — whichever is larger is the blocking axis: + +- if `spanX.entry > spanY.entry` → you hit a **vertical** wall (left/right face). + The normal is horizontal, pointing back against your x-motion: + `normal = { x: v.x > 0 ? -1 : 1, y: 0 }`. +- otherwise → you hit a **horizontal** wall (top/bottom). The normal is vertical: + `normal = { x: 0, y: v.y > 0 ? -1 : 1 }`. + +(Moving right and hitting something → the surface pushes you left → normal `-1`. +That sign rule is all there is to it.) + +## Task + +Implement `rayVsAABB(p, v, box)` in `ray.ts`. Return `{ time, normal }` for the +entry, or `null` for any of the three no-hit cases. `sweepInterval` is provided +in `given.ts` — **reuse it**, don't re-derive it. + +```sh +bun test workshop/steps/06-ray-vs-aabb +``` diff --git a/workshop/steps/06-ray-vs-aabb/given.ts b/workshop/steps/06-ray-vs-aabb/given.ts new file mode 100644 index 0000000..6255b2b --- /dev/null +++ b/workshop/steps/06-ray-vs-aabb/given.ts @@ -0,0 +1,28 @@ +// Finished in earlier steps — reuse, don't rewrite. +export type Vec = { x: number; y: number }; +export const vec = (x: number, y: number): Vec => ({ x, y }); + +export type AABB = { x: number; y: number; w: number; h: number }; +export type Span = { entry: number; exit: number }; + +/** Step 05, finished. */ +export function sweepInterval( + p: number, + v: number, + min: number, + max: number, +): Span | null { + if (v === 0) { + return min <= p && p <= max + ? { entry: -Infinity, exit: Infinity } + : null; + } + + let t1 = (min - p) / v; + let t2 = (max - p) / v; + if (t1 > t2) { + [t1, t2] = [t2, t1]; + } + + return { entry: t1, exit: t2 }; +} diff --git a/workshop/steps/06-ray-vs-aabb/ray.test.ts b/workshop/steps/06-ray-vs-aabb/ray.test.ts new file mode 100644 index 0000000..5a873d1 --- /dev/null +++ b/workshop/steps/06-ray-vs-aabb/ray.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { type AABB, vec } from "./given.ts"; +import { rayVsAABB } from "./ray.ts"; + +test("direct hit moving right -> normal points left", () => { + // point at (0,5) moving +10 in x; box spans x:[5,8], y:[0,10] + const box: AABB = { x: 5, y: 0, w: 3, h: 10 }; + const hit = rayVsAABB(vec(0, 5), vec(10, 0), box)!; + expect(hit.time).toBeCloseTo(0.5); // reaches x=5 at half the frame + expect(hit.normal).toEqual({ x: -1, y: 0 }); +}); + +test("passes above the box (not moving in y, outside in y) -> null", () => { + const box: AABB = { x: 5, y: 0, w: 3, h: 10 }; + expect(rayVsAABB(vec(0, 20), vec(10, 0), box)).toBeNull(); +}); + +test("diagonal hit where y is the blocking axis -> vertical normal", () => { + // box x:[5,15], y:[8,18]; you enter x at t=0.5 but y only at t=0.8, + // so the real entry is 0.8 and the wall you hit is horizontal (top). + const box: AABB = { x: 5, y: 8, w: 10, h: 10 }; + const hit = rayVsAABB(vec(0, 0), vec(10, 10), box)!; + expect(hit.time).toBeCloseTo(0.8); + expect(hit.normal).toEqual({ x: 0, y: -1 }); +}); + +test("flies past the corner (windows never overlap) -> null", () => { + // inside x during [0.5,0.8], inside y during [0,0.1] — never both at once + const box: AABB = { x: 5, y: 0, w: 3, h: 1 }; + expect(rayVsAABB(vec(0, 0), vec(10, 10), box)).toBeNull(); +}); + +test("collision is real but lands after this frame -> null", () => { + // too slow: reaches the box at t=5, outside [0,1) + const box: AABB = { x: 5, y: 0, w: 2, h: 10 }; + expect(rayVsAABB(vec(0, 0), vec(1, 0), box)).toBeNull(); +}); diff --git a/workshop/steps/06-ray-vs-aabb/ray.ts b/workshop/steps/06-ray-vs-aabb/ray.ts new file mode 100644 index 0000000..5d4181d --- /dev/null +++ b/workshop/steps/06-ray-vs-aabb/ray.ts @@ -0,0 +1,52 @@ +import { type AABB, type Span, sweepInterval, type Vec } from "./given.ts"; + +/** A collision: the fraction of the frame at impact, and the surface normal. */ +export type Hit = { time: number; normal: Vec }; + +/** + * A point at `p` moving by `v` over this frame, against a static box. + * Returns the entry Hit, or null if there is no collision this frame. + * + * Reuse sweepInterval twice (x and y), then combine: + * entry = max of the two entries, exit = min of the two exits. + */ +export function rayVsAABB(p: Vec, v: Vec, box: AABB): Hit | null { + const spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w); + if (spanX === null) { + return null; + } + const spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h); + if (spanY === null) { + return null; + } + + const span: Span = { + entry: Math.max(spanX.entry, spanY.entry), + exit: Math.min(spanX.exit, spanY.exit), + }; + + if (span.entry > span.exit) { + return null; + } + + if (span.entry >= 1 || span.exit <= 0) { + return null; + } + + const time = span.entry; + const normal = { + x: 0, + y: 0, + }; + + if (spanX.entry > spanY.entry) { + normal.x = v.x > 0 ? -1 : 1; + } else { + normal.y = v.y > 0 ? -1 : 1; + } + + return { + time, + normal, + }; +} diff --git a/workshop/steps/07-swept-aabb/README.md b/workshop/steps/07-swept-aabb/README.md new file mode 100644 index 0000000..cf6e1d3 --- /dev/null +++ b/workshop/steps/07-swept-aabb/README.md @@ -0,0 +1,68 @@ +# Step 07 — Swept AABB (the Minkowski trick) + +Step 06 handled a moving **point** vs a box. But in a real game the thing that +moves is a **box** (the player), not a point. This step turns "moving box vs box" +into "moving point vs box" so you can reuse step 06 *unchanged*. That conversion +is the single cleverest idea in the whole engine. + +## The problem + +Box A (the player) sits at corner `(a.x, a.y)` with size `a.w × a.h`, and moves by +`v` this frame. Box B (a wall) is static. When do they touch? + +It's fiddly because *both* shapes have size. You'd have to track four edges of A +against four edges of B. Ugh. + +## The trick: grow B, shrink A to a point + +Watch what "just touching" means on the x-axis. A spans `[a.x, a.x + a.w]`, B +spans `[b.x, b.x + b.w]`. They overlap when: + +``` +a.x < b.x + b.w AND b.x < a.x + a.w +``` + +Rearrange the second one (`b.x - a.w < a.x`) and you get a statement purely about +**`a.x`**, the corner of A: + +``` +b.x - a.w < a.x < b.x + b.w +``` + +Read that: A's *corner* `a.x` behaves exactly like a **point** sliding inside a +**wider interval** — one that starts `a.w` earlier and is `a.w` longer than B. +The same happens on y with `a.h`. + +So: **dump all of A's size onto B, and A collapses to just its corner point.** + +``` +inflated = { + x: b.x - a.w, // push the left edge out by A's width + y: b.y - a.h, // push the top edge out by A's height + w: b.w + a.w, // grow width by A's width + h: b.h + a.h, // grow height by A's height +} +point = { x: a.x, y: a.y } // A is now just its corner +``` + +This grown box is the **Minkowski sum** of B with A. And "does this point, moving +by `v`, hit `inflated`?" is *exactly* `rayVsAABB` from step 06. You're done in +three lines. + +> Sanity picture: player box 2 wide with its right edge at x=2, wall left edge at +> x=5 → real gap is 3. Inflate: `inflated.x = 5 - 2 = 3`, and the player's corner +> sits at x=0, so the corner-to-inflated-edge gap is also 3. Same answer, simpler +> shape. The inflation *bakes A's size into the wall* so the corner can pretend to +> be a point. + +This is the heart of your real engine's `sweptAABB` — the `inflAABB` it builds is +this very inflated box, and `(ax, ay)` is this corner point. + +## Task + +Implement `sweptAABB(a, v, b)` in `swept.ts`: build the inflated box, then call +the provided `rayVsAABB` (finished, in `given.ts`). Return its `Hit | null`. + +```sh +bun test workshop/steps/07-swept-aabb +``` diff --git a/workshop/steps/07-swept-aabb/given.ts b/workshop/steps/07-swept-aabb/given.ts new file mode 100644 index 0000000..8412200 --- /dev/null +++ b/workshop/steps/07-swept-aabb/given.ts @@ -0,0 +1,54 @@ +// Finished in earlier steps — reuse, don't rewrite. +export type Vec = { x: number; y: number }; +export const vec = (x: number, y: number): Vec => ({ x, y }); + +export type AABB = { x: number; y: number; w: number; h: number }; +export type Span = { entry: number; exit: number }; +export type Hit = { time: number; normal: Vec }; + +/** Step 05, finished. */ +export function sweepInterval( + p: number, + v: number, + min: number, + max: number, +): Span | null { + if (v === 0) { + return min <= p && p <= max + ? { entry: -Infinity, exit: Infinity } + : null; + } + + let t1 = (min - p) / v; + let t2 = (max - p) / v; + if (t1 > t2) { + [t1, t2] = [t2, t1]; + } + + return { entry: t1, exit: t2 }; +} + +/** Step 06, finished. A moving point vs a static box. */ +export function rayVsAABB(p: Vec, v: Vec, box: AABB): Hit | null { + const spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w); + const spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h); + if (spanX === null || spanY === null) { + return null; + } + + const entry = Math.max(spanX.entry, spanY.entry); + const exit = Math.min(spanX.exit, spanY.exit); + if (entry > exit) { + return null; + } + if (entry >= 1 || exit <= 0) { + return null; + } + + const normal = + spanX.entry > spanY.entry + ? { x: v.x > 0 ? -1 : 1, y: 0 } + : { x: 0, y: v.y > 0 ? -1 : 1 }; + + return { time: entry, normal }; +} diff --git a/workshop/steps/07-swept-aabb/swept.test.ts b/workshop/steps/07-swept-aabb/swept.test.ts new file mode 100644 index 0000000..af466df --- /dev/null +++ b/workshop/steps/07-swept-aabb/swept.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; +import { type AABB, vec } from "./given.ts"; +import { sweptAABB } from "./swept.ts"; + +test("box moving right into a wall -> hits at the real gap", () => { + // A right edge at x=2, B left edge at x=5 -> gap 3, speed 10 -> t=0.3 + const a: AABB = { x: 0, y: 0, w: 2, h: 2 }; + const b: AABB = { x: 5, y: 0, w: 2, h: 2 }; + const hit = sweptAABB(a, vec(10, 0), b)!; + expect(hit.time).toBeCloseTo(0.3); + expect(hit.normal).toEqual({ x: -1, y: 0 }); +}); + +test("box passes below the wall -> null", () => { + const a: AABB = { x: 0, y: 10, w: 2, h: 2 }; + const b: AABB = { x: 5, y: 0, w: 2, h: 2 }; + expect(sweptAABB(a, vec(10, 0), b)).toBeNull(); +}); + +test("box moving left into a wall -> normal points right", () => { + // A left edge at x=10, B right edge at x=2 -> gap 8, speed 10 -> t=0.8 + const a: AABB = { x: 10, y: 0, w: 2, h: 2 }; + const b: AABB = { x: 0, y: 0, w: 2, h: 2 }; + const hit = sweptAABB(a, vec(-10, 0), b)!; + expect(hit.time).toBeCloseTo(0.8); + expect(hit.normal).toEqual({ x: 1, y: 0 }); +}); + +test("diagonal where y blocks first -> horizontal normal", () => { + const a: AABB = { x: 0, y: 0, w: 2, h: 2 }; + const b: AABB = { x: 5, y: 8, w: 2, h: 2 }; + const hit = sweptAABB(a, vec(10, 10), b)!; + expect(hit.time).toBeCloseTo(0.6); + expect(hit.normal).toEqual({ x: 0, y: -1 }); +}); diff --git a/workshop/steps/07-swept-aabb/swept.ts b/workshop/steps/07-swept-aabb/swept.ts new file mode 100644 index 0000000..1f5cd4d --- /dev/null +++ b/workshop/steps/07-swept-aabb/swept.ts @@ -0,0 +1,20 @@ +import { type AABB, type Hit, rayVsAABB, type Vec } from "./given.ts"; + +/** + * A moving box `a` (velocity `v` over this frame) vs a static box `b`. + * + * Inflate `b` by `a`'s size (Minkowski sum), collapse `a` to its corner point, + * then reuse rayVsAABB. Return its Hit, or null for no collision this frame. + */ +export function sweptAABB(a: AABB, v: Vec, b: AABB): Hit | null { + const inflated: AABB = { + x: b.x - a.w, + y: b.y - a.h, + w: b.w + a.w, + h: b.h + a.h, + }; + + const point: Vec = { x: a.x, y: a.y }; + + return rayVsAABB(point, v, inflated); +} diff --git a/workshop/steps/08-resolve/README.md b/workshop/steps/08-resolve/README.md new file mode 100644 index 0000000..e7d6530 --- /dev/null +++ b/workshop/steps/08-resolve/README.md @@ -0,0 +1,43 @@ +# Step 08 — Resolve (move *to* the wall, not through it) + +Detection is done. Now for **response** — actually reacting to the hit. This first +half is almost embarrassingly small, but it introduces the idea the whole loop +(step 10) is built on: **the frame isn't all-or-nothing.** + +## Concept + +`sweptAABB` hands you a `Hit` with a `time` between 0 and 1 — the fraction of the +frame at which you'd collide. So instead of moving the full displacement `v` +(which would bury you inside the wall), you move only the part of it that happens +*before* impact: + +``` +no hit → newPos = pos + v (nothing in the way: take the whole move) +hit at t → newPos = pos + v * t (stop exactly at the contact point) +``` + +That's it — you already have `add` and `scale`; this is them, gated on the hit. + +### The leftover that matters + +Here's the seed for step 10: if you hit at `t = 0.3`, you only used **30%** of +this frame. The other **70%** is still owed to the player — they should keep +moving for the rest of the frame, just not *into* the wall. That leftover time is +exactly why sliding (step 09) and the loop (step 10) exist. A collision doesn't +end the frame; it **interrupts** it. + +> Real-engine footnote: production code usually moves to `t - EPSILON` (a hair +> *short* of contact) so floating-point error can't leave the box a sliver inside +> the wall, where the next frame's sweep would start already-overlapping. Your +> `nage` does this with `Math.max(0, time - EPSILON)`. We keep the kata exact so +> the numbers stay clean — just know that tiny backoff is there for a real reason. + +## Task + +Implement `resolve(pos, v, hit)` in `resolve.ts`: return the full move when `hit` +is `null`, otherwise the position at contact. `vec/add/scale` and the `Hit` type +are in `given.ts`. + +```sh +bun test workshop/steps/08-resolve +``` diff --git a/workshop/steps/08-resolve/given.ts b/workshop/steps/08-resolve/given.ts new file mode 100644 index 0000000..9217d44 --- /dev/null +++ b/workshop/steps/08-resolve/given.ts @@ -0,0 +1,8 @@ +// Finished in earlier steps. +export type Vec = { x: number; y: number }; +export const vec = (x: number, y: number): Vec => ({ x, y }); +export const add = (a: Vec, b: Vec): Vec => ({ x: a.x + b.x, y: a.y + b.y }); +export const scale = (a: Vec, s: number): Vec => ({ x: a.x * s, y: a.y * s }); + +/** From step 06/07. */ +export type Hit = { time: number; normal: Vec }; diff --git a/workshop/steps/08-resolve/resolve.test.ts b/workshop/steps/08-resolve/resolve.test.ts new file mode 100644 index 0000000..32509d0 --- /dev/null +++ b/workshop/steps/08-resolve/resolve.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { vec } from "./given.ts"; +import { resolve } from "./resolve.ts"; + +test("no collision -> full move", () => { + expect(resolve(vec(0, 0), vec(10, 5), null)).toEqual({ x: 10, y: 5 }); +}); + +test("collision at t=0.3 -> stop at contact", () => { + const hit = { time: 0.3, normal: vec(-1, 0) }; + const p = resolve(vec(0, 0), vec(10, 0), hit); + expect(p.x).toBeCloseTo(3); + expect(p.y).toBeCloseTo(0); +}); + +test("collision at t=0 -> don't move at all", () => { + const hit = { time: 0, normal: vec(-1, 0) }; + expect(resolve(vec(5, 5), vec(10, 10), hit)).toEqual({ x: 5, y: 5 }); +}); diff --git a/workshop/steps/08-resolve/resolve.ts b/workshop/steps/08-resolve/resolve.ts new file mode 100644 index 0000000..9bcf2f6 --- /dev/null +++ b/workshop/steps/08-resolve/resolve.ts @@ -0,0 +1,11 @@ +import { add, type Hit, scale, type Vec } from "./given.ts"; + +/** + * The new position after this frame's move, stopping at a collision if there is + * one. + * hit === null -> pos + v (take the whole move) + * hit -> pos + v * time (stop at the contact point) + */ +export function resolve(pos: Vec, v: Vec, hit: Hit | null): Vec { + return hit ? add(pos, scale(v, hit.time)) : add(pos, v); +} diff --git a/workshop/steps/09-slide/README.md b/workshop/steps/09-slide/README.md new file mode 100644 index 0000000..6d83a0a --- /dev/null +++ b/workshop/steps/09-slide/README.md @@ -0,0 +1,58 @@ +# Step 09 — Slide (this is what `dot` was for) + +Back in step 02 you implemented `dot` and I said "you'll see later." This is +later. Sliding is the difference between a game that feels good and one where you +stick to every wall like glue. + +## The problem + +You're moving with velocity `v` and you hit a wall whose outward normal is `n`. +If you just *stop* (velocity → 0), the player jams against the wall — press +into a wall diagonally and all motion dies, even the part that was parallel to +the wall and perfectly fine. What we actually want: **cancel only the part of `v` +that pushes *into* the wall, and keep the part that runs *along* it.** That's a +slide. + +## The math (projection) + +Any velocity `v` can be split into two pieces relative to the wall: + +- the part **along the normal** (into/out of the wall) — this is what the wall forbids, +- the part **along the wall surface** (perpendicular to the normal) — this is fine. + +Because `n` is a **unit vector**, the amount of `v` pointing along `n` is exactly +`dot(v, n)`. That single number is "how much of `v` goes straight into the wall." +The vector piece pointing into the wall is `n * dot(v, n)`. Subtract it off: + +``` +vSlide = v - n * dot(v, n) +``` + +What's left has **zero** component along the normal — it lies flat against the +wall. (That's the geometric meaning of `dot`: it measures how much two vectors +share a direction. Subtract the shared-with-the-normal part, and nothing pointing +into the wall survives.) + +### Feel it with numbers + +Move `v = (10, 5)` into a wall whose normal is `n = (-1, 0)` (a vertical wall on +your right, pushing you left): + +- `dot(v, n) = 10*(-1) + 5*0 = -10` +- `n * dot = (-1,0) * -10 = (10, 0)` +- `vSlide = (10,5) - (10,0) = (0, 5)` + +The rightward motion (10) is gone — that was driving you into the wall — but the +downward motion (5) survives untouched. You slide down the wall. Exactly right. + +This is the very last line of your engine's `moveAndSlide`: +`velocity -= normal * (velocity · normal)`. + +## Task + +Implement `slide(v, normal)` in `slide.ts` using `dot`, `scale`, and `sub` (all +in `given.ts`). Return the velocity with its into-the-wall component removed. + +```sh +bun test workshop/steps/09-slide +``` diff --git a/workshop/steps/09-slide/given.ts b/workshop/steps/09-slide/given.ts new file mode 100644 index 0000000..33d5dfd --- /dev/null +++ b/workshop/steps/09-slide/given.ts @@ -0,0 +1,6 @@ +// Finished in earlier steps. +export type Vec = { x: number; y: number }; +export const vec = (x: number, y: number): Vec => ({ x, y }); +export const sub = (a: Vec, b: Vec): Vec => ({ x: a.x - b.x, y: a.y - b.y }); +export const scale = (a: Vec, s: number): Vec => ({ x: a.x * s, y: a.y * s }); +export const dot = (a: Vec, b: Vec): number => a.x * b.x + a.y * b.y; diff --git a/workshop/steps/09-slide/slide.test.ts b/workshop/steps/09-slide/slide.test.ts new file mode 100644 index 0000000..83ddc72 --- /dev/null +++ b/workshop/steps/09-slide/slide.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +import { dot, vec } from "./given.ts"; +import { slide } from "./slide.ts"; + +test("vertical wall kills horizontal motion, keeps vertical", () => { + // moving (10,5) into a wall with normal (-1,0) + const s = slide(vec(10, 5), vec(-1, 0)); + expect(s.x).toBeCloseTo(0); + expect(s.y).toBeCloseTo(5); +}); + +test("horizontal wall kills vertical motion, keeps horizontal", () => { + const s = slide(vec(10, 5), vec(0, -1)); + expect(s.x).toBeCloseTo(10); + expect(s.y).toBeCloseTo(0); +}); + +test("result always runs along the wall (perpendicular to the normal)", () => { + // a diagonal (unit) normal: 0.36 + 0.64 = 1 + const normal = vec(-0.6, -0.8); + const s = slide(vec(10, 0), normal); + // the slid velocity has no component into the wall -> dot with normal is 0 + expect(dot(s, normal)).toBeCloseTo(0); +}); diff --git a/workshop/steps/09-slide/slide.ts b/workshop/steps/09-slide/slide.ts new file mode 100644 index 0000000..6374904 --- /dev/null +++ b/workshop/steps/09-slide/slide.ts @@ -0,0 +1,11 @@ +import { dot, scale, sub, type Vec } from "./given.ts"; + +/** + * Remove the component of `v` that points into a wall with the given (unit) + * normal, leaving only the motion that runs along the wall. + * + * vSlide = v - normal * dot(v, normal) + */ +export function slide(v: Vec, normal: Vec): Vec { + return sub(v, scale(normal, dot(v, normal))); +} diff --git a/workshop/steps/10-move-and-slide/README.md b/workshop/steps/10-move-and-slide/README.md new file mode 100644 index 0000000..ebc5274 --- /dev/null +++ b/workshop/steps/10-move-and-slide/README.md @@ -0,0 +1,75 @@ +# Step 10 — moveAndSlide (the whole engine, in one loop) + +This is it. Every function you've written since step 01 gets tied together here +into the exact loop your real `nage` engine runs. It's the hardest step, so I'll +give you the skeleton — you write the code. + +## The idea + +A single collision doesn't end the frame (step 08's "leftover that matters"). You +hit a wall at `t = 0.3`, slide, and **70% of the frame is still owed** — during +which you might hit *another* wall, slide again, and so on. So moving is a small +**loop**: sweep → stop at the nearest hit → slide → repeat with the leftover time. + +We loop a **maximum of 4 times** (your engine's cap) — enough to handle a corner +(hit a wall, slide, hit the perpendicular wall, slide, stop) without ever risking +an infinite loop. + +## The algorithm + +You're given the moving `box`, its full-frame displacement `v`, and a list of +static `walls`. Track a running `pos`, a running `vel`, and `timeLeft` (fraction +of the frame remaining, starts at `1`). + +``` +pos = { box.x, box.y } +vel = { v.x, v.y } +timeLeft = 1 + +repeat up to 4 times, while timeLeft > 0: + move = vel * timeLeft // what's left to travel this frame + + find the NEAREST hit: for each wall, sweptAABB(box-at-pos, move, wall); + keep the hit with the smallest .time + + if no hit: + pos = pos + move // clear path: take the rest of the move + stop + + else: + pos = pos + move * max(0, hit.time - EPSILON) // advance to just before contact + vel = slide(vel, hit.normal) // redirect along the wall + timeLeft = timeLeft * (1 - hit.time) // consume the used fraction + +return pos +``` + +Two things worth understanding, not just copying: + +- **`move = vel * timeLeft`.** `vel` is a *full-frame* displacement (how far you'd + go in a whole frame at this velocity). You only have `timeLeft` of the frame + left, so the actual travel is `vel * timeLeft`. `sweptAABB`'s returned `time` is + then a fraction *of that sub-move*, which is why `pos + move * time` is correct. + +- **The `EPSILON` backoff** (`max(0, hit.time - EPSILON)`). Stop a hair *short* of + the wall. If you land exactly on it, floating-point error can leave you a sliver + inside — and next iteration's sweep would start already-overlapping, reporting a + garbage negative-time "collision" that makes you stick or jitter. That tiny gap + is exactly the `Math.max(0, time - EPSILON)` in your real `moveAndSlide`. Now you + know *why* it's there. `EPSILON` is provided in `given.ts`. + +> **Moving-vs-moving (why your real engine has `velocity - otherVel`).** Here the +> walls are static, so we sweep with plain `vel`. When the *other* body also moves, +> you sweep in its frame of reference by using the **relative** velocity +> `vel - otherVel` — then the exact same loop works, because from the other body's +> point of view it's standing still. That's the only difference between this kata +> and the full engine. The loop itself doesn't change. + +## Task + +Implement `moveAndSlide(box, v, walls)` in `moveAndSlide.ts`. Everything you need — +`sweptAABB`, `slide`, the vector ops, `EPSILON` — is finished in `given.ts`. + +```sh +bun test workshop/steps/10-move-and-slide +``` diff --git a/workshop/steps/10-move-and-slide/given.ts b/workshop/steps/10-move-and-slide/given.ts new file mode 100644 index 0000000..93f22dd --- /dev/null +++ b/workshop/steps/10-move-and-slide/given.ts @@ -0,0 +1,65 @@ +// The whole kernel you've built, steps 01-09, finished. Reuse it. +export type Vec = { x: number; y: number }; +export type AABB = { x: number; y: number; w: number; h: number }; +export type Span = { entry: number; exit: number }; +export type Hit = { time: number; normal: Vec }; + +/** A hair of slack so we stop just short of a wall instead of inside it. */ +export const EPSILON = 1e-4; + +export const vec = (x: number, y: number): Vec => ({ x, y }); +export const add = (a: Vec, b: Vec): Vec => ({ x: a.x + b.x, y: a.y + b.y }); +export const sub = (a: Vec, b: Vec): Vec => ({ x: a.x - b.x, y: a.y - b.y }); +export const scale = (a: Vec, s: number): Vec => ({ x: a.x * s, y: a.y * s }); +export const dot = (a: Vec, b: Vec): number => a.x * b.x + a.y * b.y; + +export function sweepInterval( + p: number, + v: number, + min: number, + max: number, +): Span | null { + if (v === 0) { + return min <= p && p <= max + ? { entry: -Infinity, exit: Infinity } + : null; + } + let t1 = (min - p) / v; + let t2 = (max - p) / v; + if (t1 > t2) { + [t1, t2] = [t2, t1]; + } + return { entry: t1, exit: t2 }; +} + +export function rayVsAABB(p: Vec, v: Vec, box: AABB): Hit | null { + const spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w); + const spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h); + if (spanX === null || spanY === null) { + return null; + } + const entry = Math.max(spanX.entry, spanY.entry); + const exit = Math.min(spanX.exit, spanY.exit); + if (entry > exit || entry >= 1 || exit <= 0) { + return null; + } + const normal = + spanX.entry > spanY.entry + ? { x: v.x > 0 ? -1 : 1, y: 0 } + : { x: 0, y: v.y > 0 ? -1 : 1 }; + return { time: entry, normal }; +} + +export function sweptAABB(a: AABB, v: Vec, b: AABB): Hit | null { + const inflated: AABB = { + x: b.x - a.w, + y: b.y - a.h, + w: b.w + a.w, + h: b.h + a.h, + }; + return rayVsAABB({ x: a.x, y: a.y }, v, inflated); +} + +export function slide(v: Vec, normal: Vec): Vec { + return sub(v, scale(normal, dot(v, normal))); +} diff --git a/workshop/steps/10-move-and-slide/moveAndSlide.test.ts b/workshop/steps/10-move-and-slide/moveAndSlide.test.ts new file mode 100644 index 0000000..7ce7a06 --- /dev/null +++ b/workshop/steps/10-move-and-slide/moveAndSlide.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { type AABB, vec } from "./given.ts"; +import { moveAndSlide } from "./moveAndSlide.ts"; + +const player: AABB = { x: 0, y: 0, w: 2, h: 2 }; + +test("no walls -> full move", () => { + const p = moveAndSlide(player, vec(10, 5), []); + expect(p.x).toBeCloseTo(10); + expect(p.y).toBeCloseTo(5); +}); + +test("head-on into the NEAREST of two walls -> stops there", () => { + const walls: AABB[] = [ + { x: 5, y: 0, w: 2, h: 2 }, // nearer + { x: 8, y: 0, w: 2, h: 2 }, // farther + ]; + const p = moveAndSlide(player, vec(10, 0), walls); + expect(p.x).toBeCloseTo(3); // right edge (2) meets wall left edge (5) + expect(p.y).toBeCloseTo(0); +}); + +test("diagonal into a vertical wall -> slides down it", () => { + // x is blocked at 3, but the downward motion keeps going for the whole frame + const walls: AABB[] = [{ x: 5, y: -10, w: 2, h: 40 }]; + const p = moveAndSlide(player, vec(10, 10), walls); + expect(p.x).toBeCloseTo(3); + expect(p.y).toBeCloseTo(10); +}); + +test("diagonal onto a floor -> slides along it", () => { + // falls until its bottom meets the floor top (y=8 -> corner y=6), keeps moving in x + const walls: AABB[] = [{ x: 0, y: 8, w: 20, h: 2 }]; + const p = moveAndSlide(player, vec(5, 10), walls); + expect(p.x).toBeCloseTo(5); + expect(p.y).toBeCloseTo(6); +}); diff --git a/workshop/steps/10-move-and-slide/moveAndSlide.ts b/workshop/steps/10-move-and-slide/moveAndSlide.ts new file mode 100644 index 0000000..b6c87fe --- /dev/null +++ b/workshop/steps/10-move-and-slide/moveAndSlide.ts @@ -0,0 +1,59 @@ +import { + type AABB, + add, + EPSILON, + type Hit, + scale, + slide, + sweptAABB, + type Vec, +} from "./given.ts"; + +/** + * Move `box` by the full-frame displacement `v`, sliding along any `walls` it + * hits, up to 4 iterations. Returns the final corner position. + * + * See the README for the algorithm. You already have everything you need in + * given.ts: sweptAABB, slide, add, scale, EPSILON. + */ +export function moveAndSlide(box: AABB, v: Vec, walls: AABB[]): Vec { + let pos: Vec = { x: box.x, y: box.y }; + let vel: Vec = { x: v.x, y: v.y }; + + let timeLeft = 1; + let i = 0; + while (i++ < 4 && timeLeft > 0) { + const move = scale(vel, timeLeft); + let nearest: Hit = { + normal: { x: 0, y: 0 }, + time: 2, + }; + + for (const wall of walls) { + const hit = sweptAABB( + { + x: pos.x, + y: pos.y, + w: box.w, + h: box.h, + }, + move, + wall, + ); + if (hit !== null) { + nearest = hit.time < nearest.time ? hit : nearest; + } + } + + if (nearest.time === 2) { + pos = add(pos, move); + break; + } else { + pos = add(pos, scale(move, Math.max(0, nearest.time - EPSILON))); + vel = slide(vel, nearest.normal); + timeLeft = timeLeft * (1 - nearest.time); + } + } + + return pos; +} diff --git a/workshop/steps/11-capstone/README.md b/workshop/steps/11-capstone/README.md new file mode 100644 index 0000000..71c0d3e --- /dev/null +++ b/workshop/steps/11-capstone/README.md @@ -0,0 +1,45 @@ +# Step 11 — Capstone: watch your kernel run + +No test to make green here. This is the payoff. `game.js` contains the **exact +engine you built** across steps 01–10 — the same `sweptAABB`, `slide`, and +`moveAndSlide`, assembled into one file — wired to a canvas and the arrow keys. + +## Run it + +It's a plain HTML file with no build step. Any of these work: + +```sh +# simplest: just open the file in a browser +xdg-open workshop/steps/11-capstone/index.html # linux +# or drag index.html into a browser window + +# or serve it (nicer, avoids any file:// quirks): +bunx serve workshop/steps/11-capstone +# then open the printed http://localhost:... URL +``` + +Arrow keys move the pink box. Run it into the border, the ledge, the pillar, the +bar. Push diagonally into a wall and watch it **slide** along instead of sticking. +That sliding is your step-09 `dot`-product projection. The fact that it stops +*at* the wall instead of tunneling through, even at speed, is your step-07 swept +detection. The clean corners are your step-10 loop running twice in one frame. + +## Make it yours (optional) + +- Open `game.js`. The top half is your kernel — read it and confirm it matches + what you wrote. Swap in your own `moveAndSlide` from step 10 and check it feels + identical (it will). +- Add a wall to the `walls` array. Change `SPEED`. Make the player bigger. +- Try **deleting the `EPSILON` backoff** (`Math.max(0, nearest.time - EPSILON)` + → `nearest.time`) and push into a wall. Watch it stick and jitter. Then put it + back. Now you've *felt* why that line exists in your real engine. + +## You're back + +That's the whole climb: pairs of numbers → sweeping a point → sweeping a box via +Minkowski → detecting the hit → stopping and sliding → the full loop → a thing you +can play. Every rung is a function that exists, by name, inside your real +`engine/system/physics.ts`. + +Now go open the real `sweptAABB` with fresh eyes. You know exactly what every line +is *supposed* to do — so the two lines that don't should stand out. Happy hunting. diff --git a/workshop/steps/11-capstone/game.js b/workshop/steps/11-capstone/game.js new file mode 100644 index 0000000..1ee4610 --- /dev/null +++ b/workshop/steps/11-capstone/game.js @@ -0,0 +1,179 @@ +// ── The physics kernel you built across steps 01–10, assembled in one file ── +// (Plain JS so the demo runs from a file:// URL with zero build step. Compare it +// to your own step-10 code — it's the same engine you wrote.) +const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y }); +const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y }); +const scale = (a, s) => ({ x: a.x * s, y: a.y * s }); +const dot = (a, b) => a.x * b.x + a.y * b.y; +const length = (a) => Math.sqrt(a.x * a.x + a.y * a.y); +function normalize(a) { + const l = length(a); + return l === 0 ? { x: 0, y: 0 } : { x: a.x / l, y: a.y / l }; +} + +const EPSILON = 1e-4; + +function sweepInterval(p, v, min, max) { + if (v === 0) { + return min <= p && p <= max ? { entry: -Infinity, exit: Infinity } : null; + } + + let t1 = (min - p) / v; + let t2 = (max - p) / v; + if (t1 > t2) { + [t1, t2] = [t2, t1]; + } + + return { entry: t1, exit: t2 }; +} + +function rayVsAABB(p, v, box) { + const spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w); + const spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h); + if (spanX === null || spanY === null) return null; + const entry = Math.max(spanX.entry, spanY.entry); + const exit = Math.min(spanX.exit, spanY.exit); + if (entry > exit || entry >= 1 || exit <= 0) return null; + const normal = + spanX.entry > spanY.entry + ? { x: v.x > 0 ? -1 : 1, y: 0 } + : { x: 0, y: v.y > 0 ? -1 : 1 }; + return { time: entry, normal }; +} + +function sweptAABB(a, v, b) { + const inflated = { x: b.x - a.w, y: b.y - a.h, w: b.w + a.w, h: b.h + a.h }; + return rayVsAABB({ x: a.x, y: a.y }, v, inflated); +} + +const slide = (v, n) => sub(v, scale(n, dot(v, n))); + +function moveAndSlide(box, v, walls) { + let pos = { x: box.x, y: box.y }; + let vel = { x: v.x, y: v.y }; + let timeLeft = 1; + + for (let i = 0; i < 4 && timeLeft > 0; i++) { + const move = scale(vel, timeLeft); + let nearest = null; + for (const wall of walls) { + const hit = sweptAABB( + { x: pos.x, y: pos.y, w: box.w, h: box.h }, + move, + wall, + ); + if (hit && (nearest === null || hit.time < nearest.time)) { + nearest = hit; + } + } + if (nearest === null) { + pos = add(pos, move); + break; + } + pos = add(pos, scale(move, Math.max(0, nearest.time - EPSILON))); + vel = slide(vel, nearest.normal); + timeLeft *= 1 - nearest.time; + } + return pos; +} + +// ─────────────────────────── the playable demo ─────────────────────────── + +const W = 240; +const H = 160; +const SPEED = 0.07; // units per millisecond + +const canvas = document.getElementById("view"); +const ctx = canvas.getContext("2d"); +ctx.imageSmoothingEnabled = false; + +// player is an AABB (top-left corner + size) +const player = { x: 40, y: 40, w: 12, h: 12 }; + +// walls: a border plus a few interior blocks to slide against +const T = 8; // border thickness + +const ledge = { x: 70, y: 40, w: 40, h: 16 }; // a ledge +let ledgeDir = 1; + +const walls = [ + { x: 0, y: 0, w: W, h: T }, // top + { x: 0, y: H - T, w: W, h: T }, // bottom + { x: 0, y: 0, w: T, h: H }, // left + { x: W - T, y: 0, w: T, h: H }, // right + { x: 150, y: 30, w: 16, h: 90 }, // a pillar + { x: 96, y: 96, w: 60, h: 16 }, // a bar + ledge, +]; + +const keys = { + ArrowUp: false, + ArrowDown: false, + ArrowLeft: false, + ArrowRight: false, +}; +addEventListener("keydown", (e) => { + if (e.key in keys) { + keys[e.key] = true; + e.preventDefault(); + } +}); +addEventListener("keyup", (e) => { + if (e.key in keys) { + keys[e.key] = false; + e.preventDefault(); + } +}); + +function drawBox(b, fill, stroke) { + ctx.fillStyle = fill; + ctx.fillRect(b.x, b.y, b.w, b.h); + if (stroke) { + ctx.strokeStyle = stroke; + ctx.strokeRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1); + } +} + +let then = performance.now(); +function frame(now) { + const delta = Math.min(now - then, 50); // clamp huge deltas (tab was hidden) + then = now; + + // input -> direction -> velocity (units/ms), normalized so diagonals aren't faster + const dir = normalize({ + x: (keys.ArrowRight ? 1 : 0) - (keys.ArrowLeft ? 1 : 0), + y: (keys.ArrowDown ? 1 : 0) - (keys.ArrowUp ? 1 : 0), + }); + + if (ledge.x + ledge.w >= W) { + ledgeDir = -1; + } else if (ledge.x <= 0) { + ledgeDir = 1; + } + ledge.x += SPEED * ledgeDir * delta; + + const velocity = scale(dir, SPEED); + + // full-frame displacement is velocity * delta — exactly what moveAndSlide expects + const displacement = scale(velocity, delta); + const next = moveAndSlide(player, displacement, walls); + player.x = next.x; + player.y = next.y; + + // draw + ctx.fillStyle = "#12141c"; + ctx.fillRect(0, 0, W, H); + for (let gx = 0; gx <= W; gx += 16) { + ctx.fillStyle = "#191c26"; + ctx.fillRect(gx, 0, 1, H); + } + for (let gy = 0; gy <= H; gy += 16) { + ctx.fillStyle = "#191c26"; + ctx.fillRect(0, gy, W, 1); + } + for (const wall of walls) drawBox(wall, "#2e3550", "#3d4670"); + drawBox(player, "#ee459e", "#ffa8d6"); + + requestAnimationFrame(frame); +} +requestAnimationFrame(frame); diff --git a/workshop/steps/11-capstone/index.html b/workshop/steps/11-capstone/index.html new file mode 100644 index 0000000..79ec1a1 --- /dev/null +++ b/workshop/steps/11-capstone/index.html @@ -0,0 +1,61 @@ + + + + + + nage physics kernel — capstone + + + +
+

your swept-AABB kernel, live

+ +

+ ↑ ↓ ← → to move — run into + the walls and feel it slide +

+
+ + +