├── .air.toml
├── .gitignore
├── Makefile
├── cmd
└── main.go
├── editor.json
├── go.mod
├── levels
├── default.json
└── test.json
├── package-lock.json
├── package.json
├── runner
├── src
├── assert.js
├── behavioral-tree
│ └── tree.js
├── bus.js
├── debug-render.js
├── debug.js
├── editor.d.ts
├── editor.html
├── editor
│ ├── app.js
│ ├── consts.js
│ ├── editor.js
│ ├── editor.test.js
│ ├── html
│ │ ├── level-set.js
│ │ ├── platform.js
│ │ └── utils.js
│ ├── mouse.js
│ ├── platform.js
│ ├── render.js
│ ├── search.js
│ ├── state.js
│ ├── transforms.js
│ └── utils.js
├── game-config.js
├── game-runner.js
├── index.html
├── input
│ └── input.js
├── main.js
├── math
│ ├── aabb.js
│ ├── ease.js
│ ├── utils.js
│ └── vector.js
├── objects
│ ├── caleb
│ │ ├── caleb.js
│ │ ├── input.js
│ │ ├── physics.js
│ │ └── utils.js
│ ├── level
│ │ ├── level.js
│ │ ├── level.test.js
│ │ └── levels
│ │ │ ├── levels.js
│ │ │ ├── one.js
│ │ │ └── utils.js
│ └── relative_numbers.js
├── shader.html
├── shader
│ ├── frag.glsl
│ └── index.js
├── simulate.js
├── simulation
│ └── state.js
├── state
│ ├── operations.js
│ └── state.js
├── types.d.ts
├── utils.js
└── window.js
├── todo.md
└── tsconfig.json
/.air.toml:
--------------------------------------------------------------------------------
1 | root = "."
2 | testdata_dir = "testdata"
3 | tmp_dir = "tmp"
4 |
5 | [build]
6 | args_bin = []
7 | bin = "./tmp/main"
8 | cmd = "go build -o ./tmp/main cmd/main.go"
9 | delay = 1000
10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"]
11 | exclude_file = []
12 | exclude_regex = ["_test.go"]
13 | exclude_unchanged = false
14 | follow_symlink = false
15 | full_bin = "EDITOR_FILE=./editor.json ./tmp/main"
16 | include_dir = []
17 | include_ext = ["go", "tpl", "tmpl", "html", "js"]
18 | include_file = []
19 | kill_delay = "0s"
20 | log = "build-errors.log"
21 | poll = false
22 | poll_interval = 0
23 | post_cmd = []
24 | pre_cmd = []
25 | rerun = false
26 | rerun_delay = 500
27 | send_interrupt = false
28 | stop_on_error = false
29 |
30 | [color]
31 | app = ""
32 | build = "yellow"
33 | main = "magenta"
34 | runner = "green"
35 | watcher = "cyan"
36 |
37 | [log]
38 | main_only = false
39 | silent = false
40 | time = false
41 |
42 | [misc]
43 | clean_on_exit = false
44 |
45 | [proxy]
46 | app_port = 0
47 | enabled = false
48 | proxy_port = 0
49 |
50 | [screen]
51 | clear_on_rebuild = false
52 | keep_scroll = true
53 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | out
3 | results
4 | tmp
5 |
6 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | pr-ready:
2 | tsc
3 | node --test src/**/*.test.js
4 |
5 |
6 |
--------------------------------------------------------------------------------
/cmd/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 | "log"
8 | "log/slog"
9 | "net/http"
10 | "os"
11 | )
12 |
13 | type Data struct {
14 | Path string `json:"path"`
15 | EditorState map[string]interface{} `json:"editorState"`
16 | }
17 |
18 | func save(w http.ResponseWriter, r *http.Request) {
19 |
20 | // Read the request body
21 | body, err := io.ReadAll(r.Body)
22 | if err != nil {
23 | slog.Error("unable to read request body", "error", err)
24 | http.Error(w, "Failed to read request body", http.StatusBadRequest)
25 | return
26 | }
27 | defer r.Body.Close() // Close the body after reading
28 | var data Data
29 | err = json.Unmarshal(body, &data)
30 | if err != nil {
31 | slog.Error("unable to decode json body", "error", err, "body", string(body))
32 | http.Error(w, "Failed to decode json body", http.StatusBadRequest)
33 | return
34 | }
35 |
36 | err = os.WriteFile(data.Path, body, 0644)
37 | if err != nil {
38 | slog.Error("unable to write file", "error", err)
39 | http.Error(w, "Failed to write file", http.StatusBadRequest)
40 | return
41 | }
42 |
43 | // Respond to the client
44 | w.WriteHeader(http.StatusOK)
45 | }
46 |
47 | func main() {
48 | fs := http.FileServer(http.Dir("./src"))
49 | http.Handle("/", fs)
50 | http.HandleFunc("/save", save)
51 |
52 | http.HandleFunc("/get", func(w http.ResponseWriter, r *http.Request) {
53 | path := r.URL.Query().Get("path")
54 | slog.Info("get request", "path", path)
55 | if path == "" {
56 | http.Error(w, "please provide path key", http.StatusBadRequest)
57 | }
58 |
59 | // TODO if/when i make public version of level editor, i should ensure
60 | // i don't screw myself with rando paths
61 |
62 | body, err := os.ReadFile(path)
63 | if err != nil {
64 | http.Error(w, "Failed to write file", http.StatusBadRequest)
65 | return
66 | }
67 |
68 | fmt.Fprintf(w, string(body))
69 | })
70 |
71 | log.Print("Listening on :3000...")
72 | err := http.ListenAndServe(":3000", nil)
73 | if err != nil {
74 | log.Fatal(err)
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/editor.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "editor-save",
3 | "editorState": {
4 | "levelState": {
5 | "levels": [
6 | {
7 | "levels": [
8 | {
9 | "letterMap": [],
10 | "platforms": [
11 | {
12 | "selected": null,
13 | "AABB": {
14 | "pos": {
15 | "x": 8,
16 | "y": 21
17 | },
18 | "width": 14,
19 | "height": 10
20 | },
21 | "behaviors": {
22 | "next": {
23 | "type": "next-level",
24 | "toLevel": 0,
25 | "toLevelPosition": {
26 | "x": 0,
27 | "y": 0
28 | }
29 | }
30 | }
31 | }
32 | ],
33 | "initialPosition": {
34 | "x": 24,
35 | "y": 24
36 | }
37 | }
38 | ],
39 | "title": "empty",
40 | "difficulty": 0,
41 | "initialLevel": 0,
42 | "current": 0
43 | }
44 | ],
45 | "current": 0
46 | },
47 | "debug": false,
48 | "tick": 381,
49 | "change": 1,
50 | "mouse": {
51 | "startTime": 1732223319121,
52 | "startingEl": null
53 | },
54 | "outerRect": {
55 | "margin": 7,
56 | "maxX": 39,
57 | "maxY": 31
58 | },
59 | "selectedElements": []
60 | },
61 | "path": "./levels/default.json"
62 | }
63 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module caleb.theprimeagen.tv
2 |
3 | go 1.23.0
4 |
--------------------------------------------------------------------------------
/levels/default.json:
--------------------------------------------------------------------------------
1 | {"path":"./levels/default.json","editorState":{"levelSet":{"levels":[{"letterMap":[],"platforms":[{"selected":null,"AABB":{"pos":{"x":7,"y":30},"width":32,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":38,"y":26},"width":1,"height":4},"behaviors":{"next":{"type":"next-level","toLevel":1,"toLevelPosition":{"x":1,"y":0}}}},{"selected":null,"AABB":{"pos":{"x":7,"y":7},"width":1,"height":23},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":8,"y":7},"width":31,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":38,"y":8},"width":1,"height":18},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":30,"y":25},"width":3,"height":5},"behaviors":{"instagib":{"type":"instagib"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":12,"y":17},"width":12,"height":7},"behaviors":{}}],"initialPosition":{"x":3,"y":21}},{"letterMap":[],"platforms":[{"selected":null,"AABB":{"pos":{"x":7,"y":30},"width":9,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":7,"y":26},"width":1,"height":4},"behaviors":{"next":{"type":"next-level","toLevel":0,"toLevelPosition":{"x":30,"y":0}}}},{"selected":null,"AABB":{"pos":{"x":15,"y":29},"width":7,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":38,"y":25},"width":1,"height":5},"behaviors":{"next":{"type":"next-level","toLevel":2,"toLevelPosition":{"x":1,"y":0}}}},{"selected":null,"AABB":{"pos":{"x":21,"y":30},"width":18,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":7,"y":7},"width":1,"height":19},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":38,"y":7},"width":1,"height":18},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}}],"initialPosition":{"x":24,"y":24}},{"letterMap":[],"platforms":[{"selected":null,"AABB":{"pos":{"x":7,"y":30},"width":7,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":14,"y":19},"width":1,"height":12},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":14,"y":18},"width":21,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":34,"y":7},"width":5,"height":12},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":7,"y":22},"width":3,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":7,"y":7},"width":3,"height":15},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":10,"y":7},"width":24,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":7,"y":23},"width":1,"height":7},"behaviors":{"next":{"type":"next-level","toLevel":1,"toLevelPosition":{"x":30,"y":0}}}}],"initialPosition":{"x":24,"y":24}},{"letterMap":[],"platforms":[{"selected":null,"AABB":{"pos":{"x":25,"y":25},"width":1,"height":6},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}}],"initialPosition":{"x":24,"y":24}}],"title":"The Initial","difficulty":0,"initialLevel":0,"current":0},"debug":false,"tick":172752,"change":2310,"mouse":{"startTime":1732726109098,"startingEl":null,"state":"invalid"},"outerRect":{"margin":7,"maxX":39,"maxY":31}}}
--------------------------------------------------------------------------------
/levels/test.json:
--------------------------------------------------------------------------------
1 | {"path":"./levels/test.json","editorState":{"levelSet":{"levels":[{"letterMap":[],"platforms":[{"id":1,"selected":null,"AABB":{"pos":{"x":38,"y":8},"width":1,"height":6},"behaviors":{"render":{"type":"render","renderX":0,"renderY":0,"renderHeight":0,"renderWidth":0},"portal":{"to":10,"normal":{"x":-1,"y":0},"type":"portal"}}},{"id":5,"selected":null,"AABB":{"pos":{"x":6,"y":7},"width":1,"height":23},"behaviors":{"render":{"type":"render","renderX":0,"renderY":0,"renderHeight":0,"renderWidth":0},"portal":{"to":10,"normal":{"x":-1,"y":0},"type":"portal"},"lettered":{"type":"lettered","letter":"a"}}},{"id":8,"selected":null,"AABB":{"pos":{"x":12,"y":30},"width":27,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render","renderX":0,"renderY":0,"renderHeight":0,"renderWidth":0}}},{"id":9,"selected":null,"AABB":{"pos":{"x":7,"y":30},"width":5,"height":1},"behaviors":{"next":{"type":"next-level","toLevel":1,"toLevelPosition":{"x":-69,"y":2}}}},{"id":12,"selected":null,"AABB":{"pos":{"x":17,"y":22},"width":1,"height":1},"behaviors":{"lettered":{"type":"lettered","letter":"a"},"render":{"type":"render","renderX":0,"renderY":0,"renderHeight":0,"renderWidth":0}}},{"id":14,"selected":null,"AABB":{"pos":{"x":23,"y":15},"width":1,"height":1},"behaviors":{"lazer":{"type":"lazer"}}},{"id":16,"selected":null,"AABB":{"pos":{"x":11,"y":11},"width":2,"height":1},"behaviors":{"powerup":{"type":"powerup","letter":"x","rangeSpeed":500,"startRange":9,"endRange":17,"actionSpeed":1000,"actionList":"j,k"}}}],"initialPosition":{"x":1,"y":1}},{"letterMap":[],"platforms":[{"id":10,"selected":null,"AABB":{"pos":{"x":7,"y":23},"width":7,"height":8},"behaviors":{"render":{"type":"render","renderX":0,"renderY":0,"renderHeight":0,"renderWidth":0},"portal":{"to":1,"normal":{"x":0,"y":-1},"type":"portal"}}}],"initialPosition":{"x":1,"y":1}}],"title":"empty","difficulty":0,"initialLevel":0,"current":0},"debug":false,"tick":99662,"change":197,"mouse":{"startTime":1734546526328,"startingEl":null,"state":"invalid"},"outerRect":{"margin":7,"maxX":39,"maxY":31}}}
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "caleb",
3 | "lockfileVersion": 3,
4 | "requires": true,
5 | "packages": {
6 | "": {
7 | "devDependencies": {
8 | "@types/node": "^22.9.0"
9 | }
10 | },
11 | "node_modules/@types/node": {
12 | "version": "22.9.0",
13 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
14 | "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
15 | "dev": true,
16 | "dependencies": {
17 | "undici-types": "~6.19.8"
18 | }
19 | },
20 | "node_modules/undici-types": {
21 | "version": "6.19.8",
22 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
23 | "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
24 | "dev": true
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module",
3 | "devDependencies": {
4 | "@types/node": "^22.9.0"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/runner:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | echo "round $1"
4 | node ./src/simulate.js $1 1000000
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/assert.js:
--------------------------------------------------------------------------------
1 | // TODO: Make this objectively better than it is now
2 |
3 | /** @param msg {string}
4 | /** @param data {any[]}
5 | */
6 | function runAssert(msg, data) {
7 | console.error(msg, ...data)
8 | throw new Error(msg)
9 | }
10 |
11 | /** @param msg {string}
12 | /** @param data {any[]}
13 | */
14 | export function never(msg, ...data) {
15 | runAssert(msg, data);
16 | }
17 |
18 | /** @param truthy {boolean}
19 | /** @param msg {string}
20 | /** @param data {any[]}
21 | * */
22 | export function assert(truthy, msg, ...data) {
23 | if (!truthy) {
24 | runAssert(msg, data);
25 | }
26 | }
27 |
28 |
--------------------------------------------------------------------------------
/src/behavioral-tree/tree.js:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/bus.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | /** @type {BusListeners} */
4 | let listeners = {};
5 | let emitTypes = false
6 | /**
7 | * @template {keyof BusArgMap} K
8 | * @type {((args: BusArgMap[K]) => void)[]}
9 | */
10 | let allListeners = []
11 |
12 | export function debug() {
13 | emitTypes = true
14 | }
15 |
16 | /**
17 | * @template {keyof BusArgMap} T
18 | * @param {T} type
19 | * @param {BusArgMap[T]} args
20 | */
21 | export function emit(type, args) {
22 | if (emitTypes) {
23 | console.log("Bus", type, args)
24 | }
25 | for (const cb of listeners[type] || []) {
26 | cb(args)
27 | }
28 |
29 | for (const cb of allListeners) {
30 | cb(args)
31 | }
32 | }
33 |
34 | /**
35 | * @template {keyof BusArgMap} K
36 | * @param {(args: BusArgMap[K]) => void} cb
37 | */
38 | export function listenAll(cb) {
39 | allListeners.push(cb)
40 | }
41 |
42 | /**
43 | * @template {keyof BusArgMap} K
44 | * @param {(args: BusArgMap[K]) => void} cb
45 | */
46 | export function removeAll(cb) {
47 | const idx = allListeners.indexOf(cb)
48 | if (idx >= 0) {
49 | allListeners.splice(idx, 1)
50 | }
51 | }
52 |
53 |
54 | /**
55 | * @template {keyof BusArgMap} K
56 | * @param {K} type
57 | * @param {(args: BusArgMap[K]) => void} cb
58 | */
59 | export function listen(type, cb) {
60 | let cbs = listeners[type]
61 | if (!cbs) {
62 | cbs = listeners[type] = []
63 | }
64 | cbs.push(cb)
65 | }
66 |
67 | /**
68 | * @template {keyof BusArgMap} K
69 | * @param {K} type
70 | * @param {(args: BusArgMap[K]) => void} cb
71 | */
72 | export function remove(type, cb) {
73 | const cbs = listeners[type]
74 | if (!cbs) {
75 | return
76 | }
77 |
78 | const idx = cbs.indexOf(cb)
79 | if (idx >= 0) {
80 | cbs.splice(idx, 1)
81 | }
82 | }
83 |
84 | export function clear() {
85 | listeners = {}
86 | }
87 |
88 | class Render extends Event {
89 | constructor() {
90 | super("render")
91 | }
92 | }
93 |
94 | export function render() {
95 | const render = /** @type {RenderEvent} */(new Render())
96 | emit("render", render)
97 | }
98 |
99 | export function editorChange() {
100 | emit("editor-change", {type: "editor-change"})
101 | }
102 |
--------------------------------------------------------------------------------
/src/debug-render.js:
--------------------------------------------------------------------------------
1 | import { Vector2D } from "./math/vector.js";
2 | import { AABB } from "./math/aabb.js";
3 | import * as CalebUtils from "./objects/caleb/utils.js"
4 | import * as Window from "./window.js"
5 | import { clonePlatform } from "./objects/level/level.js";
6 | import { clonePhysics } from "./utils.js";
7 |
8 | /**
9 | * @param {GameState} state
10 | * @param {Vector2D} pos
11 | * @param {number} w
12 | * @param {number} h
13 | */
14 | function stroke(state, pos, w, h) {
15 | const aabb = new AABB(pos, w, h);
16 | state.getCtx().strokeRect(...Window.projectAABB(state.getCtx().canvas, aabb))
17 | }
18 |
19 | /**
20 | * @param {GameState} state
21 | */
22 | export function render(state) {
23 | if (!state.opts.debug) {
24 | return
25 | }
26 |
27 | const ctx = state.getCtx()
28 | ctx.strokeStyle = "red";
29 | ctx.lineWidth = 3;
30 |
31 | const dash = state.caleb.dash
32 | const jump = state.caleb.jump
33 | if (dash.dashing) {
34 | stroke(state, dash.dashStart, dash.dashDir * dash.dashDistance, CalebUtils.CALEB_HEIGHT)
35 | } else if (jump.jumping) {
36 | stroke(state, jump.jumpStart, CalebUtils.CALEB_WIDTH, jump.jumpDir * jump.jumpDistance)
37 | }
38 |
39 | ctx.strokeStyle = "green";
40 |
41 | for (const p of state.level.activeLevel.platforms) {
42 | const next = p.behaviors.next
43 | const body = p.physics.current.body
44 | if (next) {
45 | const body = p.physics.current.body
46 | stroke(state, body.pos, body.width, body.height)
47 | }
48 |
49 | renderText(state, "" + p.id, body.pos)
50 | }
51 | }
52 |
53 | /**
54 | * @param {GameState} state
55 | * @param {string} text
56 | * @param {Vector2D} body
57 | */
58 | function renderText(state, text, body) {
59 | const ctx = state.getCtx()
60 | const {x, y} = body
61 | const [_x, _y] = Window.projectCoords(ctx.canvas, x + 0.25, y + 0.5)
62 |
63 | ctx.fillStyle = "white";
64 | ctx.textAlign = "left"
65 | ctx.textBaseline = "middle"
66 | ctx.fillText(text, _x, _y)
67 | }
68 |
69 | /**
70 | * @param {GameState} state
71 | * @param {number} _
72 | */
73 | export function check(state, _) { }
74 |
75 |
76 | /**
77 | * @param {GameState} state
78 | * @param {number} _
79 | */
80 | export function update(state, _) { }
81 |
82 | /**
83 | * @param {GameState} state
84 | */
85 | export function tickClear(state) { }
86 |
87 | /**
88 | * @param {GameState} state
89 | * @param {number} _
90 | */
91 | export function apply(state, _) {
92 | if (state.opts.debug) {
93 | const prev = state.debug.previous.platforms
94 | state.debug.previous.platforms.length = 0
95 | const platforms = state.level.activeLevel.platforms
96 | for (const p of platforms) {
97 | prev.push(clonePlatform(p))
98 | }
99 |
100 | state.debug.previous.caleb = clonePhysics(state.caleb)
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/debug.js:
--------------------------------------------------------------------------------
1 | /** @typedef {() => void} DebuggerCounter */
2 |
3 | /** @type DebuggerCounter[] */
4 | const debuggers = [];
5 |
6 | /**
7 | * @param _ {GameState}
8 | * @param _d {number}
9 | */
10 | export function update(_, _d) { }
11 |
12 | /**
13 | * @param _ {GameState}
14 | */
15 | export function tickClear(_) {
16 | for (const d of debuggers) {
17 | d();
18 | }
19 | }
20 |
21 | /**
22 | * @param count {number}
23 | * @return (...args: any) => void
24 | * */
25 | export function debugForCallCount(count) {
26 | let internalCount = 0;
27 |
28 | /**
29 | * @param args {any[]}
30 | * */
31 | return function(...args) {
32 | if (internalCount < count) {
33 | console.log(...args);
34 | internalCount++;
35 | }
36 | }
37 | }
38 |
39 | /**
40 | * @param count {number}
41 | * @return (...args: any) => void
42 | * */
43 | export function debugForTickCount(count) {
44 | let internalCount = 0;
45 |
46 | function counter() {
47 | internalCount++;
48 | }
49 |
50 | debuggers.push(counter);
51 |
52 | /**
53 | * @param args {any[]}
54 | * */
55 | return function(...args) {
56 | if (internalCount < count) {
57 | console.log(...args);
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/editor.d.ts:
--------------------------------------------------------------------------------
1 | import { AABB } from "./math/aabb.js";
2 | import { Vector2D } from "./math/vector.js";
3 |
4 | export {};
5 |
6 | declare global {
7 | type EditorSaveRequest = {
8 | editorState: EditorState,
9 | path: string
10 | }
11 |
12 | type ElementState = {
13 | id: number
14 | pos: Vector2D
15 | el: HTMLDivElement
16 | selected: boolean
17 | }
18 |
19 | type EditorLevelSet = {
20 | title: string,
21 | difficulty: number,
22 | levels: EditorLevel[]
23 | initialLevel: number
24 | current: number
25 | }
26 |
27 | type EditorRects = {
28 | editorRect: DOMRect
29 | elementRect: DOMRect
30 | }
31 |
32 | type EditorLevel = {
33 | platforms: EditorPlatform[]
34 | initialPosition: Vector2D
35 | letterMap: (string | null)[][]
36 | }
37 |
38 | type EditorState = {
39 | // TODO opts?
40 | debug: boolean,
41 | outerRect: {
42 | margin: number,
43 | maxX: number,
44 | maxY: number,
45 | },
46 | tick: number
47 | change: number
48 |
49 | // TODO elements
50 | canvas: HTMLCanvasElement
51 | editor: HTMLElement
52 | overlay: HTMLElement
53 | platformControls: HTMLElement
54 | levelSetControls: HTMLElement
55 | levelSelectControls: HTMLElement
56 | worldOutline: HTMLElement
57 |
58 | levelSet: EditorLevelSet
59 |
60 | // TODO state
61 | activePlatform: null | EditorPlatform
62 | mouse: {
63 | startTime: number
64 | startingEl: ElementState | null
65 | state: "invalid" | "down"
66 | },
67 | elements: ElementState[][]
68 | selectedElements: ElementState[]
69 |
70 | }
71 |
72 | type EventCB = (event: Event) => (boolean | undefined)
73 | type StateCB = (s: EditorState, evt: Event) => void
74 | type PlatformCB = (s: EditorState, p: EditorPlatform, evt: Event) => void
75 | type Filter = {
76 | name: string,
77 | fn: EventCB | (Filter | EventCB)[],
78 | invert: boolean
79 | or: boolean
80 | and: boolean
81 | }
82 | type Action = (s: EditorState, evt: Event, es?: ElementState) => void
83 |
84 | type EditorPlatform = {
85 | id: number,
86 |
87 | AABB: AABB,
88 | selected: {
89 | offset: Vector2D,
90 | starting: Vector2D,
91 | moving: boolean,
92 | tick: number,
93 | } | null
94 | behaviors: Behavior
95 | el: HTMLElement | null
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/editor.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
Jabascript Loading
161 |
162 |
165 |
166 |
167 |
206 |
207 |
208 |
209 | None
210 |
211 |
212 |
213 |
214 |
215 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
331 |
377 |
378 |
379 |
380 |
381 |
--------------------------------------------------------------------------------
/src/editor/app.js:
--------------------------------------------------------------------------------
1 | import { Vector2D } from "../math/vector.js";
2 | import { GAME_WIDTH, GAME_HEIGHT } from "../window.js";
3 | import * as Editor from "./editor.js";
4 | import * as EditorState from "./state.js";
5 | import { PlatformControls } from "./html/platform.js";
6 | import { assert, never } from "../assert.js";
7 | import * as Bus from "../bus.js"
8 | import { LevelSelectControls, LevelSetControls } from "./html/level-set.js";
9 |
10 | async function run() {
11 | const data = EditorState.createEmptyEditorState();
12 |
13 | /** @type {HTMLElement} */
14 | const editor = document.querySelector("#editor")
15 | /** @type {HTMLElement} */
16 | const overlay = document.querySelector("#overlay")
17 | /** @type {HTMLCanvasElement} */
18 | const canvas = document.querySelector("canvas")
19 |
20 | assert(!!editor, "expected editor to exist")
21 | assert(!!canvas, "expected canvas to exist")
22 | assert(!!overlay, "expected overlay to exist")
23 |
24 | /** @type {HTMLElement} */
25 | const loading = document.querySelector("#loading")
26 | if (loading) {
27 | overlay.removeChild(loading)
28 | }
29 |
30 | const urlParams = new URLSearchParams(window.location.search);
31 | const debug = urlParams.get("debug") === "1";
32 | const state = EditorState.createEditorState(editor, overlay, canvas, debug, data)
33 |
34 | let id = 0
35 | for (let r = 0; r < GAME_HEIGHT + 10; ++r) {
36 | /** @type {ElementState[]} */
37 | const row = []
38 | for (let c = 0; c < GAME_WIDTH + 10; ++c) {
39 | const el = document.createElement("div")
40 | editor.appendChild(el)
41 | el.id = `gi${id++}`;
42 | el.classList.add("grid-item")
43 |
44 | el.dataset.row = String(r)
45 | el.dataset.col = String(c)
46 |
47 | row.push({
48 | selected: false,
49 | pos: new Vector2D(c, r),
50 | el,
51 | id,
52 | });
53 | }
54 | state.elements.push(row);
55 | }
56 |
57 | customElements.define("platform-controls", PlatformControls);
58 | customElements.define("level-set-controls", LevelSetControls);
59 | customElements.define("level-select-controls", LevelSelectControls);
60 |
61 | Editor.start(state)
62 |
63 | }
64 |
65 | run()
66 |
--------------------------------------------------------------------------------
/src/editor/consts.js:
--------------------------------------------------------------------------------
1 |
2 | export const platform = {
3 | sqDistForMoving: 8
4 | }
5 |
6 | export const editor = {
7 | margin: 7
8 | }
9 |
10 | export const behaviors = {
11 | fastClickTimeMS: 250,
12 | toBeMovingPxs: 144,
13 | }
14 |
15 |
--------------------------------------------------------------------------------
/src/editor/editor.js:
--------------------------------------------------------------------------------
1 | import { assert } from "../assert.js";
2 | import * as Runner from "../game-runner.js";
3 | import * as Input from "../input/input.js"
4 | import * as Config from "../game-config.js"
5 | import * as Utils from "./utils.js"
6 | import { createTransform } from "./transforms.js"
7 | import * as Bus from "../bus.js"
8 | import * as Renderer from "./render.js"
9 | import * as Window from "../window.js"
10 | import * as State from "./state.js"
11 | import * as Platform from "./platform.js"
12 | import * as Consts from "./consts.js"
13 |
14 | const windowEvents = [
15 | "mousedown",
16 | "mouseup",
17 | "mouseover",
18 | "mouseout",
19 | "mousemove",
20 | "click",
21 | "blur",
22 | "keydown",
23 | ]
24 |
25 | /** @type {(e: Event | BusEvent) => void} */
26 | let currentTakeAction = null
27 | /** @type {EditorState} */
28 | let currentEditorState = null
29 |
30 | /**
31 | * @param {ResizeEvent} e
32 | */
33 | function actionResize(e) {
34 | assert(!!currentTakeAction, "expected take action to be defined")
35 | currentTakeAction(e)
36 | Bus.emit("resize", /** @type {ResizeEvent} */(e))
37 | }
38 |
39 | function handleEditorChange() {
40 | assert(!!currentEditorState, "expected editor state to be set")
41 | currentEditorState.change++
42 | }
43 |
44 | function addListeners() {
45 | assert(!!currentTakeAction, "expected take action to be defined")
46 | assert(!!currentEditorState, "expected editor state to be set")
47 |
48 | for (const e of windowEvents) {
49 | window.addEventListener(e, currentTakeAction)
50 | }
51 | window.addEventListener("resize", actionResize)
52 |
53 | // @ts-ignore SHIT I HAVE TO FIX THESE TYPES
54 | Bus.listenAll(currentTakeAction)
55 | Bus.render()
56 | }
57 |
58 | function removeListeners() {
59 | assert(!!currentTakeAction, "expected take action to be defined")
60 | assert(!!currentEditorState, "expected editor state to be set")
61 |
62 | for (const e of windowEvents) {
63 | window.removeEventListener(e, currentTakeAction)
64 | }
65 | window.removeEventListener("resize", actionResize)
66 |
67 | // @ts-ignore I NEED TO FIX THESE DAMN TYPES
68 | Bus.removeAll(currentTakeAction)
69 | }
70 |
71 | /**
72 | * @param {EditorState} state
73 | */
74 | export function start(state) {
75 | const takeAction = createActionTaken(state)
76 | currentTakeAction = takeAction
77 | currentEditorState = state
78 | addListeners()
79 |
80 | // @ts-ignore this is reasonable thing to do
81 | window.state = state
82 |
83 | Bus.emit("editor-started", state)
84 | }
85 |
86 | /**
87 | * @param {EditorState} state
88 | * @param {Event} _
89 | * @param {ElementState?} es
90 | */
91 | export function handleEditorDown(state, _, es) {
92 | assert(!!es, "handle editor down must happen on grid element")
93 | State.createSelected(state, es)
94 | }
95 |
96 | /**
97 | * @param {EditorState} state
98 | * @param {Event} _
99 | * @param {ElementState?} es
100 | */
101 | export function handleMouseDown(state, _, es) {
102 | State.Mouse.down(state, es)
103 | }
104 |
105 | /**
106 | * @param {EditorState} state
107 | */
108 | export function handleMouseUp(state, _, es) {
109 | State.Mouse.up(state)
110 | }
111 |
112 | /**
113 | * @param {EditorState} state
114 | * @param {Event} _
115 | * @param {ElementState?} es
116 | */
117 | export function handleEditorOver(state, _, es) {
118 | assert(!!es, "handle editor down must happen on grid element")
119 | State.createSelected(state, es)
120 | }
121 |
122 | /**
123 | * @param {EditorState} state
124 | * @param {Event} _
125 | * @param {ElementState} es
126 | */
127 | export function handleCellClick(state, _, es) {
128 | State.createSelected(state, es, es)
129 | }
130 |
131 | /**
132 | * @param {EditorState} state
133 | * @param {Event} event
134 | */
135 | export function handleSelectPlatform(state, event) {
136 | if (State.hasActivePlatform(state)) {
137 | Bus.emit("hide-platform", State.activePlatform(state))
138 | State.releasePlatform(state);
139 | }
140 |
141 | const evt = /** @type {MouseEvent} */(event)
142 | assert(evt instanceof MouseEvent, "selection of platform without mouse event")
143 |
144 | const found = State.selectPlatform(state, evt)
145 |
146 | Bus.emit("show-platform", found)
147 | }
148 |
149 | /**
150 | * @param {EditorState} state
151 | */
152 | export function handleUpPlatform(state) {
153 | const platform = State.activePlatform(state)
154 | const duration = Platform.selectedDuration(state, platform)
155 | const moving = Platform.isMoving(platform)
156 |
157 | if (moving || duration < Consts.behaviors.fastClickTimeMS) {
158 | Bus.emit("show-platform", platform)
159 | }
160 | }
161 |
162 | /**
163 | * @param {EditorState} state
164 | */
165 | export function handleDeletePlatform(state) {
166 | const platform = State.deletePlatform(state);
167 | Bus.emit("delete-platform", platform)
168 | }
169 |
170 | /**
171 | * @param {EditorState} state
172 | * @param {Event} event
173 | */
174 | export function handleMovePlatform(state, event) {
175 | const evt = /** @type {MouseEvent} */(event)
176 | assert(evt instanceof MouseEvent, "selection of platform without mouse event")
177 |
178 | const platform = State.activePlatform(state)
179 | const eventPos = Utils.toVec(evt)
180 | const offset = Platform.offset(platform);
181 | const start = Platform.start(platform);
182 |
183 | const projected = Utils.project(state, eventPos.subtract(offset), Math.round)
184 | const moved = projected.add(start)
185 | const startedMoving = Platform.moveTo(platform, Utils.bound(state, moved));
186 |
187 | if (startedMoving) {
188 | Bus.emit("move-platform", platform)
189 | }
190 | }
191 |
192 | /**
193 | * @param {EditorState} state
194 | */
195 | export function handlePlayListeners(state) {
196 | window.addEventListener("resize", function() {
197 | Window.resize(state.canvas)
198 | });
199 | Window.resize(state.canvas)
200 | }
201 |
202 | /**
203 | * @param {EditorState} state
204 | */
205 | export function handlePlay(state) {
206 | state.canvas.classList.add("show")
207 | removeListeners();
208 | handlePlayListeners(state)
209 |
210 | const ticks = [Runner.tickWithRender]
211 | const levelSet = State.gameLevelSet(state)
212 | const config = Config.getGameConfig(false)
213 | const gstate = Config.createCanvasGame(state.canvas, config, levelSet)
214 | const loop = Runner.createGameLoop(gstate)
215 | Runner.clear(gstate)
216 | Runner.addStandardBehaviors(gstate)
217 |
218 | Input.addListenersTo(gstate, state.canvas)
219 | Runner.run(
220 | gstate,
221 | loop,
222 | ticks,
223 | (e) => {
224 | console.log("game finished", e)
225 | state.canvas.classList.remove("show")
226 | Input.removeListenersFrom(gstate, state.canvas)
227 | addListeners();
228 | });
229 | }
230 |
231 | /**
232 | * @param {EditorState} state
233 | */
234 | export function handleShowPlatform(state) {
235 | Bus.emit("show-platform", State.activePlatform(state));
236 | }
237 |
238 | /**
239 | * @param {EditorState} state
240 | * @param {any} e - yes i did an any... eat your hearts out typescript andies
241 | */
242 | export function handleChangeLevel(state, e) {
243 | const evt = /** @type {EditorChangeLevel} */(e)
244 | State.clearActiveState(state);
245 | State.clearPlatformElements(state);
246 | State.selectLevelByIdx(this.state, evt.next)
247 | }
248 |
249 | /**
250 | * @param {EditorState} state
251 | */
252 | export function handleReleasePlatform(state) {
253 | Bus.emit("release-platform", State.activePlatform(state))
254 | handleClearAllState(state)
255 | }
256 |
257 | /**
258 | * @param {EditorState} state
259 | */
260 | export function handleClearAllState(state) {
261 | if (State.hasActivePlatform(state)) {
262 | const plat = State.activePlatform(state)
263 | Bus.emit("hide-platform", plat)
264 | }
265 |
266 | State.clearActiveState(state)
267 | State.change(state)
268 | }
269 |
270 | /** @param {EditorState} state
271 | * @param {boolean} render - i need ot remove this and have take action emit renders
272 | *
273 | * @returns {(e: Event | BusEvent) => void}
274 | */
275 | export function createActionTaken(state, render = true) {
276 | const T = createTransform(state);
277 |
278 | const createPlatform = T(State.createPlatform).type("keydown").key("a");
279 | const selectPlatform = T(handleSelectPlatform).type("mousedown").not.controls().inPlatform()
280 |
281 | const releasePlatformByMouse = T(handleReleasePlatform).
282 | type("mouseup").activePlatform().
283 | not.platformSelectedThisTick(5).inActivePlatform().fastClick()
284 |
285 | const releasePlatformByOutsideClick = T(handleReleasePlatform).
286 | type("mouseup").activePlatform().
287 | not.inActivePlatform().
288 | fastClick().not.controls()
289 |
290 | const releasePlatformByKey = T(handleReleasePlatform).
291 | type("keydown").key(["o", "Escape"]).activePlatform()
292 |
293 | const showPlatformControls = T(handleShowPlatform).
294 | type("mouseup").platformMoving()
295 |
296 | const movePlatform = T(handleMovePlatform).
297 | type("mousemove").activePlatform().not.controls().stateMouseDown()
298 |
299 | const delPlatform = T(handleDeletePlatform).type("keydown").key("Backspace").activePlatform().not.controls()
300 |
301 | const eMove = T(handleEditorOver).
302 | type("mousemove").not.activePlatform().
303 | stateMouseDown().not.inPlatform().fromEditor()
304 |
305 | const eMoveSelectInPlatform = T(handleEditorOver).
306 | type("mousemove").not.activePlatform().
307 | stateMouseDown().inPlatform().selected().fromEditor()
308 |
309 | const eCell = T(handleCellClick).
310 | type("mouseup").not.inPlatform().fastClick().isGridItem()
311 |
312 | const play = T(handlePlay).type("keydown").key("p").not.stateHasSelected().not.activePlatform()
313 | const mousedown = T(handleMouseDown).type("mousedown")
314 | const mouseup = T(handleMouseUp).type("mouseup")
315 | const levelChanged = T(handleChangeLevel).type("editor-change-level")
316 | const change = T(handleEditorChange).type("editor-change")
317 | const clear = T(handleClearAllState).type("keydown").key("Escape")
318 |
319 | const prehandlers = [
320 | mousedown,
321 | levelChanged,
322 | change,
323 | ]
324 |
325 | const posthandlers = [
326 | mouseup,
327 | clear,
328 | ]
329 |
330 | const handlers = [
331 | play,
332 | eCell,
333 | eMove,
334 | eMoveSelectInPlatform,
335 |
336 | createPlatform,
337 | selectPlatform,
338 | releasePlatformByMouse,
339 | releasePlatformByOutsideClick,
340 | releasePlatformByKey,
341 | movePlatform,
342 | showPlatformControls,
343 | delPlatform,
344 | ]
345 |
346 | const newState = T((_, event) => {
347 | // TODO probably will consider a better T type but i don't want to go through all that typing until i am needing to extend this editor far enough that it makes sense
348 | // @ts-ignore
349 | const evt = /** @type {EditorStateLoadedEvent} */(event)
350 |
351 | State.forEachAllPlatform(state, p => {
352 | if (p.el !== null) {
353 | p.el.remove();
354 | p.el = null
355 | }
356 | });
357 |
358 | // @ts-ignore PREEMPTIVE IGNORE... THIS IS BAD BUT STFU
359 | // the reason is simple. I am about to overwrite the data from the server but i want the state of the elements on the board still within the editor state.
360 | state.levelSet = null
361 |
362 | // TODO yikes
363 | // also, don't cause a state change that way fetches don't result in an immediate save
364 | state = {
365 | ...state,
366 | ...evt.state,
367 |
368 | // use active elements
369 | elements: state.elements,
370 | }
371 |
372 | let highestId = 0
373 | State.forEachAllPlatform(state, p => {
374 | if (p.id > highestId) {
375 | highestId = p.id
376 | }
377 | });
378 |
379 | Platform.setPlatformNextId(highestId + 1)
380 | prehandlers.forEach(x => x.updateState(state));
381 | posthandlers.forEach(x => x.updateState(state));
382 | handlers.forEach(x => x.updateState(state));
383 |
384 | Bus.emit("editor-size-change", {
385 | type: "editor-size-change",
386 | ...State.getUIRects(state),
387 | });
388 |
389 | // @ts-ignore
390 | window.state = state
391 |
392 | }).type("editor-state-loaded")
393 |
394 | // TODO: The Event | BusEvent thing is real but again i don't want to
395 | // refactor the types until i really want the need to... :(
396 | const ran = []
397 | return function(event) {
398 | const startChange = state.change
399 | state.tick++
400 |
401 | newState.run(event);
402 |
403 | for (const h of prehandlers) {
404 | h.run(event)
405 | }
406 |
407 | ran.length = 0
408 | for (const h of handlers) {
409 | if (h.run(event)) {
410 | ran.push(h)
411 | }
412 | }
413 |
414 | for (const h of posthandlers) {
415 | h.run(event)
416 | }
417 |
418 | if (ran.length >= 2) {
419 | console.log("ambiguous event", ran.map(x => x.toString()))
420 | }
421 |
422 | if (render) {
423 | Renderer.render(state)
424 | }
425 |
426 | if (startChange < state.change) {
427 | Bus.emit("editor-updated", {
428 | type: "editor-updated",
429 | state: state,
430 | })
431 | }
432 | }
433 | }
434 |
435 |
436 |
437 |
--------------------------------------------------------------------------------
/src/editor/editor.test.js:
--------------------------------------------------------------------------------
1 | import test from "node:test";
2 | import * as assert from "node:assert";
3 | import { createActionTaken } from "./editor.js";
4 | import { setNow } from "../utils.js";
5 | import { Vector2D } from "../math/vector.js";
6 |
7 | const WIDTH = 25
8 | const HEIGHT = 50
9 |
10 | /**
11 | * @returns {EditorLevelSet}
12 | */
13 | function testLevel() {
14 | return {
15 | title: "foo",
16 | levels: [{
17 | letterMap: [],
18 | platforms: [],
19 | initialPosition: new Vector2D(0, 0)
20 | }],
21 | current: 0,
22 | difficulty: 0,
23 | initialLevel: 0,
24 | };
25 | }
26 |
27 | /**
28 | * @param {number} rows
29 | * @param {number} cols
30 | * @returns {ElementState[][]}
31 | */
32 | function createElements(rows, cols) {
33 | const out = []
34 | for (let r = 0; r < rows; ++r) {
35 | const row = []
36 | for (let c = 0; c < rows; ++c) {
37 | // @ts-ignore
38 | row.push(/** @type {ElementState} */({
39 | el: {
40 | gen: r * cols + c,
41 | clientTop: r * HEIGHT,
42 | clientLeft: c * WIDTH,
43 | clientWidth: WIDTH,
44 | clientHeight: HEIGHT,
45 | offsetTop: r * HEIGHT,
46 | offsetLeft: c * WIDTH,
47 | offsetWidth: WIDTH,
48 | offsetHeight: HEIGHT,
49 | },
50 | id: r * cols + c
51 | }));
52 | }
53 | out.push(row);
54 | }
55 | return out
56 | }
57 |
58 | /** @returns {EditorState} */
59 | function createTestState() {
60 | // @ts-ignore
61 | return /** @type {EditorState} */({
62 | elements: createElements(24, 40),
63 | selectedElements: [],
64 | levelState: {
65 | levels: [testLevel()],
66 | current: 0,
67 | }
68 | })
69 | }
70 |
71 | /** @param {EditorState} state
72 | /** @returns {EditorState} */
73 | function withMouse(state) {
74 | state.mouse = {
75 | state: "invalid",
76 | startTime: 0,
77 | startingEl: null
78 | };
79 | return state
80 | }
81 |
82 | /**
83 | * @param {number} x
84 | * @param {number} y
85 | * @returns {MouseEvent}
86 | */
87 | function down(x, y) {
88 | return /** @type {MouseEvent} */({
89 | type: "mousedown",
90 | clientX: x,
91 | clientY: y,
92 | });
93 | }
94 |
95 | /**
96 | * @param {number} x
97 | * @param {number} y
98 | * @returns {MouseEvent}
99 | */
100 | function up(x, y) {
101 | return /** @type {MouseEvent} */({
102 | type: "mouseup",
103 | clientX: x,
104 | clientY: y,
105 | });
106 | }
107 |
108 | let now = 0
109 | function setTime(n) {
110 | now = n
111 | }
112 | setNow(() => now)
113 |
114 | test("should ensure mouse down", () => {
115 | const state = withMouse(createTestState());
116 | const actions = createActionTaken(state, false)
117 |
118 | const target = state.elements[Math.floor(420 / HEIGHT)][Math.floor(69 / WIDTH)]
119 | setTime(42069);
120 | actions(down(69, 420));
121 | assert.deepEqual(state.mouse, {
122 | state: "down",
123 | startTime: now,
124 | startingEl: target
125 | })
126 |
127 | actions(up(75, 450));
128 | assert.deepEqual(state.mouse, {
129 | state: "invalid",
130 | startTime: now,
131 | startingEl: target
132 | })
133 | });
134 |
135 |
136 |
--------------------------------------------------------------------------------
/src/editor/html/level-set.js:
--------------------------------------------------------------------------------
1 | import { assert, never } from "../../assert.js";
2 | import * as Bus from "../../bus.js"
3 | import { AABB } from "../../math/aabb.js";
4 | import { Vector2D } from "../../math/vector.js";
5 | import * as State from "../state.js"
6 | import * as Utils from "./utils.js"
7 |
8 | const dropKeys = [
9 | "activePlatform",
10 | "elements",
11 | "el",
12 | "state",
13 |
14 | "canvas",
15 | "editor",
16 | "overlay",
17 | "platformControls",
18 | "levelSetControls",
19 | "levelSelectControls",
20 | "worldOutline",
21 | ]
22 |
23 | /** @param {EditorState} state
24 | * @param {string} path
25 | */
26 | async function save(state, path) {
27 | // todo insane??
28 | /** @type {EditorSaveRequest} */
29 | const saveState = JSON.parse(JSON.stringify({
30 | path,
31 | editorState: state,
32 | }, (key, value) => {
33 | if (dropKeys.includes(key)) {
34 | return undefined
35 | }
36 | return value
37 | }));
38 |
39 | // TODO super cursed
40 | saveState.editorState.selectedElements = []
41 | State.clearActiveState(saveState.editorState)
42 | State.cleanPlatformSelectedState(saveState.editorState)
43 | saveState.editorState.selectedElements = undefined
44 |
45 | const res = await fetch("/save", {
46 | method: "POST",
47 | headers: {
48 | 'Content-Type': 'application/json'
49 | },
50 | body: JSON.stringify(saveState),
51 | })
52 | assert(res.ok, "unable to save the editor state")
53 | }
54 |
55 | /**
56 | * @param {EditorState | null} state
57 | * @returns {EditorState}
58 | */
59 | export function readyLevelState(state) {
60 | if (!state) {
61 | return State.createEmptyEditorState()
62 | }
63 |
64 | const levelState = State.levelSet(state)
65 | for (const level of levelState.levels) {
66 | for (const p of level.platforms) {
67 | p.el = null
68 | const a = p.AABB
69 | p.AABB = new AABB(new Vector2D(a.pos.x, a.pos.y), a.width, a.height)
70 |
71 | const portal = p.behaviors.portal
72 | if (portal) {
73 | portal.normal = Vector2D.fromObject(portal.normal)
74 | }
75 | }
76 |
77 | level.initialPosition = new Vector2D(level.initialPosition.x, level.initialPosition.y)
78 | }
79 |
80 |
81 | return state
82 | }
83 |
84 | /**
85 | * @param {string} path
86 | * @returns {Promise}
87 | */
88 | async function getState(path) {
89 | const url = `/get?path=${path}`;
90 | try {
91 | const response = await fetch(url);
92 | if (!response.ok) {
93 | throw new Error(`Response status: ${response.status}`);
94 | }
95 |
96 | const event = /** @type {EditorSaveRequest} */ (await response.json())
97 | readyLevelState(event.editorState)
98 | return event.editorState
99 | } catch (error) {
100 | console.error("unable to get data: " + error + "\n" + error.stack)
101 | }
102 |
103 | return readyLevelState(null)
104 | }
105 |
106 | export class LevelSetControls extends HTMLElement {
107 | /** @type {HTMLElement} */
108 | controls
109 |
110 | /** @type {string} */
111 | path
112 |
113 | /** @type {EditorState} state */
114 | state
115 |
116 | constructor() {
117 | super();
118 | let template = /** @type {HTMLTemplateElement} */(document.getElementById("level-set-controls"))
119 | assert(!!template, "unable to retrieve template")
120 | let templateContent = template.content;
121 |
122 | const shadowRoot = this.attachShadow({ mode: "open" });
123 | shadowRoot.appendChild(templateContent.cloneNode(true));
124 |
125 | this.controls = shadowRoot.querySelector(".level-set-controls");
126 | this.path = this.getAttribute('initial-path')
127 | this.#fetchState()
128 | }
129 |
130 | #save = () => {
131 | const controls = this.#controls()
132 | this.state.levelSet.difficulty = +controls.difficulty.value
133 | this.state.levelSet.title = controls.title.value
134 | this.state.levelSet.initialLevel = +controls.initial.value
135 |
136 | const initLevel = State.initialLevel(this.state)
137 | const x = Utils.parseCoord(controls.startX.value)
138 | const y = Utils.parseCoord(controls.startY.value)
139 | initLevel.initialPosition = new Vector2D(x, y)
140 | save(this.state, this.path)
141 | }
142 |
143 | #delete = () => {
144 | never("todo")
145 | }
146 |
147 | #load = () => {
148 | this.path = this.#controls().path.value
149 | this.#fetchState()
150 | }
151 |
152 | #fetchState() {
153 | getState(this.path).
154 | then(state => {
155 | this.state = state;
156 | Bus.emit("editor-state-loaded", {type: "editor-state-loaded", state})
157 | this.#hydrateFromState(state);
158 | });
159 | }
160 |
161 | /** @param {UpdatedEvent} updated */
162 | #editorUpdated = (updated) => {
163 | this.state = updated.state;
164 | this.#hydrateFromState(this.state)
165 | this.#save()
166 | }
167 |
168 | connectedCallback() {
169 | const controls = this.#controls()
170 | controls.save.addEventListener("click", this.#save);
171 | controls.load.addEventListener("click", this.#load);
172 | controls.delete.addEventListener("click", this.#delete);
173 | Bus.listen("editor-started", this.#hydrateFromState)
174 | Bus.listen("editor-updated", this.#editorUpdated)
175 | }
176 |
177 | disconnectedCallback() {
178 | const controls = this.#controls()
179 | controls.save.removeEventListener("click", this.#save);
180 | controls.load.removeEventListener("click", this.#load);
181 | controls.delete.removeEventListener("click", this.#delete);
182 | Bus.remove("editor-started", this.#hydrateFromState)
183 | Bus.remove("editor-updated", this.#editorUpdated)
184 | }
185 |
186 | /** @param {EditorState} state */
187 | #hydrateFromState = (state) => {
188 | const controls = this.#controls()
189 | controls.path.value = this.path
190 |
191 | const levelSet = State.levelSet(state)
192 | controls.title.value = levelSet.title
193 | controls.difficulty.value = "" + levelSet.difficulty
194 |
195 | const initial = State.initialLevel(state)
196 | controls.startX.value = String(initial.initialPosition.x)
197 | controls.startY.value = String(initial.initialPosition.y)
198 | controls.initial.value = String(levelSet.initialLevel)
199 | }
200 |
201 | /**
202 | * @returns {{
203 | * path: HTMLInputElement,
204 | * title: HTMLInputElement,
205 | * difficulty: HTMLInputElement,
206 | * startX: HTMLInputElement,
207 | * startY: HTMLInputElement,
208 | * initial: HTMLInputElement,
209 | * save: HTMLButtonElement,
210 | * load: HTMLButtonElement,
211 | * delete: HTMLButtonElement,
212 | * }}
213 | */
214 | #controls() {
215 | return {
216 | path: this.controls.querySelector("#level-path"),
217 | title: this.controls.querySelector("#level-set-title"),
218 | difficulty: this.controls.querySelector("#level-set-diff"),
219 | startX: this.controls.querySelector("#level-set-x"),
220 | startY: this.controls.querySelector("#level-set-y"),
221 | initial: this.controls.querySelector("#level-set-initial"),
222 | save: this.controls.querySelector("#save-level-set"),
223 | load: this.controls.querySelector("#load-level-set"),
224 | delete: this.controls.querySelector("#delete-level-set"),
225 | }
226 | }
227 | }
228 |
229 | export class LevelSelectControls extends HTMLElement {
230 | /** @type {HTMLElement} */
231 | controls
232 |
233 | /** @type {HTMLElement[]} */
234 | levels
235 |
236 | /** @type {EditorState} */
237 | state
238 |
239 | constructor() {
240 | super();
241 | let template = /** @type {HTMLTemplateElement} */(document.getElementById("level-select-controls"))
242 | assert(!!template, "unable to retrieve template")
243 | let templateContent = template.content;
244 |
245 | const shadowRoot = this.attachShadow({ mode: "open" });
246 | shadowRoot.appendChild(templateContent.cloneNode(true));
247 |
248 | this.controls = shadowRoot.querySelector(".level-select-controls");
249 | this.levels = []
250 | }
251 |
252 | connectedCallback() {
253 | const createLevel = this.#getCreateLevel()
254 | Bus.listen("editor-started", this.#hydrateFromState)
255 | Bus.listen("editor-state-loaded", this.#pluckState)
256 | createLevel.addEventListener("click", this.#createLevel);
257 | }
258 |
259 | disconnectedCallback() {
260 | const createLevel = this.#getCreateLevel()
261 | Bus.remove("editor-started", this.#hydrateFromState)
262 | Bus.remove("editor-state-loaded", this.#pluckState)
263 | createLevel.removeEventListener("click", this.#createLevel);
264 | }
265 |
266 | /** @param {EditorStateLoadedEvent} evt */
267 | #pluckState = (evt) => {
268 | this.#hydrateFromState(evt.state)
269 | }
270 |
271 | /**
272 | * @param {Event} evt
273 | */
274 | #createLevel = (evt) => {
275 | evt.stopImmediatePropagation()
276 | evt.preventDefault()
277 |
278 | const el = this.#createLevelElement(this.levels.length)
279 | this.levels.push(el)
280 | State.addNewLevel(this.state)
281 | this.#selectLevel(el)
282 | this.#levels().append(el)
283 | Bus.render()
284 | }
285 |
286 | /** @param {EditorState} state */
287 | #hydrateFromState = (state) => {
288 | this.state = state
289 |
290 | for (const level of this.levels) {
291 | level.removeEventListener("click", this.#handleSelectLevel)
292 | level.remove()
293 | }
294 |
295 | const levelSet = State.levelSet(state)
296 | const levels = []
297 | const levelEl = this.#levels()
298 |
299 | for (let i = 0; i < levelSet.levels.length; ++i) {
300 | const el = this.#createLevelElement(i)
301 | if (i === 0) {
302 | el.classList.add("selected")
303 | }
304 | levels.push(el)
305 | levelEl.append(el)
306 | }
307 |
308 | this.levels = levels
309 | }
310 |
311 | /** @param {Event} evt */
312 | #handleSelectLevel = (evt) => {
313 | const event = /** @type {MouseEvent} */(evt)
314 | this.#selectLevel(/** @type {HTMLElement} */(event.target))
315 | }
316 |
317 | /** @param {HTMLElement} el */
318 | #selectLevel = (el) => {
319 | for (const l of this.levels) {
320 | l.classList.remove("selected")
321 | }
322 |
323 | const next = +el.dataset.index
324 | el.classList.add("selected")
325 | Bus.emit("editor-change-level", { type: "editor-change-level", next})
326 | }
327 |
328 | #levels() {
329 | return this.controls.querySelector(".levels")
330 | }
331 |
332 | #getCreateLevel() {
333 | return this.controls.querySelector("#new-level")
334 | }
335 |
336 | /** @param {number} idx
337 | * @returns {HTMLElement}
338 | * */
339 | #createLevelElement(idx) {
340 | const el = document.createElement("div")
341 | el.dataset.index = String(idx)
342 | el.innerText = `Level ${idx}`
343 | el.addEventListener("click", this.#handleSelectLevel)
344 | el.classList.add("level")
345 | return el
346 | }
347 |
348 | }
349 |
350 |
--------------------------------------------------------------------------------
/src/editor/html/utils.js:
--------------------------------------------------------------------------------
1 | import { GAME_HEIGHT, GAME_WIDTH } from "../../window.js";
2 |
3 | /** @param {string} coord
4 | * @returns {number}
5 | * */
6 | export function parseCoord(coord) {
7 | const parts = coord.split(" ")
8 | const processed = []
9 | for (let i = 0; i < parts.length; ++i) {
10 | if (parts[i].toLowerCase() === "w") {
11 | processed[i] = GAME_WIDTH
12 | } else if (parts[i].toLowerCase() === "h") {
13 | processed[i] = GAME_HEIGHT
14 | } else {
15 | processed[i] = parts[i]
16 | }
17 | }
18 |
19 | if (processed.length === 1) {
20 | return +processed[0]
21 | } else if (processed[1] === "-") {
22 | return +processed[0] - +processed[2]
23 | }
24 | return +processed[0] + +processed[2]
25 | }
26 |
27 |
28 |
--------------------------------------------------------------------------------
/src/editor/mouse.js:
--------------------------------------------------------------------------------
1 | import { now } from "../utils.js"
2 |
3 | /**
4 | * @param {EditorState} state
5 | * @param {ElementState} es
6 | */
7 | export function down(state, es) {
8 | state.mouse.state = "down"
9 | state.mouse.startingEl = es
10 | state.mouse.startTime = now()
11 | }
12 |
13 | /**
14 | * @param {EditorState} state
15 | */
16 | export function clearState(state) {
17 | state.mouse.startingEl = null
18 | state.mouse.state = "invalid"
19 | }
20 |
21 | /**
22 | * maintains startingEl for selection purposes
23 | * @param {EditorState} state
24 | */
25 | export function up(state) {
26 | state.mouse.state = "invalid"
27 | }
28 |
29 | /**
30 | * @param {EditorState} state
31 | * @returns {boolean}
32 | */
33 | export function isDown(state) {
34 | return state.mouse.state === "down"
35 | }
36 |
37 | /**
38 | * @param {EditorState} state
39 | * @returns {number}
40 | */
41 | export function duration(state) {
42 | return isDown(state) && now() - state.mouse.startTime || 0
43 | }
44 |
--------------------------------------------------------------------------------
/src/editor/platform.js:
--------------------------------------------------------------------------------
1 | import { assert } from "../assert.js";
2 | import * as Bus from "../bus.js"
3 | import * as Utils from "./utils.js"
4 | import { from2Vecs } from "../math/aabb.js";
5 | import { Vector2D } from "../math/vector.js";
6 | import * as Consts from "./consts.js"
7 | import * as Level from "../objects/level/level.js"
8 |
9 | /**
10 | * @param {EditorPlatform} platform
11 | */
12 | function assertSelected(platform) {
13 | assert(!!platform.selected, "expected platform to be selected")
14 | }
15 |
16 | let _id = 0
17 |
18 | /** @param {number} id */
19 | export function setPlatformNextId(id) {
20 | _id = id
21 | }
22 |
23 | /**
24 | * @param {EditorState} state
25 | * @param {ElementState} start
26 | * @param {ElementState} end
27 | * @returns {EditorPlatform}
28 | */
29 | export function createPlatform(state, start, end) {
30 | return {
31 | id: ++_id,
32 | selected: null,
33 | AABB: from2Vecs(start.pos, end.pos),
34 | behaviors: {},
35 | el: null,
36 | }
37 | }
38 |
39 | /**
40 | * @param {EditorState} state
41 | * @param {EditorPlatform} platform
42 | * @returns {number}
43 | */
44 | export function selectedDuration(state, platform) {
45 | assertSelected(platform)
46 | return state.tick - platform.selected.tick
47 | }
48 |
49 | /**
50 | * @param {EditorState} state
51 | * @param {EditorPlatform} platform
52 | * @returns {BasedPlatform}
53 | */
54 | export function toPlatform(state, platform) {
55 | const aabb = platform.AABB.clone()
56 | const m = state.outerRect.margin
57 | aabb.pos.subtract(new Vector2D(m, m))
58 | const plat = Level.createPlatform(aabb)
59 |
60 | plat.behaviors.circuit = platform.behaviors.circuit
61 | plat.behaviors.next = platform.behaviors.next
62 | plat.behaviors.instagib = platform.behaviors.instagib
63 | plat.behaviors.obstacle = platform.behaviors.obstacle
64 | plat.behaviors.portal = platform.behaviors.portal
65 |
66 | if (platform.behaviors.render) {
67 | plat.behaviors.render = {
68 | type: "render",
69 | renderX: 0,
70 | renderY: 0,
71 | renderWidth: 0,
72 | renderHeight: 0,
73 | }
74 | }
75 |
76 | return plat
77 | }
78 |
79 |
80 | /**
81 | * @param {EditorPlatform} platform
82 | * @returns {boolean}
83 | */
84 | export function isMoving(platform) {
85 | assertSelected(platform)
86 | return platform.selected.moving
87 | }
88 |
89 | /**
90 | * @param {EditorPlatform} platform
91 | * @param {Vector2D} pos
92 | */
93 | export function moveTo(platform, pos) {
94 | assertSelected(platform)
95 |
96 | platform.AABB.pos = pos.clone()
97 | const wasMoving = isMoving(platform)
98 | if (wasMoving) {
99 | return false;
100 | }
101 |
102 | const dist = platform.selected.starting.clone().subtract(pos).magnituteSquared()
103 | return orInMoving(platform, dist > Consts.platform.sqDistForMoving);
104 | }
105 |
106 | /**
107 | * @param {EditorPlatform} platform
108 | * @param {boolean} moving
109 | * @returns {boolean}
110 | */
111 | export function orInMoving(platform, moving) {
112 | assertSelected(platform)
113 | platform.selected.moving ||= moving
114 | return platform.selected.moving
115 | }
116 |
117 | /**
118 | * @param {EditorPlatform} platform
119 | * @returns {Vector2D}
120 | */
121 | export function offset(platform) {
122 | assertSelected(platform)
123 | return platform.selected.offset
124 | }
125 |
126 | /**
127 | * @param {EditorPlatform} platform
128 | * @returns {Vector2D}
129 | */
130 | export function start(platform) {
131 | assertSelected(platform)
132 | return platform.selected.starting
133 | }
134 |
--------------------------------------------------------------------------------
/src/editor/render.js:
--------------------------------------------------------------------------------
1 | import { assert } from "../assert.js"
2 | import { Vector2D } from "../math/vector.js"
3 | import * as Utils from "./utils.js"
4 | import * as State from "./state.js"
5 |
6 | /**
7 | * TODO perf
8 | * easy win would be to put a tick on each platform every time it changes and only recalc / re-render when i need to
9 | * @param {EditorState} state
10 | */
11 | export function render(state) {
12 | for (const row of State.elements(state)) {
13 | for (const el of row) {
14 | if (el.selected) {
15 | el.el.classList.add("selected")
16 | } else {
17 | el.el.classList.remove("selected")
18 | }
19 | }
20 | }
21 |
22 | const platforms = State.platforms(state);
23 | for (const plat of platforms) {
24 | renderPlatform(state, plat)
25 | }
26 |
27 | // TODO configure?
28 | // ... wait... this is not something i need to do a bunch...
29 | const start = Utils.unproject(State.getUIRects(state), new Vector2D(state.outerRect.margin, state.outerRect.margin))
30 | const dims = Utils.unproject(State.getUIRects(state), new Vector2D(state.outerRect.maxX, state.outerRect.maxY)).subtract(start)
31 | state.worldOutline.style.width = `${Math.ceil(dims.x)}px`
32 | state.worldOutline.style.height = `${Math.ceil(dims.y)}px`
33 | state.worldOutline.style.top = `${Math.ceil(start.y)}px`
34 | state.worldOutline.style.left = `${Math.ceil(start.x)}px`
35 | }
36 |
37 | /**
38 | * @param {EditorState} state
39 | * @param {EditorPlatform} platform
40 | */
41 | export function renderPlatform(state, platform) {
42 | const editor = state.editor
43 | assert(!!editor, "editor has to exist in the app")
44 |
45 | if (platform.el === null) {
46 | platform.el = document.createElement("div")
47 | editor.appendChild(platform.el)
48 | platform.el.classList.add("platform")
49 | }
50 |
51 | const aabb = platform.AABB
52 | const pos = aabb.pos
53 | const start = state.elements[pos.y][pos.x]
54 |
55 | const rect = start.el.getBoundingClientRect()
56 | const w = rect.width
57 | const h = rect.height
58 | const pW = w * aabb.width
59 | const pH = h * aabb.height
60 | const el = platform.el
61 |
62 | el.style.width = `${Math.ceil(pW)}px`
63 | el.style.height = `${Math.ceil(pH)}px`
64 | el.style.top = `${Math.ceil(rect.top)}px`
65 | el.style.left = `${Math.ceil(rect.left)}px`
66 |
67 | if (platform.selected) {
68 | el.classList.add("selected")
69 | } else {
70 | el.classList.remove("selected")
71 | }
72 |
73 | for (const [k, b] of Object.entries(platform.behaviors)) {
74 | el.classList.remove(k)
75 | if (b) {
76 | el.classList.add(k)
77 | if (b.type === "lettered") {
78 | el.innerText = b.letter
79 | const minDim = Math.min(pW, pH)
80 | el.style.fontSize = `${Math.floor(minDim * 0.9)}px`
81 | }
82 | }
83 | }
84 |
85 | }
86 |
--------------------------------------------------------------------------------
/src/editor/search.js:
--------------------------------------------------------------------------------
1 | import * as State from "./state.js"
2 |
3 |
--------------------------------------------------------------------------------
/src/editor/state.js:
--------------------------------------------------------------------------------
1 | import { assert } from "../assert.js";
2 | import { AABB } from "../math/aabb.js";
3 | import { Vector2D } from "../math/vector.js";
4 | import { GAME_HEIGHT, GAME_WIDTH } from "../window.js";
5 | import * as Mouse from "./mouse.js"
6 | import * as Platform from "./platform.js"
7 | import * as Utils from "./utils.js"
8 | import * as Consts from "./consts.js"
9 | import * as Level from "../objects/level/level.js"
10 | import * as LevelUtils from "../objects/level/levels/utils.js"
11 |
12 | export {Mouse}
13 |
14 | /**
15 | * @param {EditorState} state
16 | */
17 | export function change(state) {
18 | state.change++
19 | }
20 |
21 | /**
22 | * @param {EditorState} state
23 | * @returns {LevelSet}
24 | */
25 | export function gameLevelSet(state) {
26 | const ls = levelSet(state);
27 | return LevelUtils.convertLevelSet(ls)
28 | }
29 |
30 | /**
31 | * @param {EditorState} state
32 | * @returns {EditorRects}
33 | */
34 | export function getUIRects(state) {
35 | return {
36 | editorRect: state.editor.getBoundingClientRect(),
37 | elementRect: state.elements[0][0].el.getBoundingClientRect(),
38 | }
39 | }
40 |
41 | /**
42 | * @param {EditorState} state
43 | * @returns {EditorLevelSet}
44 | */
45 | export function levelSet(state) {
46 | assert(!!state.levelSet, "levelSet called on malformed editor state")
47 | return state.levelSet
48 | }
49 |
50 | /**
51 | * @param {EditorState} state
52 | * @param {number} idx
53 | * @returns {EditorLevel}
54 | */
55 | export function levelByIdx(state, idx) {
56 | const ls = levelSet(state)
57 | const level = ls.levels[idx]
58 | assert(!!level, "you selected a level that does not exist", "idx", idx, ls)
59 | return level
60 | }
61 |
62 | /**
63 | * @param {EditorState} state
64 | */
65 | export function addNewLevel(state) {
66 | const ls = levelSet(state)
67 | ls.levels.push(createEmptyLevel())
68 | }
69 |
70 | /**
71 | * @param {EditorState} state
72 | * @param {number} idx
73 | */
74 | export function selectLevelByIdx(state, idx) {
75 | levelByIdx(state, idx)
76 | state.levelSet.current = idx
77 | }
78 |
79 | /**
80 | * @param {EditorState} state
81 | * @returns {number}
82 | */
83 | export function initialLevelIdx(state) {
84 | initialLevel(state)
85 | return state.levelSet.initialLevel
86 | }
87 |
88 | /**
89 | * @param {EditorState} state
90 | * @returns {EditorLevel}
91 | */
92 | export function initialLevel(state) {
93 | const ls = levelSet(state)
94 | const l = ls.levels[ls.initialLevel]
95 | assert(!!l, "somehow you have requested a level that doesn't exist", ls)
96 | return l
97 | }
98 |
99 |
100 | /**
101 | * @param {EditorState} state
102 | * @returns {EditorLevel}
103 | */
104 | export function level(state) {
105 | const ls = levelSet(state)
106 | const l = ls.levels[ls.current]
107 | assert(!!l, "somehow you have requested a level that doesn't exist", ls)
108 | return l
109 | }
110 |
111 | /**
112 | * @param {EditorState} state
113 | * @returns {ElementState[][]}
114 | */
115 | export function elements(state) {
116 | assert(state.elements.length > 0, "calling elements on malformed objects")
117 | return state.elements
118 | }
119 |
120 | /**
121 | * @param {EditorState} state
122 | * @returns {EditorPlatform[]}
123 | */
124 | export function platforms(state) {
125 | return level(state).platforms
126 | }
127 |
128 | /**
129 | * @param {EditorState} state
130 | * @param {(plat: EditorPlatform) => void} cb
131 | */
132 | export function forEachAllPlatform(state, cb) {
133 | for (const level of state.levelSet.levels) {
134 | for (const p of level.platforms) {
135 | cb(p)
136 | }
137 | }
138 | }
139 |
140 | /**
141 | * @param {EditorState} state
142 | */
143 | export function releasePlatform(state) {
144 | const p = activePlatform(state);
145 | p.selected = null;
146 | state.activePlatform = null;
147 | }
148 |
149 | /**
150 | * @param {EditorState} state
151 | * @param {MouseEvent} evt
152 | * @returns {EditorPlatform}
153 | */
154 | export function selectPlatform(state, evt) {
155 | const found = Search.platform(state, evt)
156 | assert(found !== null, "select platform was called and unable to select platform")
157 |
158 | found.selected = {
159 | offset: Utils.toVec(evt),
160 | starting: found.AABB.pos,
161 | moving: false,
162 | tick: found.selected ? found.selected.tick : state.tick
163 | }
164 | state.activePlatform = found
165 |
166 | return found;
167 | }
168 |
169 | /**
170 | * @param {EditorState} state
171 | * @returns {EditorPlatform}
172 | */
173 | export function deletePlatform(state) {
174 | const platform = activePlatform(state);
175 | const plats = platforms(state);
176 | const idx = plats.indexOf(platform)
177 | if (hasActivePlatform(state)) {
178 | clearActiveState(state);
179 | }
180 |
181 | assert(idx > -1, "platform that is being removed doesn't exist");
182 |
183 | const [p] = plats.splice(idx, 1);
184 | p.el.remove()
185 | p.el = null
186 |
187 | change(state);
188 | return p
189 | }
190 |
191 | /**
192 | * @param {EditorState} state
193 | * @returns {EditorPlatform}
194 | */
195 | export function activePlatform(state) {
196 | const plat = state.activePlatform
197 | assert(!!plat, "expected active platform");
198 | return plat
199 | }
200 |
201 | /**
202 | * @param {EditorState} state
203 | */
204 | export function unsetActivePlatform(state) {
205 | const ap = activePlatform(state)
206 | ap.selected = null
207 | state.activePlatform = null
208 | }
209 |
210 | /**
211 | * @param {EditorState} state
212 | * @param {ElementState} end
213 | * @param {ElementState} start
214 | */
215 | export function createSelected(state, end, start = state.mouse.startingEl) {
216 | assert(start !== null, "you must call createBox after we have selected as starting element")
217 | clearSelectElements(state);
218 |
219 | const rStart = Math.min(start.pos.y, end.pos.y)
220 | const rEnd = Math.max(start.pos.y, end.pos.y)
221 | const cStart = Math.min(start.pos.x, end.pos.x)
222 | const cEnd = Math.max(start.pos.x, end.pos.x)
223 |
224 | for (let r = rStart; r <= rEnd; ++r) {
225 | for (let c = cStart; c <= cEnd; ++c) {
226 | const el = state.elements[r][c]
227 | el.selected = true
228 | state.selectedElements.push(el)
229 | }
230 | }
231 | }
232 |
233 | /**
234 | * @param {EditorState} state
235 | * @returns {boolean}
236 | */
237 | export function hasActivePlatform(state) {
238 | const plat = state.activePlatform
239 | return !!plat
240 | }
241 |
242 | export function clearSelectElements(state) {
243 | for (const el of state.selectedElements) {
244 | el.selected = false
245 | }
246 | state.selectedElements.length = 0
247 | }
248 |
249 | /**
250 | * @param {EditorState} state
251 | */
252 | export function hasSelected(state) {
253 | return state.selectedElements.length > 0
254 | }
255 |
256 | /**
257 | * @param {EditorState} state
258 | */
259 | export function clearPlatformElements(state) {
260 | const ps = platforms(state)
261 | for (const p of ps) {
262 | p.el.remove()
263 | p.el = null
264 | }
265 | }
266 |
267 | /**
268 | * @param {EditorState} state
269 | */
270 | export function clearActiveState(state) {
271 | clearSelectElements(state);
272 | Mouse.clearState(state);
273 | if (hasActivePlatform(state)) {
274 | releasePlatform(state);
275 | }
276 | }
277 |
278 | /**
279 | * @param {EditorState} state
280 | */
281 | export function cleanPlatformSelectedState(state) {
282 | const ls = levelSet(state)
283 | for (const level of ls.levels) {
284 | for (const p of level.platforms) {
285 | p.selected = null
286 | }
287 | }
288 | }
289 |
290 |
291 | /**
292 | * @param {EditorState} state
293 | */
294 | export function createPlatform(state) {
295 | if (state.selectedElements.length > 0) {
296 | const start = state.selectedElements[0]
297 | const end = state.selectedElements[state.selectedElements.length - 1]
298 | const p = Platform.createPlatform(state, start, end)
299 | platforms(state).push(p)
300 | change(state);
301 | }
302 | clearActiveState(state)
303 | }
304 |
305 | /**
306 | * @param {EditorState} state
307 | */
308 | export function startRound(state) {
309 | state.change = 0
310 | }
311 |
312 | /**
313 | * @param {EditorState} state
314 | * @returns {boolean}
315 | */
316 | export function endRound(state) {
317 | return state.change > 0
318 | }
319 |
320 | /**
321 | * @returns {EditorLevel}
322 | */
323 | export function createEmptyLevel() {
324 | return {
325 | letterMap: [],
326 | platforms: [],
327 | initialPosition: new Vector2D(1, 1),
328 | };
329 | }
330 |
331 |
332 | /**
333 | * @returns {EditorLevelSet}
334 | */
335 | export function createEmptyLevelSet() {
336 |
337 | return {
338 | levels: [createEmptyLevel()],
339 | title: "empty",
340 | difficulty: 0,
341 | initialLevel: 0,
342 | current: 0,
343 | };
344 | }
345 |
346 | /**
347 | * @returns {EditorState}
348 | */
349 | export function createEmptyEditorState() {
350 | const margin = Consts.editor.margin
351 | return /** @type EditorState */({
352 | levelSet: createEmptyLevelSet(),
353 |
354 | debug: false,
355 | tick: 0,
356 | change: 0,
357 | mouse: {
358 | startTime: 0,
359 | startingEl: null,
360 | state: "invalid",
361 | },
362 |
363 | activePlatform: null,
364 | elements: [],
365 | outerRect: {
366 | margin,
367 | maxX: GAME_WIDTH + margin,
368 | maxY: GAME_HEIGHT + margin,
369 | },
370 | selectedElements: [],
371 | })
372 | }
373 |
374 | /**
375 | * @param {HTMLElement} editor
376 | * @param {HTMLElement} overlay
377 | * @param {HTMLCanvasElement} canvas
378 | * @param {boolean} debug
379 | * @param {EditorState} remoteState
380 | * @returns {EditorState}
381 | * */
382 | export function createEditorState(editor, overlay, canvas, debug, remoteState) {
383 |
384 | const worldOutline = /** @type HTMLElement */(editor.querySelector("#world-outline"));
385 | assert(!!worldOutline, "#world-outline not within editor")
386 |
387 | const platformControls = /** @type HTMLElement */(overlay.querySelector("platform-controls"));
388 | assert(!!platformControls, "platform-controls is not within overlay")
389 |
390 | const levelSetControls = /** @type HTMLElement */(overlay.querySelector("level-set-controls"));
391 | assert(!!levelSetControls, "level-set-controls is not within overlay")
392 |
393 | const levelSelectControls = /** @type HTMLElement */(overlay.querySelector("level-select-controls"));
394 | assert(!!levelSelectControls, "level-select-controls is not within overlay")
395 |
396 | const margin = Consts.editor.margin
397 |
398 | /** @type {EditorState} */
399 | const state = {
400 | ...remoteState,
401 |
402 | change: 0,
403 | outerRect: {
404 | margin,
405 | maxX: GAME_WIDTH + margin,
406 | maxY: GAME_HEIGHT + margin,
407 | },
408 |
409 | canvas,
410 | debug,
411 | editor,
412 | overlay,
413 | worldOutline,
414 | platformControls,
415 | levelSetControls,
416 | levelSelectControls,
417 |
418 | tick: 0,
419 | activePlatform: null,
420 | elements: [],
421 | selectedElements: [],
422 | mouse: {
423 | startTime: 0,
424 | startingEl: null,
425 | state: "invalid",
426 | }
427 | }
428 |
429 | return state
430 | }
431 |
432 |
433 | /**
434 | * @param {HTMLElement} el
435 | * @param {number} x
436 | * @param {number} y
437 | * @returns {boolean}
438 | */
439 | function within(el, x, y) {
440 | return el.offsetTop <= y && el.offsetTop + el.offsetHeight > y &&
441 | el.offsetLeft <= x && el.offsetLeft + el.offsetWidth > x;
442 | }
443 |
444 | export const Search = {
445 | /**
446 | * @param {EditorState} state
447 | * @param {Event} event
448 | * @returns {EditorPlatform | null}
449 | */
450 | platform(state, event) {
451 | if (!event.type.includes("mouse")) {
452 | return null
453 | }
454 | const evt = /** @type {MouseEvent} */(event)
455 |
456 | const x = evt.clientX
457 | const y = evt.clientY
458 | const plats = platforms(state)
459 |
460 | for (const platform of plats) {
461 | if (within(platform.el, x, y)) {
462 | return platform
463 | }
464 | }
465 |
466 | return null
467 | },
468 |
469 | /**
470 | * @param {EditorState} state
471 | * @param {Event} event
472 | * @returns {ElementState | null}
473 | */
474 | gridItem(state, event) {
475 | if (!event.type.includes("mouse")) {
476 | return null
477 | }
478 | const evt = /** @type {MouseEvent} */(event)
479 |
480 | const x = evt.clientX
481 | const y = evt.clientY
482 |
483 | // TODO technically i can binary search over this 2D array, once with Y and once with X
484 | // Since its 2D and square, i can do both the X and the Y at the same time
485 | /** @type {ElementState | null} */
486 | let found = null
487 | outer: for (const row of state.elements) {
488 | const first = row[0]
489 | if (first.el.offsetTop + first.el.offsetHeight < y) {
490 | continue
491 | }
492 | for (const el of row) {
493 | if (el.el.offsetLeft + el.el.offsetWidth < x) {
494 | continue
495 | }
496 | found = el
497 | break outer
498 | }
499 | }
500 |
501 | return found
502 | }
503 |
504 | }
505 |
506 |
--------------------------------------------------------------------------------
/src/editor/transforms.js:
--------------------------------------------------------------------------------
1 | import * as State from "./state.js"
2 | import * as Platform from "./platform.js"
3 | import * as Consts from "./consts.js"
4 | import { assert, never } from "../assert.js"
5 |
6 | /** @param {Filter} fn
7 | * @returns {string}
8 | */
9 | function filterString(fn) {
10 | if (fn.invert) {
11 | return `not(${fn.name})`
12 | }
13 | return fn.name
14 | }
15 |
16 | /**
17 | * @param {Filter} filter
18 | * @param {Event} event
19 | * @returns {boolean}
20 | */
21 | function runFilter(filter, event) {
22 | const fns = filter.fn
23 | let results = true
24 | if (typeof fns === "function") {
25 | results = fns(event);
26 | } else {
27 | for (const fn of fns) {
28 | if (!results) {
29 | break;
30 | }
31 |
32 | let step = typeof fn === "function" ? fn(event) : runFilter(fn, event)
33 |
34 | if (filter.or) {
35 | results ||= step
36 | } else if (filter.and) {
37 | results &&= step
38 | } else {
39 | never("combination function with neither or / and set", filter)
40 | }
41 | }
42 | }
43 |
44 | return filter.invert ? !results : results
45 | }
46 |
47 | function hasParent(el, evt) {
48 | let curr = /** @type HTMLElement */(evt.target)
49 | if (curr == null) {
50 | return false
51 | }
52 | do {
53 | if (el === curr) {
54 | return true
55 | }
56 | } while ((curr = curr.parentElement))
57 | return false
58 | }
59 |
60 | /** @param {string} k
61 | * @returns {{key: string, ctrl: boolean}}
62 | */
63 | function mapInput(k) {
64 | let ctrl = k.startsWith("C-")
65 | let key = ctrl ? k.split("-")[1] : k
66 | return { ctrl, key };
67 | }
68 |
69 | export class Transforms {
70 | /** @type {EditorState} */
71 | state
72 |
73 | /** @type {Filter[]} */
74 | cbs
75 |
76 | /** @type {boolean} */
77 | #not
78 |
79 | /** @type {Action} */
80 | action
81 |
82 | /** @type {string} */
83 | name
84 |
85 | /** @type {boolean} */
86 | #debug
87 |
88 | /** @type {boolean} */
89 | #break
90 |
91 | /** @type {boolean} */
92 | #or
93 |
94 | /** @type {boolean} */
95 | #and
96 |
97 | /** @param {EditorState} state
98 | /** @param {Action} action */
99 | constructor(state, action) {
100 | this.state = state
101 | this.#debug = false
102 | this.cbs = []
103 | this.#not = false
104 | this.#or = false
105 | this.action = action
106 | this.name = action.name;
107 | }
108 |
109 | /**
110 | * @returns {this}
111 | */
112 | get debug() {
113 | this.#debug = true
114 | return this
115 | }
116 |
117 | /**
118 | * @returns {this}
119 | */
120 | get break() {
121 | this.#break = true
122 | return this
123 | }
124 |
125 | /**
126 | * @param {EventCB} f
127 | * @returns {this}
128 | */
129 | chain(f) {
130 | /** @type {Filter} */
131 | let filter = {
132 | name: f.name,
133 | fn: f,
134 | or: false,
135 | and: false,
136 | invert: this.#not,
137 | };
138 |
139 | if (this.#or) {
140 | const last = this.cbs.pop()
141 | filter = {
142 | or: true,
143 | and: false,
144 | invert: false, // cannot not(or(...)) under this logic (i am ok with that)
145 | name: `or(${f.name}, ${last.name})`,
146 | fn: [
147 | f,
148 | last,
149 | ]
150 | }
151 | } else if (this.#and) {
152 | const last = this.cbs.pop()
153 | filter = {
154 | or: false,
155 | and: true,
156 | invert: false, // cannot not(or(...)) under this logic (i am ok with that)
157 | name: `and(${f.name}, ${last.name})`,
158 | fn: [
159 | f,
160 | last,
161 | ]
162 | }
163 | }
164 |
165 | this.cbs.push(filter);
166 | this.#not = false
167 | this.#or = false
168 | this.#and = false
169 | return this;
170 | }
171 |
172 | /**
173 | * @param {string} t
174 | * @returns {this}
175 | */
176 | type(t) {
177 | return this.chain(function type(evt) {
178 | return evt.type === t
179 | })
180 | }
181 |
182 | get or() {
183 | assert(this.cbs.length >= 1, "there must be at least one call on the stack")
184 | assert(this.#and, "you cannot and an and harry")
185 | this.#or = true
186 | return this;
187 | }
188 |
189 | get and() {
190 | assert(this.cbs.length >= 1, "there must be at least one call on the stack")
191 | assert(this.#or, "you cannot and an or lloyd")
192 | this.#and = true
193 | return this;
194 | }
195 |
196 | get not() {
197 | this.#not = true
198 | return this;
199 | }
200 |
201 | /**
202 | * @param {string | string[]} k
203 | * @returns {this}
204 | */
205 | key(k) {
206 | const processedKeys = Array.isArray(k) ?
207 | k.map(mapInput) : [mapInput(k)];
208 |
209 | return this.chain(function key(event) {
210 | const evt = /** @type {KeyboardEvent} */(event)
211 | for (const p of processedKeys) {
212 | if (evt.key === p.key && evt.ctrlKey === p.ctrl) {
213 | return true
214 | }
215 | }
216 | })
217 | }
218 |
219 | /**
220 | * @returns {this}
221 | */
222 | stateMouseDown() {
223 | let that = this
224 | return this.chain(function stateMouseDown() {
225 | return State.Mouse.isDown(that.state)
226 | });
227 | }
228 |
229 | /**
230 | * @returns {this}
231 | */
232 | selected() {
233 | let that = this
234 | return this.chain(function selected() {
235 | return State.hasSelected(that.state)
236 | })
237 | }
238 |
239 | /**
240 | * @returns {this}
241 | */
242 | inPlatform() {
243 | let that = this
244 | return this.chain(function fromPlatform(evt) {
245 | const platform = State.Search.platform(that.state, evt)
246 | return platform !== null
247 | })
248 | }
249 |
250 | /**
251 | * @param {number} within
252 | * @returns {this}
253 | */
254 | platformSelectedThisTick(within = 1) {
255 | let that = this
256 | return this.chain(function platformSelectedThisTick() {
257 | return State.activePlatform(that.state).selected.tick >= that.state.tick - within
258 | })
259 | }
260 |
261 | /**
262 | * @returns {this}
263 | */
264 | platformMoving() {
265 | let that = this
266 | return this.chain(function platformMoving() {
267 | if (!State.hasActivePlatform(that.state)) {
268 | return false
269 | }
270 | return Platform.isMoving(State.activePlatform(that.state))
271 | })
272 | }
273 |
274 | /**
275 | * @returns {this}
276 | */
277 | inActivePlatform() {
278 | let that = this;
279 | return this.chain(function fromPlatform(evt) {
280 | if (!State.hasActivePlatform(that.state)) {
281 | return false
282 | }
283 | const ap = State.activePlatform(that.state)
284 | const platform = State.Search.platform(that.state, evt)
285 | return platform !== null && platform === ap
286 | })
287 | }
288 |
289 |
290 | /**
291 | * @returns {this}
292 | */
293 | fastClick() {
294 | let that = this
295 | return this.chain(function fastClick() {
296 | return State.Mouse.duration(that.state) < Consts.behaviors.toBeMovingPxs
297 | })
298 | }
299 |
300 | /**
301 | * @returns {this}
302 | */
303 | moving() {
304 | let that = this
305 | return this.chain(function moving() {
306 | return State.hasActivePlatform(that.state) &&
307 | State.activePlatform(that.state).selected.moving
308 | })
309 | }
310 |
311 |
312 | /**
313 | * @returns {this}
314 | */
315 | stateHasSelected() {
316 | let that = this
317 | return this.chain(function stateHasSelected() {
318 | return State.hasSelected(that.state)
319 | })
320 | }
321 |
322 | /**
323 | * @returns {this}
324 | */
325 | activePlatform() {
326 | let that = this
327 | return this.chain(function activePlatform() {
328 | return State.hasActivePlatform(that.state)
329 | })
330 | }
331 |
332 | /**
333 | * @returns {this}
334 | */
335 | controls() {
336 | let that = this
337 |
338 | return this.chain(function controls(evt) {
339 | return hasParent(that.state.platformControls, evt)
340 | })
341 | }
342 |
343 | /**
344 | * @returns {this}
345 | */
346 | isGridItem() {
347 | return this.chain(function isGridItem(evt) {
348 | let curr = /** @type HTMLElement */(evt.target)
349 | return curr?.classList?.contains("grid-item")
350 | })
351 | }
352 |
353 | /**
354 | * @returns {this}
355 | */
356 | fromEditor() {
357 | let that = this
358 | return this.chain(function fromEditor(evt) {
359 | return hasParent(that.state.editor, evt)
360 | })
361 | }
362 |
363 | /**
364 | * @param {Event | BusEvent} evt
365 | */
366 | run(evt) {
367 | const ran = [];
368 | let i = 0
369 | for (; i < this.cbs.length; ++i) {
370 | const c = this.cbs[i]
371 | if (this.#debug) {
372 | ran.push(c)
373 | }
374 |
375 | if (this.#break) {
376 | const i = 5; // allows for conditional debugger statements
377 | }
378 |
379 | // @ts-ignore
380 | // TODO probably will consider a better T type but i don't want to go through all that typing until i am needing to extend this editor far enough that it makes sense
381 | if (!runFilter(c, evt)) {
382 | break;
383 | }
384 | }
385 |
386 | if (i < this.cbs.length) {
387 | if (this.#debug) {
388 | console.log(`${this.name}(failed): ${ran.map(filterString).join(".")}`)
389 | }
390 | return false
391 | }
392 |
393 | const es = State.Search.gridItem(this.state, evt)
394 | if (this.#debug) {
395 | console.log(`${this.name}(success): ${ran.map(filterString).join(".")}`, es)
396 | }
397 |
398 | try {
399 | this.action(this.state, evt, es)
400 | } catch (e) {
401 | console.log(`${this.name}(error): ${ran.map(filterString).join(".")}`, es)
402 | throw new Error("transformer failed", {
403 | cause: e
404 | })
405 | }
406 | return true
407 | }
408 |
409 | /** @param {EditorState} state */
410 | updateState(state) {
411 | this.state = state
412 | }
413 |
414 | toString() {
415 | return `${this.name}: ${this.cbs.map(filterString)}`
416 | }
417 | }
418 |
419 | /**
420 | * @param {EditorState} state
421 | * @returns {(a: Action) => Transforms}
422 | */
423 | export function createTransform(state) {
424 | return function(action) {
425 | return new Transforms(state, action);
426 | }
427 | }
428 |
--------------------------------------------------------------------------------
/src/editor/utils.js:
--------------------------------------------------------------------------------
1 | // TODO should i have this contain the current state of the board?
2 | // Or do i pass in the current state?
3 | //
4 | // The reason why is the project and unproject require state object to be passed in which causes the platform to have back reference to the state it belongs to... which is weird
5 | import { Vector2D } from "../math/vector.js";
6 | import { GAME_HEIGHT, GAME_WIDTH } from "../window.js";
7 |
8 | /**
9 | * @param {EditorState} state
10 | * @param {Vector2D} pos
11 | * @returns {Vector2D}
12 | */
13 | export function bound(state, pos) {
14 | pos.x = Math.min(state.outerRect.maxX, Math.max(0, pos.x))
15 | pos.y = Math.min(state.outerRect.maxY, Math.max(0, pos.y))
16 | return pos
17 | }
18 |
19 | /**
20 | * @param {EditorState} state
21 | * @param {Vector2D} pos
22 | * @param {(n: number) => number} zero
23 | * @returns {Vector2D}
24 | */
25 | export function project(state, pos, zero = Math.floor) {
26 | const rect = state.elements[0][0].el.getBoundingClientRect()
27 | const w = rect.width
28 | const h = rect.height
29 | return new Vector2D(zero(pos.x / w), zero(pos.y / h));
30 | }
31 |
32 | /**
33 | * @param {EditorRects} rects
34 | * @param {Vector2D} pos
35 | * @returns {Vector2D}
36 | */
37 | export function unproject({editorRect, elementRect}, pos) {
38 | const w = elementRect.width
39 | const h = elementRect.height
40 |
41 | return new Vector2D(Math.floor(pos.x * w + editorRect.left), Math.floor(pos.y * h + editorRect.top));
42 | }
43 |
44 | /**
45 | * @param {MouseEvent} evt
46 | * @returns Vector2D
47 | */
48 | export function toVec(evt) {
49 | return new Vector2D(evt.clientX, evt.clientY)
50 | }
51 |
52 |
--------------------------------------------------------------------------------
/src/game-config.js:
--------------------------------------------------------------------------------
1 | import * as Utils from "./utils.js";
2 | import * as Input from "./input/input.js";
3 | import * as State from "./state/state.js";
4 | import * as Simulation from "./simulation/state.js";
5 | import * as Window from "./window.js";
6 | import { Vector2D } from "./math/vector.js";
7 | import * as Ease from "./math/ease.js";
8 | import { GAME_HEIGHT, GAME_WIDTH } from "./window.js";
9 | import { assert } from "./assert.js";
10 |
11 | /**
12 | * @param canvas {HTMLCanvasElement}
13 | */
14 | function configureCanvas(canvas) {
15 | canvas.getContext("2d").imageSmoothingEnabled = false;
16 | canvas.tabIndex = 0;
17 | canvas.focus();
18 | canvas.addEventListener('blur', () => {
19 | canvas.focus();
20 | });
21 | // TODO: this will probably need to fixed
22 | window.addEventListener('click', () => {
23 | canvas.focus();
24 | });
25 |
26 | window.addEventListener("resize", function() {
27 | Window.resize(canvas);
28 | });
29 | Window.resize(canvas);
30 | }
31 |
32 | /**
33 | * @param {HTMLCanvasElement} canvas
34 | * @param {GameOptions} gameopts
35 | * @param {LevelSet} levelSet
36 | * @returns {GameState}
37 | */
38 | export function createCanvasGame(canvas, gameopts, levelSet) {
39 | configureCanvas(canvas)
40 |
41 | const ctx = canvas.getContext("2d")
42 | assert(!!ctx, "unable to get context from the canvas")
43 |
44 | const inputState = Input.createInputState();
45 | const state = State.createGameState(
46 | gameopts,
47 | inputState,
48 | () => canvas,
49 | () => ctx,
50 | levelSet);
51 | State.reset(state);
52 |
53 | // @ts-ignore
54 | window.state = state
55 |
56 | return state
57 | }
58 |
59 | /**
60 | * @param {GameState} state
61 | */
62 | export function addBrowserListeners(state) {
63 | window.addEventListener("resize", function() {
64 | State.projectStaticObjects(state);
65 | });
66 | }
67 |
68 | /**
69 | * @param {SimRand} rand
70 | * @param {GameOptions} opts
71 | * @param {LevelSet} levelSet
72 | *
73 | * @returns {{
74 | * state: GameState,
75 | * sim: SimState,
76 | * }}
77 | */
78 | export function createSimGame(rand, opts, levelSet) {
79 | const inputState = Input.createInputState();
80 |
81 | /** @type {Dimension} */
82 | const dim = {width: GAME_WIDTH * 100, height: GAME_HEIGHT * 100}
83 | function getDim() { return dim }
84 | function getCtx() { return null }
85 |
86 | const state = State.createGameState(
87 | opts,
88 | inputState,
89 | getDim,
90 | getCtx,
91 | levelSet
92 | );
93 | State.reset(state);
94 |
95 | const sim = Simulation.createSimState(state, {
96 | maxJump: 15,
97 | waitRange: {start: 100, stop: 500},
98 | holdRange: {start: 100, stop: 1500},
99 | }, rand);
100 |
101 | return {
102 | sim,
103 | state,
104 | }
105 | }
106 |
107 | /**
108 | * @param {boolean} debug
109 | * @returns {GameOptions}
110 | */
111 | export function getGameConfig(debug) {
112 | return {
113 | debug,
114 |
115 | frameTimeMS: 16,
116 | tickTimeMS: 8,
117 |
118 | caleb: {
119 | hodlTime: 500,
120 | normWidthsPerSecond: 16,
121 | dash: {
122 | dashNormWidth: 70,
123 | distance: 5,
124 | dashEaseRange: 0.10
125 | },
126 |
127 | jump: {
128 | jumpEaseRange: 0.10,
129 | jumpNormHeight: 70,
130 | noJumpBase: 450,
131 | noJumpMultiplier: 350,
132 | }
133 | },
134 |
135 | tolerance: {
136 | topBy: 0.15,
137 | bottomBy: 0.15,
138 | },
139 |
140 | gravity: new Vector2D(0, 28),
141 | }
142 | }
143 |
144 | /**
145 | * @param {SimRand} rand
146 | * @returns {GameOptions}
147 | */
148 | export function getSimGameConfig(rand) {
149 | return {
150 | debug: false,
151 |
152 | frameTimeMS: rand.randRange(50, 2),
153 | tickTimeMS: rand.randRange(50, 2),
154 |
155 | caleb: {
156 | hodlTime: rand.randRange(1000),
157 | normWidthsPerSecond: rand.randRange(20),
158 | dash: {
159 | dashNormWidth: rand.randRange(50),
160 | distance: rand.randRange(15),
161 | dashEaseRange: rand.rand() * 5
162 | },
163 |
164 | jump: {
165 | jumpEaseRange: rand.rand() * 5,
166 | jumpNormHeight: rand.randRange(50),
167 | noJumpBase: rand.randRange(1000),
168 | noJumpMultiplier: rand.randRange(1000),
169 | }
170 | },
171 |
172 | tolerance: {
173 | topBy: rand.randRange(0.45),
174 | bottomBy: rand.randRange(0.45),
175 | },
176 |
177 | gravity: new Vector2D(0, rand.randRange(48, 2)),
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/src/game-runner.js:
--------------------------------------------------------------------------------
1 | import * as Input from "./input/input.js";
2 | import * as Caleb from "./objects/caleb/caleb.js";
3 | import * as CalebInput from "./objects/caleb/input.js";
4 | import * as Debugger from "./debug.js";
5 | import * as DebugRender from "./debug-render.js";
6 | import * as Level from "./objects/level/level.js"
7 | import * as RN from "./objects/relative_numbers.js";
8 | import * as State from "./state/state.js";
9 | import * as Operations from "./state/operations.js";
10 |
11 | /**
12 | * @param {GameState} state
13 | * @param {GameLoop} loop
14 | * @param {GameTick[]} ticks
15 | * @param {(e: Error) => void} done
16 | * @param {number} until
17 | */
18 | export function run(
19 | state,
20 | loop,
21 | ticks,
22 | done,
23 | until = 0,
24 | ) {
25 | let stackOverflowPrevention = 0
26 | function onLoop() {
27 | if (state.done) {
28 | done(null)
29 | return
30 | }
31 |
32 | stackOverflowPrevention++
33 | if (stackOverflowPrevention > 500) {
34 | stackOverflowPrevention = 0
35 | setTimeout(onLoop)
36 | return
37 | }
38 |
39 | try {
40 | for (const tick of ticks) {
41 | tick(state)
42 | }
43 | } catch (e) {
44 | done(e)
45 | return
46 | }
47 |
48 | if (until === 0 || state.tick <= until) {
49 | loop(onLoop)
50 | } else {
51 | done(null)
52 | }
53 | }
54 |
55 | loop(onLoop)
56 | }
57 |
58 | /**
59 | * @param {GameState} state
60 | */
61 | export function clear(state) {
62 | state.applyables.length = 0
63 | state.renderables.length = 0
64 | state.updateables.length = 0
65 | }
66 |
67 |
68 | /**
69 | * @param {GameState} state
70 | */
71 | export function addStandardBehaviors(state) {
72 | state.updateables.push(Input, CalebInput, Debugger, RN)
73 | state.applyables.push(Level, Caleb, DebugRender)
74 | state.renderables.push(Caleb, Level, RN, DebugRender)
75 | }
76 |
77 | /**
78 | * @param {GameState} state
79 | * @param {UpdateableModule} update
80 | * */
81 | export function addUpdater(state, update) {
82 | state.updateables.push(update)
83 | }
84 |
85 | /**
86 | * @param {GameState} state
87 | * @param {UpdateAndApplyModule} apply */
88 | export function addApplyer(state, apply) {
89 | state.applyables.push(apply)
90 | }
91 |
92 | /**
93 | * @param {GameState} state
94 | * @param {RenderableModule} render
95 | * */
96 | export function addRenderer(state, render) {
97 | state.renderables.push(render)
98 | }
99 |
100 | /**
101 | * @param {GameState} state
102 | */
103 | export function tickWithRender(state) {
104 | const delta = state.loopDelta
105 | state.tick++
106 |
107 | // TODO probably need opts?
108 | if (state.caleb.dead && state.loopStartTime - state.caleb.deadAt > 1000) {
109 | State.reset(state);
110 | return;
111 | }
112 |
113 | if (state.levelChanged) {
114 | Operations.clearLevelChange(state);
115 | State.projectStaticObjects(state)
116 | }
117 |
118 | for (const input of state.updateables) {
119 | input.update(state, delta);
120 | }
121 |
122 | let deltaRemaining = delta
123 | while (deltaRemaining > 0) {
124 | const time = Math.min(state.opts.tickTimeMS, deltaRemaining)
125 | for (const u of state.applyables) {
126 | u.update(state, time);
127 | }
128 | for (const u of state.applyables) {
129 | u.check(state, time);
130 | }
131 | for (const u of state.applyables) {
132 | u.apply(state, time);
133 | }
134 | deltaRemaining -= time
135 | }
136 |
137 | const ctx = state.getCtx()
138 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
139 |
140 | for (const r of state.renderables) {
141 | r.render(state);
142 | }
143 |
144 | for (const input of state.updateables) {
145 | input.tickClear(state);
146 | }
147 |
148 | for (const u of state.applyables) {
149 | u.tickClear(state);
150 | }
151 | }
152 |
153 | /**
154 | * @param {GameState} state
155 | */
156 | export function tickWithoutRender(state) {
157 | const delta = state.loopDelta
158 |
159 | state.tick++
160 |
161 | // TODO probably need opts?
162 | if (state.caleb.dead && state.loopStartTime - state.caleb.deadAt > 1000) {
163 | State.reset(state);
164 | return;
165 | }
166 |
167 | if (state.levelChanged) {
168 | Operations.clearLevelChange(state);
169 | }
170 |
171 | for (const input of state.updateables) {
172 | input.update(state, delta);
173 | }
174 |
175 | let deltaRemaining = delta
176 | while (deltaRemaining > 0) {
177 | const time = Math.min(state.opts.tickTimeMS, deltaRemaining)
178 | for (const u of state.applyables) {
179 | u.update(state, time);
180 | }
181 | for (const u of state.applyables) {
182 | u.check(state, time);
183 | }
184 | for (const u of state.applyables) {
185 | u.apply(state, time);
186 | }
187 | deltaRemaining -= time
188 | }
189 |
190 | for (const input of state.updateables) {
191 | input.tickClear(state);
192 | }
193 |
194 | for (const u of state.applyables) {
195 | u.tickClear(state);
196 | }
197 | }
198 |
199 | /**
200 | * @param {GameState} state
201 | * @returns {GameLoop}
202 | */
203 | export function createGameLoop(state) {
204 |
205 | /**
206 | * @param {() => void} cb
207 | */
208 | function runCb(cb) {
209 | const delta = state.now() - state.loopStartTime
210 | state.loopStartTime = state.now();
211 | state.loopDelta = delta;
212 | cb()
213 | }
214 |
215 | return function(cb) {
216 | const start = state.now()
217 | const goal = state.loopStartTime + state.opts.frameTimeMS
218 |
219 | if (start > goal) {
220 | requestAnimationFrame(() => runCb(cb))
221 | } else {
222 | setTimeout(() => runCb(cb), goal - start);
223 | }
224 | }
225 | }
226 |
227 | /**
228 | * @param {GameState} state
229 | * @param {(time: number) => void} setTime
230 | * @returns {GameLoop}
231 | */
232 | export function createSimulatedGameLoop(state, setTime) {
233 | return function(cb) {
234 | setTime(state.loopStartTime + state.opts.frameTimeMS)
235 | state.loopStartTime = state.now();
236 | state.loopDelta = state.opts.frameTimeMS
237 | cb()
238 | }
239 | }
240 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/input/input.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {InputState} state
3 | * @param {string} key
4 | * @returns {Input}
5 | */
6 | export function get(state, key) {
7 | let idx = -1
8 | for (let i = 0; idx === -1 && i < state.inputs.length; ++i) {
9 | const input = state.inputs[i]
10 | if (input.key === key) {
11 | return input
12 | }
13 | }
14 | return null
15 | }
16 |
17 | /** @param gameState {GameState}
18 | /** @param _ {number} */
19 | export function apply(gameState, _) {
20 | }
21 |
22 | /** @param gameState {GameState}
23 | /** @param _ {number} */
24 | export function update(gameState, _) {
25 | const input = gameState.input
26 | input.tick = gameState.tick
27 | input.hasInput = input.inputs.length > 0
28 | }
29 |
30 |
31 | /** @param gameState {GameState} */
32 | export function tickClear(gameState) {
33 | const inputs = gameState.input.inputs
34 | for (let i = inputs.length - 1; i >= 0; --i) {
35 | const item = inputs[i]
36 | if (item.type === "down-up" || item.type === "up") {
37 | inputs.splice(i, 1)
38 | } else if (item.type === "down") {
39 | item.type = "hold"
40 | }
41 | }
42 | gameState.input.hasInput = false;
43 | }
44 |
45 | /**
46 | * @returns {InputState}
47 | */
48 | export function createInputState() {
49 | return {
50 | inputs: [],
51 | hasInput: true,
52 | tick: 0,
53 | numericModifier: 0,
54 | anykey: null
55 | }
56 | }
57 |
58 |
59 |
60 | /**
61 | * @param {InputState} state
62 | * @param {KeyEvent} event
63 | */
64 | export function processKey(state, event) {
65 | if (event.key.length > 1 || event.repeat) {
66 | return
67 | }
68 |
69 | let input = get(state, event.key)
70 | if (input !== null && event.type === "keyup") {
71 | if (input.tick === state.tick) {
72 | input.type = "down-up"
73 | } else {
74 | input.type = "up"
75 | }
76 | } else if (input === null && event.type === "keyup") {
77 | input = get(state, event.key.toLowerCase())
78 | if (input && input.tick === state.tick) {
79 | input.type = "down-up"
80 | } else if (input) {
81 | input.type = "up"
82 | }
83 | } else if (input && event.type === "keydown") {
84 | input.type = "down"
85 | } else if (input === null && event.type === "keydown") {
86 | state.inputs.push({
87 | tick: state.tick,
88 | type: "down",
89 | key: event.key,
90 | });
91 | }
92 | }
93 |
94 | /**
95 | * @param {KeyboardEvent} event
96 | * @returns {KeyEvent}
97 | */
98 | function toKeyEvent(event) {
99 | return {
100 | type: /** @type {"keydown" | "keyup"}*/(event.type),
101 | key: event.key,
102 | repeat: event.repeat,
103 | }
104 | }
105 |
106 | /**
107 | * @param {GameState} state
108 | * @param {HTMLElement} el
109 | */
110 | export function addListenersTo(state, el) {
111 | state.input.listener = function listen(event) {
112 | processKey(state.input, toKeyEvent(event));
113 | }
114 |
115 | el.addEventListener("keydown", state.input.listener)
116 | el.addEventListener("keyup", state.input.listener)
117 | }
118 |
119 | /**
120 | * @param {GameState} state
121 | * @param {HTMLElement} el
122 | */
123 | export function removeListenersFrom(state, el) {
124 | el.removeEventListener("keydown", state.input.listener)
125 | el.removeEventListener("keyup", state.input.listener)
126 | }
127 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import * as Config from "./game-config.js"
2 | import * as Levels from "./objects/level/levels/levels.js"
3 | import { assert } from "./assert.js";
4 | import * as Runner from "./game-runner.js";
5 | import * as Simulation from "./simulation/state.js";
6 | import * as State from "./state/state.js";
7 | import * as Utils from "./utils.js";
8 | import * as Input from "./input/input.js";
9 |
10 | const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById("game_canvas"))
11 | assert(!!canvas, "expected canvas to exist")
12 |
13 | const urlParams = new URLSearchParams(window.location.search);
14 | const debug = urlParams.get("debug") === "1";
15 | const seedParam = +urlParams.get("seed")
16 | const seed = seedParam > 0 ? seedParam : 0
17 | const ticks = [Runner.tickWithRender]
18 |
19 | // TODO level selection will likely need to be passed in
20 | const level = Levels.levels()[0]
21 | /** @type {GameState} */
22 | let state = null
23 | /** @type {SimState} */
24 | let sim = null
25 |
26 | if (seed > 0) {
27 | let now = 0
28 | /** @param {number} next */
29 | function setTime(next) {
30 | now = next
31 | }
32 | Utils.setNow(() => {
33 | return now
34 | })
35 |
36 | const rand = Utils.createSimRand(Utils.mulberry32(seed))
37 | const res = Config.createSimGame(rand, Config.getSimGameConfig(rand), level)
38 | state = res.state
39 | sim = res.sim
40 |
41 | ticks.unshift(function(state) {
42 | setTime(now + state.opts.frameTimeMS)
43 | });
44 | ticks.unshift(Simulation.createSimulationTick(sim));
45 |
46 | state.getCtx = () => canvas.getContext("2d")
47 | state.getDim = () => canvas
48 |
49 | } else {
50 | state = Config.createCanvasGame(canvas, Config.getGameConfig(debug), level)
51 | }
52 |
53 | Input.addListenersTo(state, canvas)
54 | Config.addBrowserListeners(state)
55 | State.projectStaticObjects(state);
56 |
57 | const loop = Runner.createGameLoop(state)
58 | Runner.clear(state)
59 | Runner.addStandardBehaviors(state)
60 | Runner.run(
61 | state,
62 | loop,
63 | ticks,
64 | (e) => {
65 | console.log("game finished", e)
66 | });
67 |
--------------------------------------------------------------------------------
/src/math/aabb.js:
--------------------------------------------------------------------------------
1 | import { assert, never } from "../assert.js";
2 | import { Vector2D } from "./vector.js";
3 |
4 | /**
5 | * @param {AABB} a
6 | * @param {AABB} b
7 | */
8 |
9 | export class AABB {
10 | /**
11 | * @param {any} obj
12 | * @returns {AABB}
13 | */
14 | static fromObject(obj) {
15 | assert(typeof obj.width === "number", "expected property width to be a number")
16 | assert(typeof obj.height === "number", "expected property height to be a number")
17 |
18 | const pos = Vector2D.fromObject(obj.pos)
19 | return new AABB(pos, obj.width, obj.height)
20 | }
21 |
22 | /**
23 | * @constructor
24 | * @param {Vector2D} pos
25 | * @param {number} width
26 | * @param {number} height
27 | */
28 | constructor(pos = new Vector2D(0, 0), width = 0, height = 0) {
29 | this.pos = pos;
30 | this.width = width;
31 | this.height = height;
32 | }
33 |
34 | /**
35 | * @param {AABB} other
36 | * @returns {AABB}
37 | */
38 | set(other) {
39 | this.pos.set(other.pos)
40 | this.width = other.width;
41 | this.height = other.height;
42 | return this;
43 | }
44 |
45 | /**
46 | * @returns {Vector2D}
47 | */
48 | center() {
49 | return new Vector2D(
50 | this.pos.x + (this.width / 2),
51 | this.pos.y + (this.height / 2),
52 | );
53 | }
54 |
55 | /**
56 | * @param {AABB} other
57 | * @returns {boolean}
58 | */
59 | intersects(other) {
60 | return (
61 | this.pos.x < other.pos.x + other.width &&
62 | this.pos.x + this.width > other.pos.x &&
63 | this.pos.y < other.pos.y + other.height &&
64 | this.pos.y + this.height > other.pos.y
65 | );
66 | }
67 |
68 | /**
69 | * @param {Vector2D} point
70 | * @returns {boolean}
71 | */
72 | contains(point) {
73 | return (
74 | point.x >= this.pos.x &&
75 | point.x <= this.pos.x + this.width &&
76 | point.y >= this.pos.y &&
77 | point.y <= this.pos.y + this.height
78 | );
79 | }
80 |
81 | /**
82 | * ASSUMES THERE IS ALREADY AN INTERSECTION
83 | * @param {AABB} other
84 | * @returns {Vector2D}
85 | */
86 | firstInsidePoint(other) {
87 | const tl = other.pos.clone()
88 | const tr = other.pos.clone().addComponents(other.width, 0)
89 | const bl = other.pos.clone().addComponents(0, other.height)
90 | const br = other.pos.clone().addComponents(other.width, other.height)
91 |
92 | if (this.contains(tl)) {
93 | return tl
94 | }
95 |
96 | if (this.contains(tr)) {
97 | return tr
98 | }
99 |
100 | if (this.contains(br)) {
101 | return br
102 | }
103 |
104 | if (this.contains(bl)) {
105 | return bl
106 | }
107 |
108 | never("cannot have this condition", "this", this, "other", other)
109 | return null
110 | }
111 |
112 | /**
113 | * @param {Vector2D} point
114 | * @returns {Vector2D}
115 | */
116 | closestPoint(point) {
117 | let tl = this.pos.clone()
118 | const tr = this.pos.clone().addComponents(this.width, 0)
119 | const bl = this.pos.clone().addComponents(0, this.height)
120 | const br = this.pos.clone().addComponents(this.width, this.height)
121 |
122 | let min = tl.clone().subtract(point)
123 | let minPoint = tl
124 | if (min.magnituteSquared() > tr.magnituteSquared()) {
125 | min = tr.clone().subtract(point)
126 | minPoint = tr
127 | }
128 |
129 | if (min.magnituteSquared() > br.magnituteSquared()) {
130 | min = br.clone().subtract(point)
131 | minPoint = br
132 | }
133 |
134 | if (min.magnituteSquared() > bl.magnituteSquared()) {
135 | min = bl.clone().subtract(point)
136 | minPoint = bl
137 | }
138 |
139 | return min
140 | }
141 |
142 | /**
143 | * @returns {Vector2D}
144 | */
145 | getCenter() {
146 | return new Vector2D(
147 | this.pos.x + this.width / 2,
148 | this.pos.y + this.height / 2
149 | );
150 | }
151 |
152 | /**
153 | * @param other {AABB}
154 | * @returns {boolean}
155 | */
156 | rightOf(other) {
157 | return this.pos.x >= other.pos.x + other.width;
158 | }
159 |
160 | /**
161 | * @param other {AABB}
162 | * @returns {boolean}
163 | */
164 | topOf(other) {
165 | return this.pos.y + this.height <= other.pos.y;
166 | }
167 |
168 | /**
169 | * @param other {AABB}
170 | * @returns {boolean}
171 | */
172 | leftOf(other) {
173 | return this.pos.x + this.width <= other.pos.x
174 | }
175 |
176 | /**
177 | * @param other {AABB}
178 | * @returns {boolean}
179 | */
180 | bottomOf(other) {
181 | return this.pos.y >= other.pos.y + other.height;
182 | }
183 |
184 | /**
185 | * @param other {AABB}
186 | * @param amount {number}
187 | * @returns {boolean}
188 | */
189 | leftOverlapBy(other, amount) {
190 | const leftOverlap = this.pos.x - (other.pos.x + other.width);
191 | return leftOverlap <= amount && leftOverlap >= 0
192 | }
193 |
194 | /**
195 | * @param other {AABB}
196 | * @param amount {number}
197 | * @returns {boolean}
198 | */
199 | rightOverlapBy(other, amount) {
200 | const rightOverlap = other.pos.x - (this.pos.x + this.width);
201 | return rightOverlap <= amount && rightOverlap >= 0
202 | }
203 |
204 | /**
205 | * @param other {AABB}
206 | * @param amount {number}
207 | * @returns {boolean}
208 | */
209 | topOverlapBy(other, amount) {
210 | const topOverlap = (this.pos.y + this.height) - other.pos.y
211 | return topOverlap <= amount && topOverlap >= 0
212 | }
213 |
214 | /**
215 | * @param other {AABB}
216 | * @param amount {number}
217 | * @returns {boolean}
218 | */
219 | bottomOverlapBy(other, amount) {
220 | const bottomOverlap = this.pos.y - (other.pos.y + other.height);
221 | return bottomOverlap <= amount && bottomOverlap >= 0
222 | }
223 |
224 | /**
225 | * @returns {AABB}
226 | */
227 | clone() {
228 | return new AABB(this.pos.clone(), this.width, this.height);
229 | }
230 |
231 | /**
232 | * @returns {string}
233 | */
234 | toString() {
235 | return `AABB(pos: ${this.pos}, width: ${this.width}, height: ${this.height})`;
236 | }
237 | }
238 |
239 | /**
240 | * @param {Vector2D} start
241 | * @param {Vector2D} end
242 | * @returns {AABB}
243 | */
244 | export function from2Vecs(start, end) {
245 | const width = (end.x - start.x) + 1
246 | const height = (end.y - start.y) + 1
247 |
248 | assert(width > 0, "cannot have negative width")
249 | assert(height > 0, "cannot have negative height")
250 |
251 | return new AABB(start.clone(), width, height)
252 | }
253 |
254 | /**
255 | * Determines if a line segment intersects with this AABB
256 | * @param {Vector2D} start - Start point of line segment
257 | * @param {Vector2D} end - End point of line segment
258 | * @param {AABB} aabb - The AABB to check intersection with
259 | * @returns {boolean} - True if line segment intersects AABB
260 | */
261 | export function lineIntersectsAABB(start, end, aabb) {
262 | const dir = end.clone().subtract(start);
263 |
264 | if (dir.magnituteSquared() === 0) {
265 | return aabb.contains(start);
266 | }
267 |
268 | const tMin = new Vector2D(
269 | (aabb.pos.x - start.x) / dir.x,
270 | (aabb.pos.y - start.y) / dir.y
271 | );
272 | const tMax = new Vector2D(
273 | (aabb.pos.x + aabb.width - start.x) / dir.x,
274 | (aabb.pos.y + aabb.height - start.y) / dir.y
275 | );
276 |
277 | if (dir.x < 0) {
278 | [tMin.x, tMax.x] = [tMax.x, tMin.x];
279 | }
280 | if (dir.y < 0) {
281 | [tMin.y, tMax.y] = [tMax.y, tMin.y];
282 | }
283 |
284 | if (tMin.x > tMax.y || tMin.y > tMax.x) {
285 | return false;
286 | }
287 |
288 | const tEnter = Math.max(tMin.x, tMin.y);
289 | const tExit = Math.min(tMax.x, tMax.y);
290 |
291 | return tEnter <= 1 && tExit >= 0;
292 | }
293 |
--------------------------------------------------------------------------------
/src/math/ease.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param percentDone {number}
3 | * @returns {number}
4 | */
5 | export function x2(percentDone) {
6 | return percentDone * percentDone;
7 | }
8 |
9 | /**
10 | * @param percentDone {number}
11 | * @returns {number}
12 | */
13 | export function x3(percentDone) {
14 | return percentDone * percentDone * percentDone;
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/src/math/utils.js:
--------------------------------------------------------------------------------
1 | import { AABB } from "./aabb.js";
2 | import { ZERO } from "./vector.js";
3 |
4 | /**
5 | * @param {AABB} from
6 | * @returns {PhysicsBody}
7 | */
8 | export function createPhysics(from) {
9 | return {
10 | acc: ZERO.clone(),
11 | vel: ZERO.clone(),
12 | vel2: ZERO.clone(),
13 | body: from.clone(),
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/math/vector.js:
--------------------------------------------------------------------------------
1 | import { assert } from "../assert.js";
2 |
3 | function precision(num) {
4 | return Math.floor(num * 100) / 100
5 | }
6 |
7 | /**
8 | * A class representing a 2D vector with various methods for game development.
9 | * @class
10 | */
11 | export class Vector2D {
12 | /**
13 | * @param {any} obj
14 | * @returns {Vector2D}
15 | */
16 | static fromObject(obj) {
17 | assert(typeof obj.x === "number", "expected property x to be a number")
18 | assert(typeof obj.y === "number", "expected property y to be a number")
19 | return new Vector2D(obj.x, obj.y)
20 | }
21 |
22 | /**
23 | * @constructor
24 | * @param {number} x - The x component of the vector.
25 | * @param {number} y - The y component of the vector.
26 | */
27 | constructor(x = 0, y = 0) {
28 | /** @type {number} */
29 | this.x = x;
30 | /** @type {number} */
31 | this.y = y;
32 | }
33 |
34 | /**
35 | * @param {Vector2D} other
36 | * @returns {Vector2D}
37 | */
38 | set(other) {
39 | this.x = other.x;
40 | this.y = other.y;
41 | return this;
42 | }
43 |
44 | /**
45 | * @param {Vector2D} v - The vector to add.
46 | * @returns {Vector2D} The current instance for chaining.
47 | */
48 | add(v) {
49 | this.x += v.x;
50 | this.y += v.y;
51 | return this;
52 | }
53 |
54 | /**
55 | * @param {number} x
56 | * @param {number} y
57 | * @returns {Vector2D} The current instance for chaining.
58 | */
59 | addComponents(x, y) {
60 | this.x += x;
61 | this.y += y;
62 | return this;
63 | }
64 |
65 | /**
66 | * @param {Vector2D} v - The vector to add.
67 | * @returns {Vector2D} The current instance for chaining.
68 | */
69 | addCopy(v) {
70 | return new Vector2D(this.x + v.x, this.y + v.y);
71 | }
72 |
73 |
74 | /**
75 | * @param {Vector2D} v - The vector to subtract.
76 | * @returns {Vector2D} The current instance for chaining.
77 | */
78 | subtract(v) {
79 | this.x -= v.x;
80 | this.y -= v.y;
81 | return this;
82 | }
83 |
84 | /**
85 | * @param {number} scalar - The scalar to multiply by.
86 | * @returns {Vector2D} The current instance for chaining.
87 | */
88 | multiply(scalar) {
89 | this.x *= scalar;
90 | this.y *= scalar;
91 | return this;
92 | }
93 |
94 | /**
95 | * @param {number} scalar - The scalar to multiply by.
96 | * @returns {Vector2D} The current instance for chaining.
97 | */
98 | multiplyCopy(scalar) {
99 | return new Vector2D(this.x * scalar, this.y * scalar);
100 | }
101 |
102 | /**
103 | * @param {number} scalar - The scalar to divide by.
104 | * @returns {Vector2D} The current instance for chaining.
105 | */
106 | divide(scalar) {
107 | if (scalar !== 0) {
108 | this.x /= scalar;
109 | this.y /= scalar;
110 | }
111 | return this;
112 | }
113 |
114 | /**
115 | * @returns {number} The magnitude of the vector.
116 | */
117 | magnituteSquared() {
118 | return this.x * this.x + this.y * this.y;
119 | }
120 |
121 | /**
122 | * @returns {number} The magnitude of the vector.
123 | */
124 | magnitude() {
125 | return Math.sqrt(this.x * this.x + this.y * this.y);
126 | }
127 |
128 | /**
129 | * @returns {Vector2D} The current instance for chaining.
130 | */
131 | normalize() {
132 | const length = this.magnitude();
133 | if (length !== 0) {
134 | this.x /= length;
135 | this.y /= length;
136 | }
137 | return this;
138 | }
139 |
140 | /**
141 | * @param {Vector2D} v - The other vector.
142 | * @returns {number} The dot product.
143 | */
144 | dot(v) {
145 | return this.x * v.x + this.y * v.y;
146 | }
147 |
148 | /**
149 | * @param {Vector2D} v - The other vector.
150 | * @returns {number} The distance.
151 | */
152 | distanceTo(v) {
153 | const dx = this.x - v.x;
154 | const dy = this.y - v.y;
155 | return Math.sqrt(dx * dx + dy * dy);
156 | }
157 |
158 | /**
159 | * @returns {Vector2D} A new instance with the same x and y components.
160 | */
161 | clone() {
162 | return new Vector2D(this.x, this.y);
163 | }
164 |
165 | /**
166 | * @returns {string} The string representation.
167 | */
168 | toString() {
169 | return `V2(${precision(this.x)}, ${precision(this.y)})`;
170 | }
171 |
172 | /**
173 | * @param {Vector2D} v - The vector to compare.
174 | * @returns {boolean} True if the vectors are equal, false otherwise.
175 | */
176 | equals(v) {
177 | return this.x === v.x && this.y === v.y;
178 | }
179 | }
180 |
181 | export const ZERO = new Vector2D(0, 0)
182 |
183 |
--------------------------------------------------------------------------------
/src/objects/caleb/caleb.js:
--------------------------------------------------------------------------------
1 | import { AABB } from "../../math/aabb.js";
2 | import { Vector2D } from "../../math/vector.js";
3 | import * as Window from "../../window.js";
4 | import { debugForCallCount, debugForTickCount } from "../../debug.js";
5 | import * as CalebInput from "./input.js";
6 | import {CALEB_HEIGHT as HEIGHT, CALEB_WIDTH as WIDTH} from "./utils.js";
7 | import * as CalebPhysics from "./physics.js";
8 | import * as Operations from "../../state/operations.js";
9 | import { assert } from "../../assert.js";
10 |
11 | const debugLog = debugForCallCount(100);
12 |
13 | /** @param state {GameState}
14 | /** @returns {Caleb} */
15 | export function createCaleb(state) {
16 | return {
17 | opts: state.opts.caleb,
18 |
19 | platform: {
20 | platform: null,
21 | tick: 0,
22 | },
23 |
24 | physics: {
25 | current: {
26 | vel2: new Vector2D(0, 0),
27 | acc: new Vector2D(0, 0),
28 | vel: new Vector2D(0, 0),
29 | body: new AABB(state.level.activeLevel.initialPosition.clone(), 0.5, 1),
30 | },
31 | next: {
32 | vel2: new Vector2D(0, 0),
33 | acc: new Vector2D(0, 0),
34 | vel: new Vector2D(0, 0),
35 | body: new AABB(state.level.activeLevel.initialPosition.clone(), 0.5, 1),
36 | }
37 | },
38 |
39 | dead: false,
40 | deadAt: 0,
41 |
42 | hodl: CalebInput.defaultHodlState(state.opts.caleb),
43 | jump: CalebInput.defaultJumpState(),
44 | dash: CalebInput.defaultDashStat(),
45 | fFtT: CalebInput.defaultfFtT(),
46 | portal: CalebInput.defaultPortal(),
47 | changingLevels: false,
48 |
49 | renderWidth: 0,
50 | renderHeight: 0,
51 | renderX: 0,
52 | renderY: 0,
53 |
54 | // I don't know wghat the canvas coloring mechanism is yet
55 | renderColor: "#FFFFFF",
56 | };
57 | }
58 |
59 | /**
60 | * @param state {GameState}
61 | * @param delta {number}
62 | * @returns {boolean}
63 | */
64 | function updateJump(state, delta) {
65 | const deltaNorm = delta / 1000
66 | const caleb = state.caleb
67 | const next = state.caleb.physics.next;
68 | const body = next.body
69 | const vel = next.vel
70 | const cJump = caleb.jump;
71 | const jumpOpts = caleb.opts.jump;
72 | const jumping = cJump.jumping
73 |
74 | if (jumping) {
75 | if (cJump.jumpStart === null) {
76 | cJump.jumpStart = body.pos.clone();
77 | }
78 |
79 | const dist = Math.abs(body.pos.y - cJump.jumpStart.y);
80 | const remaining = cJump.jumpDistance - dist;
81 | const easing = remaining <= jumpOpts.jumpEaseRange
82 |
83 | let jump = cJump.jumpDir * jumpOpts.jumpNormHeight;
84 | let jumpNormDist = jump * deltaNorm;
85 | if (!easing && remaining - Math.abs(jumpNormDist) <= jumpOpts.jumpEaseRange) {
86 |
87 | const correctedDist = remaining - jumpOpts.jumpEaseRange;
88 | const correctedJump = correctedDist / deltaNorm
89 |
90 | // 0.01 is a bonus to force into easing
91 | jump = cJump.jumpDir * (correctedJump + 0.01);
92 | } else if (easing) {
93 | jump = cJump.jumpDir * jumpOpts.jumpEaseRange * 2;
94 | }
95 |
96 | cJump.jumping = remaining > 0;
97 | vel.y = jump
98 | }
99 |
100 | cJump.noJumpTime -= delta
101 | return jumping
102 | }
103 |
104 | /**
105 | * @param state {GameState}
106 | * @param delta {number}
107 | * @returns {boolean}
108 | */
109 | function updateDash(state, delta) {
110 | const deltaNorm = delta / 1000
111 | const caleb = state.caleb
112 | const next = caleb.physics.next;
113 | const body = next.body
114 | const vel = next.vel
115 |
116 | const dash = caleb.dash;
117 | const opts = caleb.opts.dash;
118 |
119 | const dashing = dash.dashing
120 | if (dashing) {
121 | if (dash.dashStart === null) {
122 | dash.dashStart = body.pos.clone();
123 | }
124 |
125 | const dist = Math.abs(body.pos.x - dash.dashStart.x);
126 | const remaining = dash.dashDistance - dist;
127 | const easing = remaining <= opts.dashEaseRange
128 |
129 | let dashDist = dash.dashDir * opts.dashNormWidth;
130 | let dashNormDist = dashDist * deltaNorm;
131 |
132 | if (!easing && remaining - Math.abs(dashNormDist) <= opts.dashEaseRange) {
133 |
134 | const correctedDist = remaining - opts.dashEaseRange;
135 | const correctedJump = correctedDist / deltaNorm
136 |
137 | // 0.01 is a bonus to force into easing
138 | dashDist = dash.dashDir * (correctedJump + 0.01);
139 | } else if (easing) {
140 | dashDist = dash.dashDir * opts.dashEaseRange * 2;
141 | }
142 |
143 | dash.dashing = remaining > 0;
144 | vel.x = dashDist
145 | }
146 |
147 | dash.noDashTime -= delta
148 | return dashing
149 | }
150 | /**
151 | * @param {GameState} state
152 | */
153 | function updatePortal(state) {
154 | const caleb = state.caleb
155 | if (!caleb.portal.portaling || caleb.portal.tick === state.tick) {
156 | return false
157 | }
158 |
159 | // TODO should i move all these data retrievals behind an interface?
160 | const aabb = caleb.physics.current.body
161 | const level = state.level.activeLevel
162 |
163 | assert(!!level, "performing a caleb portal and there is no active level...")
164 | let found = false
165 | for (const p of level.platforms) {
166 | const portal = p.behaviors.portal
167 | if (!!portal && p.physics.current.body.intersects(aabb)) {
168 |
169 | const vel = caleb.physics.current.vel.clone()
170 |
171 | CalebInput.resetJumpState(state);
172 | CalebInput.resetDashState(state);
173 | CalebInput.resetPlatformHold(state)
174 |
175 | const {
176 | platform: next,
177 | level,
178 | } = Operations.findPlatformById(state, portal.to)
179 | caleb.portal.to = level
180 |
181 | // TODO: ?? is this really the best option? the only downfall would be portals of height 1
182 | // that would put caleb into potentially an obstacle which is currently undefined behavior
183 | caleb.physics.next.body.pos.set(next.physics.current.body.center())
184 |
185 | if (caleb.physics.current.vel2) {
186 | vel.add(caleb.physics.current.vel2)
187 | }
188 |
189 | caleb.physics.next.vel2 = next.behaviors.portal.normal.clone().multiply(vel.magnitude())
190 | found = true
191 | break
192 | }
193 | }
194 |
195 | if (!found) {
196 | CalebInput.resetPortalState(state)
197 | }
198 |
199 | return true
200 | }
201 |
202 | /**
203 | * @param state {GameState}
204 | * @param delta {number}
205 | */
206 | function forceRemainingOnMovingPlatform(state, delta) {
207 | const plat = state.caleb.platform
208 |
209 | if (
210 | plat.platform && state.tick - 1 > plat.tick ||
211 | !plat.platform ||
212 | state.caleb.dash.dashing || state.caleb.jump.jumping
213 | ) {
214 | return
215 | }
216 |
217 | const pphys = plat.platform.physics.next
218 | const cphys = state.caleb.physics.next
219 | if (pphys.body.intersects(cphys.body)) {
220 | return
221 | }
222 |
223 | const diff = pphys.body.pos.y - (cphys.body.pos.y + HEIGHT)
224 | cphys.body.pos.y += diff
225 | }
226 |
227 | /**
228 | * @param state {GameState}
229 | * @param delta {number}
230 | */
231 | function updatePosition(state, delta) {
232 | const caleb = state.caleb;
233 | const next = caleb.physics.next;
234 | const pos = next.body.pos
235 | const vel = next.vel
236 |
237 | let deltaNorm = delta / 1000;
238 |
239 | if (updatePortal(state)) {
240 | } else if (updateDash(state, delta)) {
241 | } else if (updateJump(state, delta)) {
242 | } else {
243 | vel.add(state.opts.gravity.multiplyCopy(deltaNorm));
244 | forceRemainingOnMovingPlatform(state, delta)
245 | }
246 |
247 | next.body.pos = pos.
248 | add(vel.clone().multiply(deltaNorm)).
249 | add(next.vel2.clone().multiply(deltaNorm));
250 |
251 | next.vel2.multiply(1 - (deltaNorm / 2.0)); // <-- delta norm rate?
252 | }
253 |
254 | /**
255 | * @param {GameState} state
256 | * @param {number} _
257 | */
258 | export function check(state, _) {
259 | if (state.caleb.changingLevels) {
260 | return
261 | }
262 |
263 | CalebPhysics.testCollisions(state);
264 | }
265 |
266 | /**
267 | * @param state {GameState}
268 | * @param _ {number}
269 | */
270 | export function apply(state, _) {
271 | const caleb = state.caleb
272 | const next = caleb.physics.next;
273 | const curr = caleb.physics.current;
274 |
275 | curr.body.set(next.body)
276 | curr.vel.set(next.vel)
277 | curr.acc.set(next.acc)
278 |
279 | // techincally i could move this into the engine side not in each update
280 | Window.projectInto(state.getDim(), caleb, next.body);
281 |
282 | if (caleb.portal.portaling && caleb.portal.tick !== state.tick) {
283 | Operations.setLevel(state, caleb.portal.to, curr.body.pos)
284 | caleb.portal.tick = state.tick
285 | }
286 | }
287 |
288 | /**
289 | * @param {GameState} state
290 | */
291 | export function render(state) {
292 | const ctx = state.getCtx();
293 |
294 | ctx.fillStyle = "black";
295 | const caleb = state.caleb
296 | ctx.fillRect(caleb.renderX, caleb.renderY, caleb.renderWidth, caleb.renderHeight);
297 | }
298 |
299 | /**
300 | * @param gameState {GameState}
301 | * @param delta {number}
302 | */
303 | export function update(gameState, delta) {
304 | const caleb = gameState.caleb
305 | if (caleb.dead || delta === 0 || caleb.changingLevels) {
306 | return;
307 | }
308 |
309 | if (caleb.hodl.hodlTime > 0) {
310 | caleb.hodl.hodlTime -= delta
311 | } else {
312 | updatePosition(gameState, delta);
313 | }
314 |
315 | }
316 |
317 | /**
318 | * @param state {GameState}
319 | */
320 | export function tickClear(state) {
321 | const caleb = state.caleb
322 | if (!caleb.dead && caleb.physics.current.body.pos.y > Window.FULL_HEIGHT + 3) {
323 | caleb.dead = true;
324 | caleb.deadAt = state.now()
325 | }
326 |
327 | if (caleb.portal.portaling) {
328 | CalebInput.resetPortalState(state);
329 | }
330 | }
331 |
--------------------------------------------------------------------------------
/src/objects/caleb/input.js:
--------------------------------------------------------------------------------
1 | import * as Input from "../../input/input.js"
2 | import { ZERO } from "../../math/vector.js";
3 | import * as Level from "../level/level.js"
4 | import * as CalebUtils from "./utils.js";
5 |
6 | /**
7 | * @param {-1 | 1} dir
8 | * @returns {InputHandler}
9 | */
10 | function moveWB(dir) {
11 | return function(state) {
12 | if (state.caleb.dash.dashing) {
13 | return false;
14 | }
15 |
16 | const caleb = state.caleb;
17 | const dash = caleb.dash;
18 |
19 | const row = CalebUtils.getNextRow(state.caleb)
20 | const col = CalebUtils.getNextCol(state.caleb)
21 | const letters = Level.getLetters(state, row)
22 |
23 | let destination = -1
24 | for (const {idx} of letters) {
25 | if (dir === -1 && idx < col) {
26 | destination = idx
27 | } else if (dir === 1 && idx > col) {
28 | destination = idx
29 | }
30 | }
31 |
32 | if (destination === -1) {
33 | return
34 | }
35 |
36 | resetJumpState(state);
37 | resetDashState(state);
38 | resetVel2(state);
39 |
40 | const distance = destination - state.caleb.physics.next.body.pos.x + CalebUtils.CALEB_WIDTH
41 | dash.dashing = true;
42 | dash.dashDistance = Math.abs(distance)
43 | dash.dashStart = null
44 | dash.dashDir = distance > 0 ? 1 : -1
45 | if (dash.dashDir === 1) {
46 | dash.dashDistance -= CalebUtils.CALEB_WIDTH
47 | }
48 |
49 | return true;
50 | }
51 | }
52 |
53 | /**
54 | * @param {GameState} state
55 | */
56 | function movePortal(state) {
57 | const caleb = state.caleb;
58 | caleb.portal.portaling = true
59 | caleb.portal.tick = 0
60 | return true
61 | }
62 |
63 | /**
64 | * @param {-1 | 1} dir
65 | * @returns {InputHandler}
66 | */
67 | function moveKJ(dir) {
68 |
69 | return function(state) {
70 | if (state.caleb.jump.noJumpTime > 0) {
71 | return false;
72 | }
73 |
74 | const input = state.input;
75 | const caleb = state.caleb;
76 | const jump = caleb.jump;
77 | const opts = caleb.opts.jump;
78 | const number = Math.min(Math.max(state.input.numericModifier, 1), 15)
79 |
80 | input.numericModifier = 0
81 | jump.jumping = true;
82 | jump.jumpDistance = number
83 | jump.jumpStart = null
84 | jump.noJumpTime = (number * opts.noJumpMultiplier) + opts.noJumpBase;
85 | jump.jumpDir = dir
86 |
87 | if (dir === 1) {
88 | jump.jumpDistance -= CalebUtils.CALEB_HEIGHT
89 | }
90 |
91 | resetDashState(state);
92 | resetVel2(state);
93 |
94 | return true;
95 | }
96 | }
97 |
98 | /**
99 | * @param dir {number}
100 | * @returns {InputHandler}
101 | */
102 | function moveHL(dir) {
103 | return function(state) {
104 | resetVel2(state);
105 | state.caleb.physics.next.vel.x = state.opts.caleb.normWidthsPerSecond * dir
106 | return true;
107 | }
108 | }
109 |
110 | /**
111 | * @param {fFtTKey} key
112 | * @returns {InputHandler}
113 | */
114 | function movefFtT(key) {
115 | return function(state) {
116 | state.caleb.fFtT.type = key
117 | state.caleb.fFtT.startTick = state.tick
118 | state.input.anykey = completefFtT;
119 |
120 | // modifies a structure while iterating it...
121 | state.input.inputs.length = 0
122 |
123 | return true;
124 | }
125 | }
126 |
127 | /**
128 | * @param {GameState} state
129 | */
130 | function completeCommand(state) {
131 | const input = state.input.inputs[0]
132 | if (!input) {
133 | return
134 | }
135 |
136 | state.input.inputs.length = 0
137 | state.input.anykey = null
138 | if (input.key === "q") {
139 | state.done = true
140 | }
141 | }
142 |
143 | /**
144 | * @param {GameState} state
145 | * @returns {boolean}
146 | */
147 | function command(state) {
148 | state.input.anykey = completeCommand;
149 | state.input.inputs.length = 0
150 | return true;
151 | }
152 |
153 | /**
154 | * @param {GameState} state
155 | */
156 | function completefFtT(state) {
157 | const caleb = state.caleb;
158 | const fFtT = caleb.fFtT;
159 | const dash = caleb.dash;
160 | const input = state.input.inputs[0];
161 | if (!input) {
162 | return;
163 | }
164 |
165 | state.input.inputs.length = 0;
166 | state.input.anykey = null;
167 |
168 | const row = CalebUtils.getNextRow(state.caleb);
169 | const col = CalebUtils.getNextCol(state.caleb);
170 | const letters = Level.getLetters(state, row);
171 | let destination = -1;
172 | for (const {key, idx} of letters) {
173 | if (input.key === key) {
174 | if ((fFtT.type === "f" || fFtT.type === "t") && idx > col ||
175 | (fFtT.type === "F" || fFtT.type === "T") && idx < col) {
176 | destination = idx;
177 | break;
178 | }
179 | }
180 | }
181 |
182 | if (destination === -1) {
183 | return;
184 | }
185 |
186 | resetJumpState(state);
187 | resetDashState(state);
188 | resetVel2(state);
189 |
190 | // Adjust destination based on motion type (f/F/t/T)
191 | if (fFtT.type === "t") {
192 | destination -= CalebUtils.CALEB_WIDTH - 0.01; // Move before character
193 | } else if (fFtT.type === "T") {
194 | destination += 1.01; // Move after character
195 | }
196 |
197 | const distance = destination - CalebUtils.getNextX(caleb);
198 | dash.dashing = true;
199 | dash.dashDistance = Math.abs(distance);
200 | dash.dashStart = null;
201 | dash.dashDir = distance > 0 ? 1 : -1;
202 |
203 | caleb.physics.next.acc.x = 0;
204 | caleb.physics.next.acc.y = 0;
205 | }
206 |
207 | /**
208 | * @param {string} key
209 | * @param {InputHandler} next
210 | * @returns {InputHandler}
211 | */
212 | function filter(key, next) {
213 | return function(state, input) {
214 | if (key !== input.key) {
215 | return false;
216 | }
217 | return next(state, input);
218 | }
219 | }
220 |
221 | function onDown(next) {
222 | return function(state, input) {
223 | if (input.type !== "down" && input.type !== "down-up") {
224 | return false;
225 | }
226 | return next(state, input);
227 | }
228 | }
229 |
230 | const _0 = "0".charCodeAt(0)
231 | const _9 = "9".charCodeAt(0)
232 |
233 | /**
234 | * @param {InputHandler} next
235 | * @returns {InputHandler}
236 | */
237 | function isNumeric(next) {
238 | return function(state, input) {
239 | const code = input.key.charCodeAt(0)
240 | if (code < _0 || code > _9) {
241 | return false;
242 | }
243 | return next(state, input);
244 | }
245 | }
246 |
247 | /**
248 | * @param {GameState} state
249 | * @param {Input} input
250 | * @returns boolean
251 | */
252 | function numericModifier(state, input) {
253 | state.input.numericModifier *= 10
254 | state.input.numericModifier += +input.key
255 | return true;
256 | }
257 |
258 | const h = filter("h", moveHL(-1));
259 | const l = filter("l", moveHL(1));
260 | const j = onDown(filter("j", moveKJ(1)));
261 | const k = onDown(filter("k", moveKJ(-1)));
262 | const w = onDown(filter("w", moveWB(1)));
263 | const b = onDown(filter("b", moveWB(-1)));
264 | const f = onDown(filter("f", movefFtT("f")));
265 | const t = onDown(filter("t", movefFtT("t")));
266 | const F = onDown(filter("F", movefFtT("F")));
267 | const T = onDown(filter("T", movefFtT("T")));
268 | const quit = onDown(filter(":", command));
269 | const numeric = onDown(isNumeric(numericModifier))
270 | const portal = onDown(filter("%", movePortal));
271 |
272 | /**
273 | * @param {GameState} state
274 | */
275 | function handleHL(state) {
276 | if (state.input.anykey) {
277 | state.caleb.physics.next.vel.x = 0
278 | return
279 | }
280 |
281 | const hInput = Input.get(state.input, "h")
282 | const lInput = Input.get(state.input, "l")
283 |
284 | if (hInput && !lInput) {
285 | h(state, hInput)
286 | } else if (!hInput && lInput) {
287 | l(state, lInput)
288 | } else if (!state.caleb.dash.dashing) {
289 | state.caleb.physics.next.vel.x = 0
290 | }
291 | }
292 |
293 | /**
294 | * @param {GameState} state
295 | * @param {number} _
296 | */
297 | export function apply(state, _) { }
298 |
299 | /**
300 | * @param {GameState} state
301 | * @param {number} _
302 | */
303 | export function update(state, _) {
304 | handleHL(state);
305 | if (state.input.anykey) {
306 | state.input.anykey(state)
307 | return
308 | }
309 |
310 | for (const i of state.input.inputs) {
311 | numeric(state, i)
312 | j(state, i)
313 | k(state, i)
314 | w(state, i)
315 | b(state, i)
316 | f(state, i)
317 | F(state, i)
318 | t(state, i)
319 | T(state, i)
320 | quit(state, i)
321 | portal(state, i)
322 | }
323 | }
324 |
325 | /**
326 | */
327 | export function tickClear() { }
328 |
329 | /** @returns {CalebJump} */
330 | export function defaultJumpState() {
331 | return {
332 | jumping: false,
333 | jumpDistance: 0,
334 | noJumpTime: 0,
335 | jumpDir: /** @type {-1 | 1} */(1),
336 | jumpStart: null,
337 | }
338 | }
339 |
340 | /** @param {CalebOpts} opts
341 | /** @returns {CalebHodl} */
342 | export function defaultHodlState(opts) {
343 | return {
344 | hodlTime: opts.hodlTime
345 | }
346 | }
347 |
348 |
349 | /** @returns {CalebDash} */
350 | export function defaultDashStat() {
351 | return {
352 | dashing: false,
353 | dashDir: 1,
354 | dashStart: null,
355 | dashDistance: 0,
356 | noDashTime: 0,
357 | }
358 | }
359 |
360 | /** @returns {fFtT} */
361 | export function defaultfFtT() {
362 | return {
363 | type: "f",
364 | startTick: 0,
365 | }
366 | }
367 |
368 | /** @returns {CalebPortal} */
369 | export function defaultPortal() {
370 | return {
371 | portaling: false,
372 | to: 0,
373 | }
374 | }
375 |
376 |
377 | /**
378 | * @param state {GameState}
379 | */
380 | export function resetPlatformHold(state) {
381 | state.caleb.platform.platform = null
382 | }
383 |
384 | /**
385 | * @param state {GameState}
386 | */
387 | export function resetVel2(state) {
388 | state.caleb.physics.current.vel2.set(ZERO);
389 | state.caleb.physics.next.vel2.set(ZERO);
390 | }
391 |
392 | /**
393 | * @param state {GameState}
394 | */
395 | export function resetJumpState(state) {
396 | const jump = state.caleb.jump
397 | state.caleb.physics.next.vel.y = 0
398 | jump.jumping = false;
399 | jump.jumpDistance = 0;
400 | jump.noJumpTime = 0;
401 | jump.jumpDir = 1
402 | jump.jumpStart = null
403 | }
404 |
405 | /**
406 | * @param state {GameState}
407 | */
408 | export function resetDashState(state) {
409 | const dash = state.caleb.dash
410 | dash.dashing = false;
411 | dash.dashDistance = 0;
412 | dash.dashDir = 1;
413 | dash.noDashTime = 0;
414 | }
415 |
416 | /**
417 | * @param {GameState} state
418 | */
419 | export function resetPortalState(state) {
420 | state.caleb.portal.portaling = false
421 | state.caleb.portal.tick = 0
422 | }
423 |
424 |
--------------------------------------------------------------------------------
/src/objects/caleb/physics.js:
--------------------------------------------------------------------------------
1 | import * as CalebInput from "./input.js";
2 | import { assert } from "../../assert.js";
3 | import * as Operations from "../../state/operations.js"
4 | import { DO_NOT_USE_FOR_INITIAL_POS_OR_YOU_WILL_BE_FIRED } from "../level/level.js";
5 |
6 | /**
7 | * @param {GameState} state
8 | */
9 | export function testCollisions(state) {
10 | if (state.caleb.hodl.hodlTime > 0) {
11 | return
12 | }
13 |
14 | const platforms = findCollisions(state)
15 |
16 | for (const p of platforms) {
17 | if (p.behaviors.obstacle) {
18 | collidePlatform(state, p)
19 | } else if (p.behaviors.next) {
20 | collideLevelChange(state, p)
21 | } else if (p.behaviors.instagib) {
22 | collideInstagib(state)
23 | }
24 | }
25 | }
26 |
27 | /**
28 | * @param {GameState} state
29 | * @returns {BasedPlatform[]}
30 | */
31 | function findCollisions(state) {
32 | const nextAABB = state.caleb.physics.next.body;
33 | const out = []
34 | for (const platform of state.level.activeLevel.platforms) {
35 | const platformAABB = platform.physics.next.body;
36 | if (nextAABB.intersects(platformAABB)) {
37 | out.push(platform)
38 | }
39 | }
40 | return out
41 | }
42 |
43 | /**
44 | * @param {GameState} state
45 | * @param {BasedPlatform} platform
46 | */
47 | function collidePlatform(state, platform) {
48 | const prev = state.caleb.physics.current;
49 | const next = state.caleb.physics.next;
50 | const body = next.body;
51 | const dash = state.caleb.dash;
52 | const jump = state.caleb.jump;
53 | const opts = state.opts;
54 | const tolerance = opts.tolerance
55 |
56 | const platformAABB = platform.physics.next.body
57 |
58 | const left = prev.body.leftOf(platform.physics.current.body)
59 | const right = prev.body.rightOf(platform.physics.current.body)
60 | const top = prev.body.topOf(platform.physics.current.body)
61 | const bottom = prev.body.bottomOf(platform.physics.current.body)
62 |
63 | if (left || right) {
64 | if (dash.dashing && body.topOverlapBy(platformAABB, tolerance.topBy)) {
65 | body.pos.y = platformAABB.pos.y - body.height
66 | } else if (dash.dashing && body.bottomOverlapBy(platformAABB, tolerance.bottomBy)) {
67 | body.pos.y = platformAABB.pos.y + platformAABB.pos.y
68 | } else {
69 | next.vel.x = 0;
70 | next.vel2.x = 0;
71 | if (left) {
72 | body.pos.x = platformAABB.pos.x - body.width;
73 | } else {
74 | body.pos.x = platformAABB.pos.x + platformAABB.width
75 | }
76 |
77 | CalebInput.resetDashState(state);
78 | }
79 | } else if (top || bottom) {
80 | next.vel.y = 0;
81 |
82 | if (top) {
83 | body.pos.y = platformAABB.pos.y - body.height
84 | if (!dash.dashing && !jump.jumping) {
85 | const x = platform.physics.next.body.pos.x - platform.physics.current.body.pos.x
86 | next.body.pos.x += x
87 | state.caleb.platform.platform = platform
88 | state.caleb.platform.tick = state.tick
89 | CalebInput.resetVel2(state)
90 | } else {
91 | state.caleb.platform.platform = null
92 | state.caleb.platform.tick = state.tick
93 | }
94 | } else {
95 | body.pos.y = platformAABB.pos.y + platformAABB.height
96 | }
97 |
98 | CalebInput.resetJumpState(state);
99 | } else {
100 | // TODO Figure out how this happens and how to prevent it...
101 | }
102 | }
103 |
104 | /**
105 | * @param {GameState} state
106 | * @param {BasedPlatform} p
107 | */
108 | function collideLevelChange(state, p) {
109 | const platform = p.behaviors.next
110 | const next = state.caleb.physics.next;
111 | const body = next.body;
112 |
113 | if (platform.toLevelPosition.x !== DO_NOT_USE_FOR_INITIAL_POS_OR_YOU_WILL_BE_FIRED) {
114 | body.pos.x = platform.toLevelPosition.x
115 | } else if (platform.toLevelPosition.y !== DO_NOT_USE_FOR_INITIAL_POS_OR_YOU_WILL_BE_FIRED) {
116 | body.pos.y = platform.toLevelPosition.y
117 | }
118 |
119 | console.log(state.tick, "setLevel", platform.toLevel)
120 | Operations.setLevel(state, platform.toLevel, body.pos)
121 | }
122 |
123 | /**
124 | * @param {GameState} state
125 | */
126 | function collideInstagib(state) {
127 | if (!state.caleb.dead) {
128 | state.caleb.deadAt = state.now()
129 | }
130 | state.caleb.dead = true
131 | }
132 |
--------------------------------------------------------------------------------
/src/objects/caleb/utils.js:
--------------------------------------------------------------------------------
1 | export const CALEB_HEIGHT = 1
2 | export const CALEB_WIDTH = 0.5
3 |
4 | /**
5 | * @param caleb {Caleb}
6 | * @returns {number}
7 | */
8 | export function getRow(caleb) {
9 | const body = caleb.physics.current.body;
10 | return Math.floor(body.pos.y + body.height / 2)
11 | }
12 |
13 | /**
14 | * @param {Caleb} caleb
15 | * @returns {number}
16 | */
17 | export function getNextRow(caleb) {
18 | const body = caleb.physics.next.body;
19 | return Math.floor(body.pos.y + body.height / 2)
20 | }
21 |
22 | /**
23 | * @param caleb {Caleb}
24 | * @returns {number}
25 | */
26 | export function getCol(caleb) {
27 | return Math.floor(caleb.physics.current.body.pos.x)
28 | }
29 |
30 | /**
31 | * @param caleb {Caleb}
32 | * @returns {number}
33 | */
34 | export function getNextCol(caleb) {
35 | return Math.floor(caleb.physics.next.body.pos.x)
36 | }
37 |
38 | /**
39 | * @param caleb {Caleb}
40 | * @returns {number}
41 | */
42 | export function getNextX(caleb) {
43 | return caleb.physics.next.body.pos.x
44 | }
45 |
46 |
--------------------------------------------------------------------------------
/src/objects/level/level.js:
--------------------------------------------------------------------------------
1 | import { assert } from "../../assert.js";
2 | import { AABB } from "../../math/aabb.js";
3 | import { Vector2D } from "../../math/vector.js";
4 | import { clonePhysics } from "../../utils.js";
5 | import { GAME_HEIGHT, GAME_WIDTH, projectCoords, projectInto } from "../../window.js";
6 | import { getRow } from "../caleb/utils.js";
7 | import * as Easing from "../../math/ease.js"
8 |
9 | export const DO_NOT_USE_FOR_INITIAL_POS_OR_YOU_WILL_BE_FIRED = -69
10 |
11 | let _id = 0;
12 |
13 | /**
14 | * @param {CanvasRenderingContext2D} ctx
15 | * @param {string} text
16 | * @param {number} x
17 | * @param {number} y
18 | * @param {number} calebY
19 | */
20 | function renderText(ctx, text, x, y, calebY) {
21 | const [_x, _y] = projectCoords(ctx.canvas, x + 0.25, y + 0.5)
22 |
23 | if (y === calebY) {
24 | ctx.fillStyle = "purple";
25 | } else {
26 | ctx.fillStyle = "white";
27 | }
28 |
29 | ctx.textAlign = "left"
30 | ctx.textBaseline = "middle"
31 | ctx.fillText(text, _x, _y)
32 | }
33 |
34 |
35 | /** @param {GameState} state
36 | */
37 | export function render(state) {
38 | const ctx = state.getCtx();
39 | const plats = state.level.activeLevel.platforms
40 | const calebY = getRow(state.caleb);
41 |
42 | for (const p of plats) {
43 | ctx.fillStyle = "black";
44 |
45 | const render = p.behaviors.render
46 | const lazer = p.behaviors.lazer
47 | const lettered = p.behaviors.lettered
48 |
49 | if (lazer) {
50 | // Draw black circle base
51 | ctx.beginPath();
52 | ctx.arc(render.renderX + render.renderWidth/2,
53 | render.renderY + render.renderHeight/2,
54 | render.renderWidth/2, 0, Math.PI * 2);
55 | ctx.fill();
56 |
57 | // Calculate direction to player
58 | const platformCenter = new Vector2D(
59 | p.physics.current.body.pos.x + p.physics.current.body.width/2,
60 | p.physics.current.body.pos.y + p.physics.current.body.height/2
61 | );
62 | const playerPos = state.caleb.physics.current.body.pos;
63 | const direction = playerPos.clone().subtract(platformCenter).normalize();
64 |
65 | // Draw gray cannon
66 | ctx.save();
67 | ctx.translate(render.renderX + render.renderWidth/2,
68 | render.renderY + render.renderHeight/2);
69 | ctx.rotate(Math.atan2(direction.y, direction.x));
70 | ctx.fillStyle = "gray";
71 | ctx.fillRect(0, -render.renderWidth/8,
72 | render.renderWidth/2, render.renderWidth/4);
73 | ctx.restore();
74 | } else if (render) {
75 | ctx.fillRect(render.renderX,
76 | render.renderY,
77 | render.renderWidth,
78 | render.renderHeight
79 | );
80 | }
81 |
82 | if (lettered) {
83 | const pos = p.physics.current.body.pos
84 | renderText(ctx, lettered.letter, pos.x, pos.y, calebY)
85 | }
86 | }
87 | }
88 |
89 | /**
90 | * @param {AABB} aabb
91 | * @returns {BasedPlatform}
92 | */
93 | export function createPlatform(aabb) {
94 | const id = _id++
95 | return {
96 | behaviors: {},
97 | id,
98 | physics: {
99 | current: {
100 | vel2: new Vector2D(0, 0),
101 | vel: new Vector2D(0, 0),
102 | acc: new Vector2D(0, 0),
103 | body: aabb,
104 | },
105 | next: {
106 | vel2: new Vector2D(0, 0),
107 | vel: new Vector2D(0, 0),
108 | acc: new Vector2D(0, 0),
109 | body: aabb.clone(),
110 | },
111 | }
112 | };
113 | }
114 |
115 | /**
116 | * @param {BasedPlatform} platform
117 | * @returns {BasedPlatform}
118 | */
119 | export function withRender(platform) {
120 | platform.behaviors.render = {
121 | type: "render",
122 | renderX: 0,
123 | renderY: 0,
124 | renderWidth: 0,
125 | renderHeight: 0,
126 | };
127 |
128 | return platform
129 | }
130 |
131 | /**
132 | * @param {BasedPlatform} platform
133 | * @returns {BasedPlatform}
134 | */
135 | export function withObstacle(platform) {
136 | assert(platform.behaviors.instagib === undefined, "cannot have an obsacle that is also instagib")
137 | platform.behaviors.obstacle = {type: "obstacle"}
138 | return platform
139 | }
140 |
141 | /**
142 | * @param {BasedPlatform} platform
143 | * @param {number} toLevel
144 | * @param {Vector2D} toLevelPosition
145 | * @returns {BasedPlatform}
146 | */
147 | export function withNextLevel(platform, toLevel, toLevelPosition) {
148 | platform.behaviors.next = {
149 | toLevel,
150 | toLevelPosition,
151 | type: "next-level",
152 | };
153 | return platform
154 | }
155 |
156 | /**
157 | * @param {BasedPlatform} platform
158 | * @returns {BasedPlatform}
159 | */
160 | export function withInstaGib(platform) {
161 | assert(platform.behaviors.obstacle === undefined, "cannot have instagib that is also obstacle")
162 | platform.behaviors.instagib = { type: "instagib", };
163 | return platform
164 | }
165 |
166 | /**
167 | * @param {BasedPlatform} platform
168 | * @param {number} time
169 | * @param {Vector2D} endPos
170 | * @returns {BasedPlatform}
171 | */
172 | export function withCircuit(platform, time, endPos) {
173 | platform.behaviors.circuit = {
174 | type: "circuit",
175 | time,
176 | currentTime: 0,
177 | currentDir: 1,
178 | startPos: platform.physics.current.body.pos.clone(),
179 | endPos,
180 | }
181 |
182 | return platform
183 | }
184 |
185 | /**
186 | * @param {BasedPlatform} platform
187 | * @returns {BasedPlatform}
188 | */
189 | export function withLazer(platform) {
190 | platform.behaviors.lazer = {
191 | type: "lazer",
192 | }
193 | platform.behaviors.render = {
194 | type: "render",
195 | renderX: 0,
196 | renderY: 0,
197 | renderWidth: 0,
198 | renderHeight: 0,
199 | }
200 | return platform
201 | }
202 |
203 |
204 | /**
205 | * @param {BasedPlatform[]} platforms
206 | * @returns {(string | null)[][]}
207 | */
208 | export function createLetterMap(platforms) {
209 | const out = [];
210 | for (let y = 0; y < GAME_HEIGHT; y++) {
211 | out.push(new Array(GAME_WIDTH).fill(null));
212 | }
213 |
214 | for (const p of platforms) {
215 | const letter = p.behaviors.lettered
216 | if (!letter) {
217 | continue
218 | }
219 | const pos = p.physics.current.body.pos
220 |
221 | out[pos.y][pos.x] = letter.letter
222 | }
223 |
224 | return out;
225 | }
226 |
227 | /**
228 | * @param {GameState} state
229 | * @param {number} r
230 | * @returns {{key: string, idx: number}[]}
231 | */
232 | export function getLetters(state, r) {
233 | // TODO this just has to create such garbage...
234 | return getLettersByRow(state, r).map((key, idx) => ({key, idx})).filter(({key}) => key !== null)
235 | }
236 |
237 | /**
238 | * @param {GameState} state
239 | * @param {number} r
240 | * @returns {string[]}
241 | */
242 | export function getLettersByRow(state, r) {
243 | return state.level.activeLevel.letterMap[r] || []
244 | }
245 |
246 |
247 | /**
248 | * @param {GameState} state
249 | * @param {number} _
250 | */
251 | export function check(state, _) { }
252 |
253 | /**
254 | * @param {GameState} state
255 | * @param {number} _
256 | */
257 | export function apply(state, _) {
258 | for (const p of state.level.activeLevel.platforms) {
259 | const render = p.behaviors.render
260 | if (render) {
261 | projectInto(state.getDim(), render, p.physics.next.body)
262 | }
263 |
264 |
265 | const next = p.physics.next;
266 | const curr = p.physics.current;
267 |
268 | curr.body.set(next.body)
269 | curr.vel.set(next.vel)
270 | curr.acc.set(next.acc)
271 | }
272 |
273 | }
274 |
275 | /**
276 | * @param {GameState} state
277 | * @param {number} delta
278 | */
279 | export function update(state, delta) {
280 | for (const p of state.level.activeLevel.platforms) {
281 | const circuit = p.behaviors.circuit
282 | if (!circuit) {
283 | continue
284 | }
285 |
286 | // TODO: probably calculate out the velocity instead of updating position
287 | circuit.currentTime += delta
288 |
289 | let percentDone = Math.min(1, circuit.currentTime / circuit.time)
290 | percentDone = Easing.x3(percentDone)
291 |
292 | if (circuit.currentDir === -1) {
293 | percentDone = 1 - percentDone
294 | }
295 |
296 |
297 | // TODO: here is the problem
298 | const x = circuit.startPos.x + (circuit.startPos.x - circuit.endPos.x) * percentDone
299 | const y = circuit.startPos.y + (circuit.startPos.y - circuit.endPos.y) * percentDone
300 |
301 | const next = p.physics.next
302 | const pos = next.body.pos
303 | const dNorm = (delta / 1000)
304 | const xDiff = (x - pos.x) / dNorm
305 | const yDiff = (y - pos.y) / dNorm
306 |
307 | next.vel.x = xDiff
308 | next.vel.y = yDiff
309 | pos.x = x
310 | pos.y = y
311 |
312 | if (circuit.currentDir === 1 && percentDone === 1 ||
313 | circuit.currentDir === -1 && percentDone === 0) {
314 | circuit.currentDir *= -1
315 | circuit.currentTime = 0
316 | }
317 | }
318 | }
319 |
320 | /**
321 | * @param {GameState} state
322 | * @param {number} id
323 | * @returns {BasedPlatform | undefined}
324 | */
325 | export function findPlatformById(state, id) {
326 | return state.level.platforms.get(id)
327 | }
328 |
329 | /**
330 | * @param {GameState} state
331 | */
332 | export function tickClear(state) {
333 | }
334 |
335 | /**
336 | * @param {BasedPlatform} platform
337 | * @returns {BasedPlatform}
338 | */
339 | export function clonePlatform(platform) {
340 | const out = {...platform}
341 | out.physics = clonePhysics(out).physics
342 | return out
343 | }
344 |
--------------------------------------------------------------------------------
/src/objects/level/level.test.js:
--------------------------------------------------------------------------------
1 | import test from "node:test";
2 | import * as assert from "node:assert";
3 |
4 | import * as Level from "./level.js";
5 | import * as Window from "../../window.js";
6 | import { AABB } from "../../math/aabb.js";
7 | import { Vector2D } from "../../math/vector.js";
8 |
9 | test("should return an empty map when no platforms are provided", () => {
10 | const platforms = [];
11 | const result = Level.createLetterMap(platforms);
12 | assert.deepEqual(result, Array(Window.GAME_HEIGHT).fill(Array(Window.GAME_WIDTH).fill(null)));
13 | });
14 |
15 | test("should place letters correctly for a single platform", () => {
16 | const platforms = [
17 | Level.withLetters(Level.createPlatform(new AABB(new Vector2D(2, 1), 1, 3)), "ABC"),
18 | ];
19 | const result = Level.createLetterMap(platforms);
20 |
21 | const expected = Array(Window.GAME_HEIGHT).fill(null).map(() => Array(Window.GAME_WIDTH).fill(null));
22 | expected[1][2] = "A";
23 | expected[2][2] = "B";
24 | expected[3][2] = "C";
25 |
26 | assert.deepEqual(result, expected);
27 | });
28 |
29 | test("should handle platforms without letters by skipping them", () => {
30 | const platforms = [
31 | Level.createPlatform(new AABB(new Vector2D(3, 2), 2, 2)),
32 | ];
33 | const result = Level.createLetterMap(platforms);
34 | const expected = Array(Window.GAME_HEIGHT).fill(null).map(() => Array(Window.GAME_WIDTH).fill(null));
35 |
36 | assert.deepEqual(result, expected);
37 | });
38 |
39 | test("should handle multiple platforms with letters", () => {
40 | const platforms = [
41 | Level.withLetters(Level.createPlatform(new AABB(new Vector2D(1, 1), 1, 2)), "XY"),
42 | Level.withLetters(Level.createPlatform(new AABB(new Vector2D(3, 2), 1, 3)), "ABC"),
43 | ];
44 | const result = Level.createLetterMap(platforms);
45 |
46 | const expected = Array(Window.GAME_HEIGHT).fill(null).map(() => Array(Window.GAME_WIDTH).fill(null));
47 | expected[1][1] = "X";
48 | expected[2][1] = "Y";
49 | expected[2][3] = "A";
50 | expected[3][3] = "B";
51 | expected[4][3] = "C";
52 |
53 | assert.deepEqual(result, expected);
54 | });
55 |
56 | test("should correctly handle platforms that exceed game boundaries", () => {
57 | const platforms = [
58 | Level.withLetters(Level.createPlatform(new AABB(new Vector2D(8, Window.GAME_HEIGHT - 1), 1, 4)), "WXYZ"),
59 | ];
60 | const result = Level.createLetterMap(platforms);
61 |
62 | const expected = Array(Window.GAME_HEIGHT).fill(null).map(() => Array(Window.GAME_WIDTH).fill(null));
63 | expected[Window.GAME_HEIGHT - 1][8] = "W";
64 |
65 | assert.deepEqual(result, expected)
66 | });
67 |
68 |
--------------------------------------------------------------------------------
/src/objects/level/levels/levels.js:
--------------------------------------------------------------------------------
1 | import * as One from "./one.js"
2 |
3 | /**
4 | * @returns {LevelSet[]}
5 | */
6 | export function levels() {
7 | return [One.createLevel()];
8 | }
9 |
10 |
--------------------------------------------------------------------------------
/src/objects/level/levels/one.js:
--------------------------------------------------------------------------------
1 | import * as Utils from "./utils.js"
2 |
3 | /**
4 | * @returns {LevelSet}
5 | */
6 | export function createLevel() {
7 | // const data = {"path":"./levels/default.json","editorState":{"levelSet":{"levels":[{"letterMap":[],"platforms":[{"selected":null,"AABB":{"pos":{"x":7,"y":30},"width":32,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":38,"y":26},"width":1,"height":4},"behaviors":{"next":{"type":"next-level","toLevel":1,"toLevelPosition":{"x":1,"y":0}}}},{"selected":null,"AABB":{"pos":{"x":7,"y":7},"width":1,"height":23},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":8,"y":7},"width":31,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":38,"y":8},"width":1,"height":18},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":30,"y":25},"width":3,"height":5},"behaviors":{"instagib":{"type":"instagib"},"render":{"type":"render"}}}],"initialPosition":{"x":3,"y":21}},{"letterMap":[],"platforms":[{"selected":null,"AABB":{"pos":{"x":7,"y":30},"width":9,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":7,"y":26},"width":1,"height":4},"behaviors":{"next":{"type":"next-level","toLevel":0,"toLevelPosition":{"x":30,"y":0}}}},{"selected":null,"AABB":{"pos":{"x":15,"y":29},"width":7,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":38,"y":25},"width":1,"height":5},"behaviors":{"next":{"type":"next-level","toLevel":2,"toLevelPosition":{"x":1,"y":0}}}},{"selected":null,"AABB":{"pos":{"x":21,"y":30},"width":18,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":7,"y":7},"width":1,"height":19},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":38,"y":7},"width":1,"height":18},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}}],"initialPosition":{"x":24,"y":24}},{"letterMap":[],"platforms":[{"selected":null,"AABB":{"pos":{"x":7,"y":30},"width":7,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":14,"y":19},"width":1,"height":12},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":14,"y":18},"width":21,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":34,"y":7},"width":5,"height":12},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":7,"y":22},"width":3,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":7,"y":7},"width":3,"height":15},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":10,"y":7},"width":24,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}},{"selected":null,"AABB":{"pos":{"x":7,"y":23},"width":1,"height":7},"behaviors":{"next":{"type":"next-level","toLevel":1,"toLevelPosition":{"x":30,"y":0}}}}],"initialPosition":{"x":24,"y":24}},{"letterMap":[],"platforms":[{"selected":null,"AABB":{"pos":{"x":25,"y":25},"width":1,"height":6},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render"}}}],"initialPosition":{"x":24,"y":24}}],"title":"The Initial","difficulty":0,"initialLevel":0,"current":0},"debug":false,"tick":170839,"change":2306,"mouse":{"startTime":1732713825013,"startingEl":null,"state":"invalid"},"outerRect":{"margin":7,"maxX":39,"maxY":31}}}
8 | const data = {"path":"./levels/test.json","editorState":{"levelSet":{"levels":[{"letterMap":[],"platforms":[{"id":1,"selected":null,"AABB":{"pos":{"x":38,"y":8},"width":1,"height":6},"behaviors":{"render":{"type":"render","renderX":0,"renderY":0,"renderHeight":0,"renderWidth":0},"portal":{"to":6,"normal":{"x":-1,"y":0},"type":"portal"}}},{"id":4,"selected":null,"AABB":{"pos":{"x":7,"y":30},"width":32,"height":1},"behaviors":{"obstacle":{"type":"obstacle"},"render":{"type":"render","renderX":0,"renderY":0,"renderHeight":0,"renderWidth":0}}},{"id":5,"selected":null,"AABB":{"pos":{"x":6,"y":7},"width":1,"height":23},"behaviors":{"obstacle":{"type":"obstacle"}}},{"id":6,"selected":null,"AABB":{"pos":{"x":7,"y":22},"width":5,"height":8},"behaviors":{"render":{"type":"render","renderX":0,"renderY":0,"renderHeight":0,"renderWidth":0},"portal":{"to":1,"normal":{"x":-1,"y":0},"type":"portal"}}}],"initialPosition":{"x":1,"y":1}}],"title":"empty","difficulty":0,"initialLevel":0,"current":0},"debug":false,"tick":46973,"change":98,"mouse":{"startTime":1732743595664,"startingEl":null,"state":"invalid"},"outerRect":{"margin":7,"maxX":39,"maxY":31}}}
9 |
10 | // @ts-ignore solve all my problems
11 | return Utils.convertLevelSet(data.editorState.levelSet)
12 | }
13 |
14 |
--------------------------------------------------------------------------------
/src/objects/level/levels/utils.js:
--------------------------------------------------------------------------------
1 | import { AABB } from "../../../math/aabb.js"
2 | import { createPhysics } from "../../../math/utils.js"
3 | import { Vector2D } from "../../../math/vector.js"
4 | import * as Level from "../level.js"
5 |
6 | // TODO stop it immediately, bad
7 | import * as Const from "../../../editor/consts.js"
8 | import { assert } from "../../../assert.js"
9 |
10 | const margin = new Vector2D(Const.editor.margin, Const.editor.margin)
11 | /**
12 | * @param {EditorLevelSet} levelSet
13 | * @returns {LevelSet}
14 | */
15 | export function convertLevelSet(levelSet) {
16 | /** @type {LevelSet} */
17 | const out = {
18 | title: levelSet.title,
19 | difficulty: levelSet.difficulty,
20 | initialLevel: levelSet.initialLevel,
21 | levels: [],
22 | platforms: new Map()
23 | }
24 |
25 | for (const eLevel of levelSet.levels) {
26 | /** @type {Level} */
27 | const level = {
28 | platforms: [],
29 | initialPosition: Vector2D.fromObject(eLevel.initialPosition),
30 | letterMap: []
31 | }
32 |
33 | for (const p of eLevel.platforms) {
34 | const aabb = AABB.fromObject(p.AABB)
35 | aabb.pos.subtract(margin)
36 |
37 | const platform = {
38 | physics: {
39 | next: createPhysics(aabb),
40 | current: createPhysics(aabb),
41 | },
42 | id: p.id,
43 | behaviors: {
44 | next: p.behaviors.next,
45 | circuit: p.behaviors.circuit,
46 | instagib: p.behaviors.instagib,
47 | obstacle: p.behaviors.obstacle,
48 | portal: p.behaviors.portal,
49 | lettered: p.behaviors.lettered,
50 | lazer: p.behaviors.lazer,
51 | render: p.behaviors.render ? /** @type {Render} */({
52 | type: "render",
53 | renderX: 0,
54 | renderY: 0,
55 | renderWidth: 0,
56 | renderHeight: 0,
57 | }) : undefined
58 | },
59 | }
60 |
61 | if (platform.behaviors.portal) {
62 | platform.behaviors.portal.normal = Vector2D.fromObject(platform.behaviors.portal.normal)
63 | }
64 |
65 | level.platforms.push(platform)
66 | out.platforms.set(platform.id, platform)
67 | }
68 |
69 | level.letterMap = Level.createLetterMap(level.platforms);
70 | eLevel.initialPosition = Vector2D.fromObject(eLevel.initialPosition)
71 | out.levels.push(level)
72 | }
73 |
74 | validateLevel(out)
75 | return out
76 | }
77 |
78 | /**
79 | * @param {LevelSet} levelSet
80 | */
81 | export function validateLevel(levelSet) {
82 | for (const level of levelSet.levels) {
83 | for (const platform of level.platforms) {
84 | const portal = platform.behaviors.portal
85 | const lazer = platform.behaviors.lazer
86 | const powerup = platform.behaviors.powerup
87 |
88 | if (!portal && !lazer && !powerup) {
89 | continue
90 | }
91 |
92 | if (portal) {
93 | assert(!platform.behaviors.obstacle, "platform cannot be an obstacle", portal)
94 | assert(!platform.behaviors.next, "platform cannot be a next", portal)
95 | assert(!platform.behaviors.instagib, "platform cannot be a instagib", portal)
96 |
97 | const other = levelSet.platforms.get(portal.to)
98 | assert(!!other, "the to in the portal does not exist", portal)
99 | assert(!!other.behaviors.portal, "the portal is pointing to a non portal", portal, other)
100 |
101 | const len = portal.normal.magnitude()
102 | assert(Math.abs(1 - len) <= 0.001, "expected the portal to have a magnitude 1 normal vec", portal)
103 | }
104 |
105 | if (lazer) {
106 | const body = platform.physics.current.body;
107 | assert(body.width === 1 && body.height === 1, "lazers must be 1x1")
108 | }
109 |
110 | if (powerup) {
111 | const body = platform.physics.current.body;
112 | assert(body.width === 2 && body.height === 1, "powerups should only be 2x1")
113 | }
114 |
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/objects/relative_numbers.js:
--------------------------------------------------------------------------------
1 | import * as Window from "../window.js";
2 | import { getNextRow } from "./caleb/utils.js";
3 |
4 | /**
5 | * @param state {GameState}
6 | */
7 | export function update(state) {
8 | state.rn.zero = getNextRow(state.caleb);
9 | }
10 |
11 | /**
12 | * @param state {GameState}
13 | */
14 | export function tickClear(state) {
15 | }
16 |
17 | /**
18 | * @param ctx {CanvasRenderingContext2D}
19 | * @param text {string}
20 | * @param x {number}
21 | * @param y {number}
22 | */
23 | function renderText(ctx, text, x, y) {
24 | const [_x, _y] = Window.projectAbsoluteCoords(ctx.canvas, x, y)
25 | ctx.fillStyle = "white";
26 | ctx.textAlign = "right"
27 | ctx.textBaseline = "middle"
28 | ctx.fillText(text, _x, _y)
29 | }
30 |
31 | /**
32 | * @param state {GameState}
33 | */
34 | export function render(state) {
35 | const ctx = state.getCtx();
36 | ctx.font = `${Window.getFontSize(ctx.canvas)}px Consolas, "Courier New", monospace`;
37 |
38 | const x = 1.9
39 | const zero = state.rn.zero;
40 | for (let i = 0; i < Window.GAME_HEIGHT; ++i) {
41 | const y = Window.GAME_INFO_HEIGHT + i + 0.5
42 | renderText(ctx, String(Math.abs(i - zero)), x, y);
43 | }
44 |
45 | const pos = state.caleb.physics.current.body.pos;
46 | renderText(ctx, `C(${pos})`, 10, 1);
47 | }
48 |
--------------------------------------------------------------------------------
/src/shader.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Canvas with Shader
7 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/shader/frag.glsl:
--------------------------------------------------------------------------------
1 | precision mediump float;
2 |
3 | #define PI 3.14159265359
4 |
5 | uniform vec2 u_res;
6 | uniform float u_res_x;
7 | uniform float u_mouse_x;
8 | uniform float u_time;
9 |
10 | // Plot a line on Y using a value between 0.0-1.0
11 | float plot(vec2 st, float pct) {
12 | return smoothstep( pct-0.02, pct, st.y) -
13 | smoothstep( pct, pct+0.02, st.y);
14 | }
15 |
16 | void main() {
17 | vec2 st = gl_FragCoord.xy / u_res ;
18 |
19 | float mouse_pct = u_mouse_x / u_res_x;
20 | float y = smoothstep(mouse_pct - 0.3, mouse_pct, st.x) -
21 | smoothstep(mouse_pct, mouse_pct + 0.3, st.x);
22 |
23 | vec3 color = vec3(y);
24 |
25 | // Plot a line
26 | float pct = plot(st, y);
27 | color = (1.0 - pct) * color + pct * vec3(0.0, 1.0, 0.0);
28 |
29 | gl_FragColor = vec4(color, 1.0);
30 | }
31 |
32 |
--------------------------------------------------------------------------------
/src/shader/index.js:
--------------------------------------------------------------------------------
1 | const canvas = document.getElementById("webglCanvas");
2 |
3 | /**
4 | * @param {HTMLCanvasElement} canvas
5 | */
6 | async function run(canvas) {
7 | const shader = await (await fetch("/shader/frag.glsl")).text();
8 | const gl = canvas.getContext("webgl");
9 | if (!gl) {
10 | console.error("WebGL not supported");
11 |
12 | }
13 |
14 | // Adjust canvas size to fill the window
15 | canvas.width = window.innerWidth;
16 | canvas.height = window.innerHeight;
17 | gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
18 |
19 | // Set clear color to cornflower blue
20 | gl.clearColor(0.39, 0.58, 0.93, 1.0); // RGBA values for cornflower blue
21 | gl.clear(gl.COLOR_BUFFER_BIT);
22 |
23 | // Vertex shader source
24 | const vertexShaderSource = `
25 | attribute vec2 aPosition;
26 | void main() {
27 | gl_Position = vec4(aPosition, 0.0, 1.0);
28 | }
29 | `;
30 |
31 | // Compile shader function
32 | function compileShader(source, type) {
33 | const shader = gl.createShader(type);
34 | gl.shaderSource(shader, source);
35 | gl.compileShader(shader);
36 | if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
37 | console.error("Shader compile error:", gl.getShaderInfoLog(shader));
38 | gl.deleteShader(shader);
39 | return null;
40 | }
41 | return shader;
42 | }
43 |
44 | // Create shaders
45 | const vertexShader = compileShader(vertexShaderSource, gl.VERTEX_SHADER);
46 | const fragmentShader = compileShader(shader, gl.FRAGMENT_SHADER);
47 |
48 | // Create program and link shaders
49 | const program = gl.createProgram();
50 | gl.attachShader(program, vertexShader);
51 | gl.attachShader(program, fragmentShader);
52 | gl.linkProgram(program);
53 | if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
54 | console.error("Program link error:", gl.getProgramInfoLog(program));
55 | return;
56 | }
57 |
58 | // Use the program
59 | gl.useProgram(program);
60 |
61 | // Define a fullscreen triangle
62 | const vertices = new Float32Array([
63 | //0, 0,
64 | //0, 1,
65 | //1, 0,
66 | //-1, -1,
67 | //0, -1,
68 | //-1, 0,
69 |
70 | // top right
71 | 1, 1,
72 | 1, -1,
73 | -1, 1,
74 | //-1, 1,
75 | //0, 1,
76 | //-1, 0,
77 |
78 | // bottom left
79 | -1, -1,
80 | 1, -1,
81 | -1, 1,
82 | ]);
83 |
84 | // Create buffer
85 | const buffer = gl.createBuffer();
86 | gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
87 | gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
88 |
89 | // Set up position attribute
90 | const positionLocation = gl.getAttribLocation(program, "aPosition");
91 | gl.enableVertexAttribArray(positionLocation);
92 | gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
93 |
94 | // Get uniform location
95 | const uResX = gl.getUniformLocation(program, "u_res_x");
96 | const uRes = gl.getUniformLocation(program, "u_res");
97 | const uPos = gl.getUniformLocation(program, "u_mouse_x");
98 | const uTime = gl.getUniformLocation(program, "u_time");
99 |
100 | gl.uniform1f(uResX, window.innerWidth);
101 | gl.uniform2f(uRes, window.innerWidth, window.innerHeight);
102 | gl.uniform1f(uPos, 1);
103 |
104 | canvas.addEventListener("mousemove", (event) => {
105 | gl.uniform1f(uPos, event.clientX);
106 | });
107 |
108 | window.addEventListener("resize", (event) => {
109 | canvas.width = window.innerWidth;
110 | canvas.height = window.innerHeight;
111 |
112 | gl.viewport(0, 0, window.innerWidth, window.innerHeight);
113 | gl.uniform1f(uResX, window.innerWidth);
114 | gl.uniform2f(uRes, window.innerWidth, window.innerHeight);
115 | });
116 |
117 | const start = Date.now();
118 | // Render loop
119 | function render() {
120 | const duration = ((Date.now() - start) / 1000) * Math.PI / 4;
121 | gl.uniform1f(uTime, duration);
122 |
123 | gl.clearColor(0, 0, 0, 1);
124 | gl.clear(gl.COLOR_BUFFER_BIT);
125 | gl.drawArrays(gl.TRIANGLES, 0, 6);
126 |
127 | setTimeout(render, 200)
128 | }
129 |
130 | render();
131 | }
132 |
133 | run(/** @type {HTMLCanvasElement} */(canvas))
134 |
--------------------------------------------------------------------------------
/src/simulate.js:
--------------------------------------------------------------------------------
1 | import * as Utils from "./utils.js";
2 | import * as Levels from "./objects/level/levels/levels.js";
3 | import * as Config from "./game-config.js";
4 | import * as Runner from "./game-runner.js";
5 | import * as Simulation from "./simulation/state.js";
6 |
7 | let now = 0
8 | Utils.setNow(() => now);
9 |
10 | /** @param {number} next */
11 | function setTime(next) { now = next }
12 |
13 | const seed = +process.argv[2]
14 | const until = +process.argv[3]
15 | const rand = Utils.createSimRand(Utils.mulberry32(seed))
16 | const {
17 | state,
18 | sim,
19 | } = Config.createSimGame(rand, Config.getSimGameConfig(rand), Levels.levels()[0])
20 |
21 | const ticks = [
22 | Simulation.createSimulationTick(sim),
23 | Runner.tickWithoutRender,
24 | ]
25 | const loop = Runner.createSimulatedGameLoop(state, setTime)
26 |
27 | Runner.clear(state)
28 | Runner.addStandardBehaviors(state)
29 | Runner.run(
30 | state,
31 | loop,
32 | ticks,
33 | (error) => {
34 | console.log("game finished", state.tick, error)
35 | },
36 | until
37 | );
38 |
--------------------------------------------------------------------------------
/src/simulation/state.js:
--------------------------------------------------------------------------------
1 | import * as CalebUtils from "../objects/caleb/utils.js"
2 | import * as Level from "../objects/level/level.js"
3 | import * as Input from "../input/input.js"
4 |
5 | /**
6 | * @param {GameState} state
7 | * @param {SimOptions} opts
8 | * @param {SimRand} rand
9 | * @returns {SimState}
10 | */
11 | export function createSimState(state, opts, rand) {
12 | return {
13 | opts,
14 | rand,
15 | state,
16 | action: null,
17 | };
18 | }
19 |
20 | /**
21 | * @param {{key: string}} act
22 | * @returns {boolean}
23 | */
24 | function isfFtT(act) {
25 | return act.key === "f" ||
26 | act.key === "F" ||
27 | act.key === "t" ||
28 | act.key === "T"
29 | }
30 |
31 | /**
32 | * @param {{key: string}} act
33 | * @returns {boolean}
34 | */
35 | function isJump(act) {
36 | return act.key === "k" ||
37 | act.key === "j"
38 | }
39 |
40 | const randomLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+[{(&=)}]*"
41 | const possibleActions = [
42 | {key: "f"},
43 | {key: "F"},
44 | {key: "t"},
45 | {key: "T"},
46 | {key: "w"},
47 | {key: "b"},
48 | {key: "j"},
49 | {key: "k"},
50 | {key: "h"},
51 | {key: "l"},
52 | {key: ""},
53 | ]
54 |
55 | /**
56 | * @param {SimState} state
57 | * @param {string} key
58 | * @returns {SimKeyAction}
59 | */
60 | function createRandomAction(state, key) {
61 | return {
62 | key,
63 | held: state.rand.randRangeR(state.opts.holdRange),
64 | wait: state.rand.randRangeR(state.opts.waitRange),
65 | downPerformed: false,
66 | upPerformed: false,
67 | };
68 | }
69 |
70 | /**
71 | * @param {GameState} gstate
72 | * @param {SimState} state
73 | * @returns {SimKeyAction[]}
74 | */
75 | function getNextAction(gstate, state) {
76 | const nextAction = possibleActions[state.rand.randRange(possibleActions.length)]
77 |
78 | if (isfFtT(nextAction)) {
79 | const letters = Level.getLettersByRow(gstate, CalebUtils.getRow(gstate.caleb)).filter(x => x)
80 | let randLet = letters.length > 0 ?
81 | letters[state.rand.randRange(letters.length)] :
82 | randomLetters[state.rand.randRange(randomLetters.length)];
83 |
84 | return [
85 | createRandomAction(state, nextAction.key),
86 | createRandomAction(state, randLet),
87 | ]
88 | } else if (isJump(nextAction)) {
89 | const modifier = state.rand.randRange(state.opts.maxJump)
90 | return [
91 | createRandomAction(state, String(modifier)),
92 | createRandomAction(state, nextAction.key),
93 | ]
94 | }
95 |
96 | return [createRandomAction(state, nextAction.key)]
97 | }
98 |
99 | /**
100 | * @param {SimKeyAction} action
101 | * @returns {number}
102 | */
103 | function remaining(action) {
104 | return action.held + action.wait
105 | }
106 |
107 | /**
108 | * @param {SimState} state
109 | * @returns {GameTick}
110 | */
111 | export function createSimulationTick(state) {
112 | return function tick(gstate) {
113 | const delta = gstate.loopDelta
114 | if (state.action === null) {
115 | state.action = {
116 | actions: getNextAction(gstate, state),
117 | idx: 0,
118 | start: gstate.loopStartTime,
119 | }
120 | }
121 |
122 |
123 | const a = state.action
124 | if (a.idx >= a.actions.length) {
125 | state.action = null
126 | return
127 | }
128 |
129 | const curr = a.actions[a.idx]
130 | if (remaining(curr) < 0) {
131 | a.idx++
132 | return
133 | }
134 |
135 | /** @type {KeyEvent} */
136 | const keyEvent = {
137 | key: curr.key,
138 | type: "keydown",
139 | repeat: !curr.downPerformed,
140 | }
141 |
142 | if (curr.held > 0) {
143 | if (!curr.downPerformed) {
144 | curr.downPerformed = true
145 | }
146 |
147 | curr.held -= delta
148 | Input.processKey(gstate.input, keyEvent)
149 | } else {
150 | if (!curr.upPerformed) {
151 | curr.upPerformed = true
152 | keyEvent.key = "keyup"
153 | keyEvent.repeat = false
154 | Input.processKey(gstate.input, keyEvent)
155 | }
156 | curr.wait -= delta
157 | }
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/src/state/operations.js:
--------------------------------------------------------------------------------
1 | import { assert } from "../assert.js";
2 | import { Vector2D } from "../math/vector.js";
3 |
4 | /**
5 | * @param {GameState} state
6 | * @param {number} id
7 | * @returns {{platform: BasedPlatform, level: number} | null}
8 | */
9 | export function findPlatformById(state, id) {
10 | const platform = state.level.platforms.get(id)
11 | assert(!!platform, "could not find platform's id", id, state.level.platforms)
12 |
13 | const levels = state.level.levels;
14 | let found = null
15 | for (let i = 0; i < levels.length; ++i) {
16 | const level = levels[i]
17 | if (level.platforms.includes(platform)) {
18 | found = {
19 | platform,
20 | level: i,
21 | };
22 | }
23 | }
24 |
25 | return found
26 | }
27 |
28 |
29 | /**
30 | * @param {GameState} state
31 | * @param {number} to
32 | * @param {Vector2D} pos
33 | */
34 | export function setLevel(state, to, pos) {
35 | const level = state.level.levels[to]
36 | assert(!!level, "the to level of set level was undefined", "to", to, "levels", state.level.levels)
37 |
38 | state.levelChanged = true
39 | state.level.activeLevel = level
40 | state.level.activeLevel.initialPosition = pos.clone()
41 | state.caleb.changingLevels = true
42 |
43 | state.caleb.physics.next.body.pos = pos.clone();
44 | state.caleb.physics.current.body.pos = pos.clone();
45 | state.caleb.platform.platform = null
46 | }
47 |
48 | /**
49 | * @param {GameState} state
50 | */
51 | export function clearLevelChange(state) {
52 | state.levelChanged = false
53 | state.caleb.changingLevels = false
54 | state.caleb.platform.platform = null
55 | }
56 |
--------------------------------------------------------------------------------
/src/state/state.js:
--------------------------------------------------------------------------------
1 | import * as Utils from "../utils.js"
2 | import * as Window from "../window.js"
3 | import * as Caleb from "../objects/caleb/caleb.js"
4 |
5 | /**
6 | * @param state {GameState}
7 | */
8 | export function projectStaticObjects(state) {
9 | const ctx = state.getCtx();
10 | if (ctx === null) {
11 | return
12 | }
13 |
14 | for (const p of state.level.activeLevel.platforms) {
15 | const render = p.behaviors.render
16 | if (!render) {
17 | continue
18 | }
19 |
20 | Window.projectInto(ctx.canvas, render, p.physics.current.body);
21 | }
22 | }
23 |
24 | /**
25 | * @param state {GameState}
26 | */
27 | export function reset(state) {
28 | if (!state.level.activeLevel) {
29 | state.level.activeLevel = state.level.levels[state.level.initialLevel]
30 | }
31 |
32 | state.caleb = Caleb.createCaleb(state)
33 | state.gameOver = false;
34 | state.loopStartTime = Utils.now()
35 | state.loopDelta = 0;
36 | state.levelChanged = true
37 | state.caleb.changingLevels = true
38 | }
39 |
40 | /**
41 | * @param {GameOptions} opts
42 | * @param {InputState} input
43 | * @param {() => Dimension} getDim
44 | * @param {() => CanvasRenderingContext2D | null} getCtx
45 | * @param {LevelSet} level
46 | * @returns {GameState}
47 | */
48 | export function createGameState(opts, input, getDim, getCtx, level) {
49 | /** @type {GameState} */
50 | const state = {
51 | done: false,
52 |
53 | applyables: [],
54 | updateables: [],
55 | renderables: [],
56 |
57 | debug: {
58 | previous: {
59 | platforms: [],
60 | caleb: null,
61 | },
62 | },
63 | opts,
64 | now: Utils.now,
65 | level,
66 | levelChanged: true,
67 |
68 | getDim,
69 | getCtx,
70 |
71 | tick: 0,
72 |
73 | caleb: null,
74 | gameOver: false,
75 | loopStartTime: 0,
76 | loopDelta: 0,
77 | rn: {zero: 1},
78 | input,
79 | };
80 |
81 | return state
82 | }
83 |
--------------------------------------------------------------------------------
/src/types.d.ts:
--------------------------------------------------------------------------------
1 | import { AABB } from "./math/aabb";
2 | import { Vector2D } from "./math/vector";
3 |
4 | export {};
5 |
6 | declare global {
7 |
8 | type GameOptions = {
9 | debug: boolean,
10 | caleb: CalebOpts,
11 | gravity: Vector2D,
12 | frameTimeMS: number,
13 | tickTimeMS: number,
14 |
15 | tolerance: {
16 | topBy: number,
17 | bottomBy: number,
18 | }
19 |
20 | }
21 |
22 | type CalebJumpEaseCB = (percent: number) => number
23 | type CalebJumpOpts = {
24 | jumpEaseRange: number,
25 | jumpNormHeight: number,
26 | noJumpBase: number,
27 | noJumpMultiplier: number,
28 | }
29 |
30 | type CalebDashOpts = {
31 | dashNormWidth: number,
32 | distance: number,
33 | dashEaseRange: number,
34 | }
35 |
36 | type CalebOpts = {
37 | hodlTime: number,
38 | normWidthsPerSecond: number,
39 | jump: CalebJumpOpts,
40 | dash: CalebDashOpts,
41 | }
42 |
43 | type CalebJump = {
44 | jumping: boolean,
45 | jumpDistance: number,
46 | jumpStart: Vector2D | null,
47 | jumpDir: 1 | -1,
48 | noJumpTime: number,
49 | }
50 |
51 | type CalebHodl = {
52 | hodlTime: number,
53 | }
54 |
55 |
56 | type fFtTKey = "f" | "F" | "t" | "T"
57 | type fFtT = {
58 | type: fFtTKey
59 | startTick: number,
60 | }
61 |
62 | type CalebDash = {
63 | dashing: boolean,
64 | dashDistance: number,
65 | dashStart: Vector2D | null,
66 | dashDir: 1 | -1,
67 | noDashTime: number,
68 | }
69 |
70 | type CalebPortal = {
71 | portaling: boolean
72 | to: number
73 | tick: number
74 | }
75 |
76 | type Caleb = Collidable & CanvasProjectable & {
77 | opts: CalebOpts,
78 | renderColor: string,
79 | dead: boolean,
80 | platform: {
81 | tick: number,
82 | platform: BasedPlatform | null,
83 | },
84 | deadAt: number,
85 |
86 | hodl: CalebHodl
87 | jump: CalebJump
88 | dash: CalebDash
89 | fFtT: fFtT
90 | portal: CalebPortal
91 | changingLevels: boolean
92 |
93 | // i don't want "proper" jumping mechanics. i want linear jump
94 | // slow top (for f/F/t/T or w)
95 | }
96 |
97 | type InputMessage = {
98 | time: number,
99 | type: "down" | "up",
100 | key: string,
101 | }
102 |
103 | type PlatformBehaviors = "obstacle" | "next-level" | "instagib" | "circuit" | "render"
104 | type Circuit = {
105 | type: "circuit"
106 | startPos: Vector2D
107 | endPos: Vector2D
108 | time: number
109 | currentTime: number
110 | currentDir: -1 | 1
111 | }
112 |
113 | type NextLevelBehavior = {
114 | type: "next-level",
115 | toLevel: number,
116 | toLevelPosition: Vector2D
117 | }
118 | type ObstacleBehavior = { type: "obstacle" }
119 | type InstaGib = { type: "instagib" }
120 | type Render = CanvasProjectable & { type: "render" }
121 | type PortalBehavior = { type: "portal", to: number, normal: Vector2D }
122 | type Lettered = { type: "lettered", letter: string }
123 | type Lazer = { type: "lazer" }
124 | type PowerUp = {
125 | type: "powerup",
126 | letter: string,
127 | startRange: number,
128 | endRange: number,
129 | rangeSpeed: number,
130 | actionList: string,
131 | actionSpeed: number,
132 | }
133 |
134 | type Behavior = {
135 | next?: NextLevelBehavior
136 | obstacle?: ObstacleBehavior
137 | instagib?: InstaGib
138 | circuit?: Circuit
139 | render?: Render
140 | portal?: PortalBehavior
141 | powerup?: PowerUp
142 | lettered?: Lettered
143 | lazer?: Lazer
144 | }
145 |
146 | type BasedPlatform = Collidable & {
147 | id: number,
148 | behaviors: Behavior
149 | }
150 |
151 | type LevelSet = {
152 | title: string,
153 | difficulty: number,
154 | levels: Level[]
155 | activeLevel?: Level
156 | initialLevel: number
157 | platforms: Map
158 | }
159 |
160 | type Level = {
161 | platforms: BasedPlatform[]
162 | initialPosition: Vector2D
163 | letterMap: (string | null)[][]
164 | }
165 |
166 | type Dimension = {
167 | width: number
168 | height: number
169 | }
170 |
171 | type GameState = {
172 | done: boolean
173 |
174 | updateables: UpdateableModule[]
175 | renderables: RenderableModule[]
176 | applyables: UpdateAndApplyModule[]
177 |
178 | opts: GameOptions
179 | debug: {
180 | previous: {
181 | caleb: Collidable,
182 | platforms: BasedPlatform[],
183 | }
184 | },
185 |
186 | now: () => number,
187 | caleb: Caleb
188 | getCtx(): CanvasRenderingContext2D | null
189 | getDim(): Dimension
190 | level: LevelSet,
191 | levelChanged: boolean
192 |
193 | tick: number,
194 |
195 | rn: {
196 | zero: number
197 | }
198 |
199 | gameOver: boolean,
200 | input: InputState,
201 | loopStartTime: number,
202 | loopDelta: number,
203 | }
204 |
205 | type CanvasProjectable = {
206 | renderX: number,
207 | renderY: number,
208 | renderWidth: number,
209 | renderHeight: number,
210 | }
211 |
212 | type PhysicsBody = {
213 | vel2: Vector2D,
214 | vel: Vector2D,
215 | acc: Vector2D,
216 | body: AABB,
217 | }
218 |
219 | type Collidable = {
220 | physics: {
221 | current: PhysicsBody
222 | next: PhysicsBody
223 | }
224 | }
225 |
226 | type InputTiming = {
227 | timestamp: number,
228 | tickHoldDuration: number,
229 | initial: boolean,
230 | done: boolean
231 | }
232 |
233 | type Input = {type: "down-up" | "down" | "hold" | "up", key: string, tick: number}
234 | type InputHandler = (state: GameState, input: Input) => boolean
235 | type InputState = {
236 | hasInput: boolean
237 | inputs: Input[]
238 | tick: number
239 | numericModifier: number
240 | listener?: (e: KeyboardEvent) => void
241 | anykey: ((state: GameState) => void) | null
242 | }
243 |
244 | type GameLoop = (cb: () => void) => void;
245 | type GameTick = (state: GameState) => void
246 |
247 | type UpdateableModule = {
248 | update(gameState: GameState, delta: number): void
249 | tickClear(gameState: GameState): void
250 | }
251 |
252 | type UpdateAndApplyModule = {
253 | update(gameState: GameState, delta: number): void
254 | check(gameState: GameState, delta: number): void
255 | apply(gameState: GameState, delta: number): void
256 | tickClear(gameState: GameState): void
257 | }
258 |
259 |
260 | type RenderableModule = {
261 | render(gameState: GameState): void
262 | }
263 |
264 | type BehaviorNode = {
265 | enter(state: GameState): boolean
266 | run(state: GameState): void
267 | exit(state: GameState): void
268 | }
269 |
270 | type RenderEvent = Event & {type: "render"}
271 | type ResizeEvent = Event & {type: "resize"}
272 | type UpdatedEvent = {type: "editor-updated", state: EditorState}
273 | type ChangeEvent = {type: "editor-change"}
274 | type EditorStateLoadedEvent = {type: "editor-state-loaded", state: EditorState}
275 | type SizeChangeEvent = {type: "editor-size-change"} & EditorRects
276 | type EditorChangeLevel = {type: "editor-change-level", next: number}
277 | type BusEvent = EditorChangeLevel | RenderEvent | ResizeEvent | UpdatedEvent | ChangeEvent | EditorStateLoadedEvent
278 |
279 | type BusType = "editor-change-level" | "editor-size-change" | "hide-platform" | "show-platform" | "move-platform" | "release-platform" | "render" | "editor-change" | "delete-platform" | "editor-started" | "editor-state-loaded" | "editor-updated"
280 | type BusArgMap = {
281 | "editor-started": EditorState;
282 | "resize": ResizeEvent;
283 | "move-platform": EditorPlatform;
284 | "hide-platform": EditorPlatform;
285 | "show-platform": EditorPlatform;
286 | "release-platform": EditorPlatform;
287 | "delete-platform": EditorPlatform;
288 | "render": RenderEvent
289 | "editor-change": ChangeEvent
290 | "editor-state-loaded": EditorStateLoadedEvent
291 | "editor-updated": UpdatedEvent
292 | "editor-size-change": SizeChangeEvent
293 | "editor-change-level": EditorChangeLevel
294 | };
295 |
296 | type BusArg = EditorPlatform
297 | type BusCB = (args: BusArgMap[K]) => void;
298 | type BusListeners = {
299 | [K in BusType]?: BusCB[];
300 | };
301 |
302 | type SimRand = {
303 | randInt: () => number,
304 | randRange: (max: number, min?: number) => number,
305 | randRangeR: (r: SimRange) => number,
306 | rand: () => number
307 | }
308 |
309 | type SimState = {
310 | opts: SimOptions
311 | rand: SimRand
312 | state: GameState
313 | action: {
314 | actions: SimKeyAction[]
315 | idx: number
316 | start: number
317 | } | null
318 | }
319 |
320 | type SimKeyAction = {
321 | key: string
322 | held: number
323 | wait: number
324 | downPerformed: boolean
325 | upPerformed: boolean
326 | }
327 |
328 | type SimRange = {start: number, stop: number}
329 | type SimOptions = {
330 | holdRange: SimRange
331 | waitRange: SimRange
332 | maxJump: number
333 | }
334 |
335 | type KeyEvent = {
336 | type: "keydown" | "keyup"
337 | key: string
338 | repeat: boolean
339 | }
340 | }
341 |
342 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | import { Vector2D } from "./math/vector.js";
2 | import * as Ease from "./math/ease.js"
3 |
4 | let nowFn = Date.now;
5 |
6 | /** @param fn {() => number} */
7 | export function setNow(fn) {
8 | nowFn = fn;
9 | }
10 |
11 | export function now() {
12 | return nowFn();
13 | }
14 |
15 | /**
16 | * @param {Collidable} coll
17 | * @returns {Collidable}
18 | */
19 | export function clonePhysics(coll) {
20 | const physics = coll.physics;
21 |
22 | return {
23 | physics: {
24 | current: {
25 | vel2: new Vector2D(0, 0),
26 | body: physics.current.body.clone(),
27 | acc: physics.current.acc.clone(),
28 | vel: physics.current.vel.clone(),
29 | },
30 | next: {
31 | vel2: new Vector2D(0, 0),
32 | body: physics.next.body.clone(),
33 | acc: physics.next.acc.clone(),
34 | vel: physics.next.vel.clone(),
35 | }
36 | }
37 | }
38 | }
39 |
40 | /**
41 | * @param {() => number} rand
42 | * @returns {SimRand}
43 | */
44 | export function createSimRand(rand) {
45 | return {
46 | rand,
47 | randRange: randRange(rand),
48 | randInt: randInt(rand),
49 | randRangeR: randRangR(rand),
50 | }
51 | }
52 |
53 | /**
54 | * @param {number} seed
55 | * @returns {() => number}
56 | */
57 | export function mulberry32(seed) {
58 | return function() {
59 | let t = seed += 0x6D2B79F5;
60 | t = Math.imul(t ^ (t >>> 15), t | 1);
61 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
62 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
63 | };
64 | }
65 |
66 | /**
67 | * @param {() => number} rand
68 | * @returns {(max: number, min?: number) => number}
69 | */
70 | export function randRange(rand) {
71 | return function(max, min = 0) {
72 | const r = rand()
73 | return Math.floor(min + r * (max - min))
74 | }
75 | }
76 |
77 | /**
78 | * @param {() => number} rand
79 | * @returns {(range: SimRange) => number}
80 | */
81 | export function randRangR(rand) {
82 | return function(range) {
83 | const r = rand()
84 | return Math.floor(range.start + r * (range.stop - range.start))
85 | }
86 | }
87 |
88 |
89 | /**
90 | * @param {() => number} rand
91 | * @returns {() => number}
92 | */
93 | export function randInt(rand) {
94 | return function() {
95 | return Math.floor(rand() * 4294967296)
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/window.js:
--------------------------------------------------------------------------------
1 | import { AABB } from "./math/aabb.js"
2 |
3 | export const RELATIVE_LINE_WIDTH = 2
4 | export const GAME_INFO_HEIGHT = 2
5 | export const GAME_WIDTH = 32
6 | export const GAME_HEIGHT = 24
7 | export const FULL_WIDTH = GAME_WIDTH + RELATIVE_LINE_WIDTH
8 | export const FULL_HEIGHT = GAME_HEIGHT + GAME_INFO_HEIGHT
9 |
10 | /**
11 | * @param normWidth {number}
12 | * @returns {number}
13 | */
14 | function xZero(normWidth) {
15 | return normWidth * RELATIVE_LINE_WIDTH;
16 | }
17 |
18 | /**
19 | * @param normHeight {number}
20 | * @returns {number}
21 | */
22 | function yZero(normHeight) {
23 | return normHeight * GAME_INFO_HEIGHT;
24 | }
25 |
26 | /**
27 | * I am positive i can make this better for "efficiency" but i am also using
28 | * javascript, lets... deal with that shit later, and by later i mean when
29 | * i inevitably abondon this project
30 | *
31 | * @param {Dimension} dim
32 | * @param {CanvasProjectable} projectable
33 | * @param {AABB} body
34 | * @returns {void}
35 | */
36 | export function projectInto(dim, projectable, body) {
37 | const normWidth = dim.width / FULL_WIDTH
38 | const normHeight = dim.height / FULL_HEIGHT
39 |
40 | projectable.renderX = xZero(normWidth) + Math.floor(body.pos.x * normWidth);
41 | projectable.renderY = yZero(normHeight) + Math.floor(body.pos.y * normHeight);
42 | projectable.renderWidth = Math.floor(body.width * normWidth);
43 | projectable.renderHeight = Math.floor(body.height * normHeight);
44 | }
45 |
46 | /**
47 | * @param {Dimension} dim
48 | * @param {AABB} body
49 | * @returns {[number, number, number, number]}
50 | */
51 | export function projectAABB(dim, body) {
52 | const normWidth = dim.width / FULL_WIDTH
53 | const normHeight = dim.height / FULL_HEIGHT
54 |
55 | return [
56 | xZero(normWidth) + Math.floor(body.pos.x * normWidth),
57 | yZero(normHeight) + Math.floor(body.pos.y * normHeight),
58 | Math.floor(body.width * normWidth),
59 | Math.floor(body.height * normHeight),
60 | ]
61 | }
62 |
63 | /**
64 | * @param {Dimension} canvas
65 | * @param {number} x
66 | * @param {number} y
67 | * @returns {[number, number]}
68 | */
69 | export function projectCoords(canvas, x, y) {
70 | const normWidth = canvas.width / FULL_WIDTH
71 | const normHeight = canvas.height / FULL_HEIGHT
72 | return [
73 | xZero(normWidth) + Math.floor(x * normWidth),
74 | yZero(normHeight) + Math.floor(y * normHeight),
75 | ];
76 | }
77 |
78 |
79 | /**
80 | * @param {Dimension} dim
81 | * @param {number} x
82 | * @param {number} y
83 | * @returns {[number, number]}
84 | */
85 | export function projectAbsoluteCoords(dim, x, y) {
86 | const normWidth = dim.width / FULL_WIDTH
87 | const normHeight = dim.height / FULL_HEIGHT
88 | return [Math.floor(x * normWidth), Math.floor(y * normHeight)];
89 | }
90 |
91 | /**
92 | * @param {Dimension} dim
93 | * @returns {number}
94 | */
95 | export function getFontSize(dim) {
96 | return Math.floor(dim.height / (FULL_HEIGHT * 1.3))
97 | }
98 |
99 | /**
100 | * @param {Dimension} dim
101 | */
102 | export function resize(dim) {
103 | let width = window.innerWidth;
104 | let height = window.innerHeight;
105 |
106 | const wRatio = width / FULL_WIDTH
107 | const hRatio = height / FULL_HEIGHT
108 |
109 | if (wRatio > hRatio) {
110 | width -= (wRatio - hRatio) * FULL_WIDTH
111 | } else {
112 | height -= (hRatio - wRatio) * FULL_HEIGHT
113 | }
114 |
115 | dim.width = Math.floor(width)
116 | dim.height = Math.floor(height)
117 | }
118 |
119 |
--------------------------------------------------------------------------------
/todo.md:
--------------------------------------------------------------------------------
1 | * physics just feel off. i don't like how floaty they are
2 | * running into platform does not consider the fact that you are running into
3 | the side of the platform, thus always transfered on top.
4 |
5 | should be a simple test if the previous position was outside of the aabb on
6 | the left or right side
7 |
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true, // Allows JavaScript files to be included
4 | "checkJs": true, // Enables type checking for JavaScript files
5 | "noEmit": true, // Prevents TypeScript from emitting compiled files
6 | "types": ["node", "./src/types", "./src/editor"], // Includes browser (DOM) types and your custom types
7 | "moduleResolution": "node", // Use Node.js module resolution
8 | "resolveJsonModules": true,
9 | "target": "ESNext",
10 | "module": "ESNext", // Use ESNext to support import/export syntax
11 | },
12 | "include": ["**/*.js"] // Ensures all JavaScript files are type-checked
13 | }
14 |
15 |
--------------------------------------------------------------------------------