├── .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 |
163 |
164 |
165 |
166 | 214 | 240 | 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 | --------------------------------------------------------------------------------