From 68c038d89bfa9f1abaf04f87d15d76df35f09b90 Mon Sep 17 00:00:00 2001 From: Marc Flerackers Date: Fri, 24 May 2024 07:37:33 +0900 Subject: [PATCH] high level raycasting functions --- examples/mazeRaycastedLight.js | 151 +++++++++++++++++++++ examples/raycastObject.js | 201 ++++++++++++++++++++++++++++ examples/transformShape.js | 232 +++++++++++++++++++++++++++++++++ src/kaboom.ts | 121 ++++++++++++----- src/math.ts | 90 +++++++++++-- src/types.ts | 7 + 6 files changed, 760 insertions(+), 42 deletions(-) create mode 100644 examples/mazeRaycastedLight.js create mode 100644 examples/raycastObject.js create mode 100644 examples/transformShape.js diff --git a/examples/mazeRaycastedLight.js b/examples/mazeRaycastedLight.js new file mode 100644 index 00000000..c246067c --- /dev/null +++ b/examples/mazeRaycastedLight.js @@ -0,0 +1,151 @@ +kaboom({ + scale: 0.5, + background: [0, 0, 0], +}); + +loadSprite("bean", "sprites/bean.png"); +loadSprite("steel", "sprites/steel.png"); + +const TILE_WIDTH = 64; +const TILE_HEIGHT = TILE_WIDTH; + +function createMazeMap(width, height) { + const size = width * height; + function getUnvisitedNeighbours(map, index) { + const n = []; + const x = Math.floor(index / width); + if (x > 1 && map[index - 2] === 2) n.push(index - 2); + if (x < width - 2 && map[index + 2] === 2) n.push(index + 2); + if (index >= 2 * width && map[index - 2 * width] === 2) { + n.push(index - 2 * width); + } + if (index < size - 2 * width && map[index + 2 * width] === 2) { + n.push(index + 2 * width); + } + return n; + } + const map = new Array(size).fill(1, 0, size); + map.forEach((_, index) => { + const x = Math.floor(index / width); + const y = Math.floor(index % width); + if ((x & 1) === 1 && (y & 1) === 1) { + map[index] = 2; + } + }); + + const stack = []; + const startX = Math.floor(Math.random() * (width - 1)) | 1; + const startY = Math.floor(Math.random() * (height - 1)) | 1; + const start = startX + startY * width; + map[start] = 0; + stack.push(start); + while (stack.length) { + const index = stack.pop(); + const neighbours = getUnvisitedNeighbours(map, index); + if (neighbours.length > 0) { + stack.push(index); + const neighbour = + neighbours[Math.floor(neighbours.length * Math.random())]; + const between = (index + neighbour) / 2; + map[neighbour] = 0; + map[between] = 0; + stack.push(neighbour); + } + } + return map; +} + +function createMazeLevelMap(width, height, options) { + const symbols = options?.symbols || {}; + const map = createMazeMap(width, height); + const space = symbols[" "] || " "; + const fence = symbols["#"] || "#"; + const detail = [ + space, + symbols["╸"] || "╸", // 1 + symbols["╹"] || "╹", // 2 + symbols["┛"] || "┛", // 3 + symbols["╺"] || "╺", // 4 + symbols["━"] || "━", // 5 + symbols["┗"] || "┗", // 6 + symbols["┻"] || "┻", // 7 + symbols["╻"] || "╻", // 8 + symbols["┓"] || "┓", // 9 + symbols["┃"] || "┃", // a + symbols["┫"] || "┫", // b + symbols["┏"] || "┏", // c + symbols["┳"] || "┳", // d + symbols["┣"] || "┣", // e + symbols["╋ "] || "╋ ", // f + ]; + const symbolMap = options?.detailed + ? map.map((s, index) => { + if (s === 0) return space; + const x = Math.floor(index % width); + const leftWall = x > 0 && map[index - 1] == 1 ? 1 : 0; + const rightWall = x < width - 1 && map[index + 1] == 1 ? 4 : 0; + const topWall = index >= width && map[index - width] == 1 ? 2 : 0; + const bottomWall = + index < height * width - width && map[index + width] == 1 + ? 8 + : 0; + return detail[leftWall | rightWall | topWall | bottomWall]; + }) + : map.map((s) => { + return s == 1 ? fence : space; + }); + const levelMap = []; + for (let i = 0; i < height; i++) { + levelMap.push(symbolMap.slice(i * width, i * width + width).join("")); + } + return levelMap; +} + +const level = addLevel( + createMazeLevelMap(15, 15, {}), + { + tileWidth: TILE_WIDTH, + tileHeight: TILE_HEIGHT, + tiles: { + "#": () => [ + sprite("steel"), + tile({ isObstacle: true }), + ], + }, + }, +); + +const bean = level.spawn( + [ + sprite("bean"), + anchor("center"), + pos(32, 32), + tile(), + agent({ speed: 640, allowDiagonals: true }), + "bean", + ], + 1, + 1, +); + +onClick(() => { + const pos = mousePos(); + bean.setTarget(vec2( + Math.floor(pos.x / TILE_WIDTH) * TILE_WIDTH + TILE_WIDTH / 2, + Math.floor(pos.y / TILE_HEIGHT) * TILE_HEIGHT + TILE_HEIGHT / 2, + )); +}); + +onUpdate(() => { + const pts = [bean.pos]; + // This is overkill, since you theoretically only need to shoot rays to grid positions + for (let i = 0; i < 360; i += 1) { + const hit = level.raycast(bean.pos, Vec2.fromAngle(i)); + pts.push(hit.point); + } + pts.push(pts[1]); + drawPolygon({ + pts: pts, + color: rgb(255, 255, 100), + }); +}); diff --git a/examples/raycastObject.js b/examples/raycastObject.js new file mode 100644 index 00000000..59b758c1 --- /dev/null +++ b/examples/raycastObject.js @@ -0,0 +1,201 @@ +kaboom(); + +add([ + pos(80, 80), + circle(40), + color(BLUE), + area(), +]); + +add([ + pos(180, 210), + circle(20), + color(BLUE), + area(), +]); + +add([ + pos(40, 180), + rect(20, 40), + color(BLUE), + area(), +]); + +add([ + pos(140, 130), + rect(60, 50), + color(BLUE), + area(), +]); + +add([ + pos(180, 40), + polygon([vec2(-60, 60), vec2(0, 0), vec2(60, 60)]), + color(BLUE), + area(), +]); + +add([ + pos(280, 130), + polygon([vec2(-20, 20), vec2(0, 0), vec2(20, 20)]), + color(BLUE), + area(), +]); + +onUpdate(() => { + const shapes = get("shape"); + shapes.forEach(s1 => { + if ( + shapes.some(s2 => + s1 !== s2 && s1.getShape().collides(s2.getShape()) + ) + ) { + s1.color = RED; + } else { + s1.color = BLUE; + } + }); +}); + +onDraw("selected", (s) => { + const bbox = s.worldArea().bbox(); + drawRect({ + pos: bbox.pos.sub(s.pos), + width: bbox.width, + height: bbox.height, + outline: { + color: YELLOW, + width: 1, + }, + fill: false, + }); +}); + +onMousePress(() => { + const shapes = get("area"); + const pos = mousePos(); + const pickList = shapes.filter((shape) => shape.hasPoint(pos)); + selection = pickList[pickList.length - 1]; + if (selection) { + get("selected").forEach(s => s.unuse("selected")); + selection.use("selected"); + } +}); + +onMouseMove((pos, delta) => { + get("selected").forEach(sel => { + sel.moveBy(delta); + }); + get("turn").forEach(laser => { + const oldVec = mousePos().sub(delta).sub(laser.pos); + const newVec = mousePos().sub(laser.pos); + laser.angle += oldVec.angleBetween(newVec); + }); +}); + +onMouseRelease(() => { + get("selected").forEach(s => s.unuse("selected")); + get("turn").forEach(s => s.unuse("turn")); +}); + +function laser() { + return { + draw() { + drawTriangle({ + p1: vec2(-16, -16), + p2: vec2(16, 0), + p3: vec2(-16, 16), + pos: vec2(0, 0), + color: this.color, + }); + if (this.showRing || this.is("turn")) { + drawCircle({ + pos: vec2(0, 0), + radius: 28, + outline: { + color: RED, + width: 4, + }, + fill: false, + }); + } + pushTransform(); + pushRotate(-this.angle); + const MAX_TRACE_DEPTH = 3; + const MAX_DISTANCE = 400; + let origin = this.pos; + let direction = Vec2.fromAngle(this.angle).scale(MAX_DISTANCE); + let traceDepth = 0; + while (traceDepth < MAX_TRACE_DEPTH) { + const hit = raycast(origin, direction, ["laser"]); + if (!hit) { + drawLine({ + p1: origin.sub(this.pos), + p2: origin.add(direction).sub(this.pos), + width: 1, + color: this.color, + }); + break; + } + const pos = hit.point.sub(this.pos); + // Draw hit point + drawCircle({ + pos: pos, + radius: 4, + color: this.color, + }); + // Draw hit normal + drawLine({ + p1: pos, + p2: pos.add(hit.normal.scale(20)), + width: 1, + color: BLUE, + }); + // Draw hit distance + drawLine({ + p1: origin.sub(this.pos), + p2: pos, + width: 1, + color: this.color, + }); + // Offset the point slightly, otherwise it might be too close to the surface + // and give internal reflections + origin = hit.point.add(hit.normal.scale(0.001)); + // Reflect vector + direction = direction.reflect(hit.normal); + traceDepth++; + } + popTransform(); + }, + showRing: false, + }; +} + +const ray = add([ + pos(150, 270), + rotate(-45), + anchor("center"), + rect(64, 64), + area(), + laser(0), + color(RED), + opacity(0.0), + "laser", +]); + +get("laser").forEach(laser => { + laser.onHover(() => { + laser.showRing = true; + }); + laser.onHoverEnd(() => { + laser.showRing = false; + }); + laser.onClick(() => { + get("selected").forEach(s => s.unuse("selected")); + if (laser.pos.sub(mousePos()).slen() > 28 * 28) { + laser.use("turn"); + } else { + laser.use("selected"); + } + }); +}); diff --git a/examples/transformShape.js b/examples/transformShape.js new file mode 100644 index 00000000..194a608c --- /dev/null +++ b/examples/transformShape.js @@ -0,0 +1,232 @@ +kaboom(); + +add([ + pos(80, 80), + circle(40), + color(BLUE), + "shape", + { + getShape() { + return new Circle(vec2(), this.radius); + }, + }, +]); + +add([ + pos(180, 210), + circle(20), + color(BLUE), + "shape", + { + getShape() { + return new Circle(vec2(), this.radius); + }, + }, +]); + +add([ + pos(40, 180), + rect(20, 40), + color(BLUE), + "shape", + { + getShape() { + return new Rect(vec2(), this.width, this.height); + }, + }, +]); + +add([ + pos(140, 130), + rect(60, 50), + color(BLUE), + "shape", + { + getShape() { + return new Rect(vec2(), this.width, this.height); + }, + }, +]); + +add([ + pos(190, 40), + rotate(45), + polygon([vec2(-60, 60), vec2(0, 0), vec2(60, 60)]), + color(BLUE), + "shape", + { + getShape() { + return new Polygon(this.pts); + }, + }, +]); + +add([ + pos(280, 130), + polygon([vec2(-20, 20), vec2(0, 0), vec2(20, 20)]), + color(BLUE), + "shape", + { + getShape() { + return new Polygon(this.pts); + }, + }, +]); + +add([ + pos(280, 80), + color(BLUE), + "shape", + { + draw() { + drawLine({ + p1: vec2(30, 0), + p2: vec2(0, 30), + width: 4, + color: this.color, + }); + }, + getShape() { + return new Line( + vec2(30, 0).add(this.pos), + vec2(0, 30).add(this.pos), + ); + }, + }, +]); + +add([ + pos(260, 80), + color(BLUE), + rotate(45), + rect(30, 60), + "shape", + { + getShape() { + return new Rect(vec2(0, 0), 30, 60); + }, + }, +]); + +add([ + pos(280, 200), + color(BLUE), + "shape", + { + getShape() { + return new Ellipse(vec2(), 80, 30); + }, + draw() { + drawEllipse({ + radiusX: 80, + radiusY: 30, + color: this.color, + }); + }, + }, +]); + +add([ + pos(340, 120), + color(BLUE), + "shape", + { + getShape() { + return new Ellipse(vec2(), 40, 15, 45); + }, + draw() { + pushRotate(45); + drawEllipse({ + radiusX: 40, + radiusY: 15, + color: this.color, + }); + popTransform(); + }, + }, +]); + +function getGlobalShape(s) { + const t = s.transform; + return s.getShape().transform(t); +} + +onUpdate(() => { + const shapes = get("shape"); + shapes.forEach(s1 => { + if ( + shapes.some(s2 => + s1 !== s2 && getGlobalShape(s1).collides(getGlobalShape(s2)) + ) + ) { + s1.color = RED; + } else { + s1.color = BLUE; + } + }); +}); + +onDraw(() => { + const shapes = get("shape"); + shapes.forEach(s => { + const shape = getGlobalShape(s); + // console.log(tshape) + switch (shape.constructor.name) { + case "Ellipse": + pushTransform(); + pushTranslate(shape.center); + pushRotate(shape.angle); + drawEllipse({ + pos: vec2(), + radiusX: shape.radiusX, + radiusY: shape.radiusY, + fill: false, + outline: { + width: 4, + color: rgb(255, 255, 0), + }, + }); + popTransform(); + break; + case "Polygon": + drawPolygon({ + pts: shape.pts, + fill: false, + outline: { + width: 4, + color: rgb(255, 255, 0), + }, + }); + break; + } + }); +}); + +onMousePress(() => { + const shapes = get("shape"); + const pos = mousePos(); + const pickList = shapes.filter((shape) => + getGlobalShape(shape).contains(pos) + ); + selection = pickList[pickList.length - 1]; + if (selection) { + get("selected").forEach(s => s.unuse("selected")); + selection.use("selected"); + } +}); + +onMouseMove((pos, delta) => { + get("selected").forEach(sel => { + sel.moveBy(delta); + }); + get("turn").forEach(laser => { + const oldVec = mousePos().sub(delta).sub(laser.pos); + const newVec = mousePos().sub(laser.pos); + laser.angle += oldVec.angleBetween(newVec); + }); +}); + +onMouseRelease(() => { + get("selected").forEach(s => s.unuse("selected")); + get("turn").forEach(s => s.unuse("turn")); +}); diff --git a/src/kaboom.ts b/src/kaboom.ts index 9d9621e1..d76094c0 100644 --- a/src/kaboom.ts +++ b/src/kaboom.ts @@ -13,36 +13,37 @@ import { } from "./assets"; import { - MAX_TEXT_CACHE_SIZE, - DEF_VERT, - DEF_FRAG, - VERTEX_FORMAT, - MAX_BATCHED_VERTS, - MAX_BATCHED_INDICES, - SPRITE_ATLAS_WIDTH, - SPRITE_ATLAS_HEIGHT, - DEF_FONT_FILTER, - DEF_TEXT_CACHE_SIZE, ASCII_CHARS, - DEF_FONT, - VERT_TEMPLATE, - FRAG_TEMPLATE, BG_GRID_SIZE, - DEF_ANCHOR, - UV_PAD, - FONT_ATLAS_WIDTH, - FONT_ATLAS_HEIGHT, - LOG_MAX, COMP_DESC, + COMP_DESC, COMP_EVENTS, - DEF_TEXT_SIZE, - DEF_HASH_GRID_SIZE, DBG_FONT, - LOG_TIME, - TEXT_STYLE_RE, - DEF_OFFSCREEN_DIS, + DEF_ANCHOR, + DEF_FONT, + DEF_FONT_FILTER, + DEF_FRAG, + DEF_HASH_GRID_SIZE, DEF_JUMP_FORCE, + DEF_OFFSCREEN_DIS, + DEF_TEXT_CACHE_SIZE, + DEF_TEXT_SIZE, + DEF_VERT, + FONT_ATLAS_HEIGHT, + FONT_ATLAS_WIDTH, + FRAG_TEMPLATE, + LOG_MAX, + LOG_TIME, + MAX_BATCHED_INDICES, + MAX_BATCHED_VERTS, + MAX_TEXT_CACHE_SIZE, MAX_VEL, -} from "./constants" + SPRITE_ATLAS_HEIGHT, + SPRITE_ATLAS_WIDTH, + TEXT_STYLE_RE, + UV_PAD, + VERT_TEMPLATE, + VERTEX_FORMAT, +} from "./constants"; import { chance, @@ -67,6 +68,8 @@ import { rand, randi, randSeed, + raycastGrid, + RaycastHit as BaseRaycastHit, Rect, rgb, RNG, @@ -288,7 +291,8 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { } // create a if user didn't provide one - const canvas = gopt.canvas ?? root.appendChild(document.createElement("canvas")) + const canvas = gopt.canvas + ?? root.appendChild(document.createElement("canvas")); // global pixel scale const gscale = gopt.scale ?? 1; @@ -3694,7 +3698,11 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { return col && col.hasOverlap(); }, - onClick(this: GameObj, f: () => void, btn: MouseButton = "left"): EventController { + onClick( + this: GameObj, + f: () => void, + btn: MouseButton = "left", + ): EventController { const e = app.onMousePress(btn, () => { if (this.isHovering()) { f(); @@ -4762,12 +4770,18 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { const fade = opt.fade ?? 0; return { id: "lifespan", - require: [ "opacity" ], + require: ["opacity"], async add(this: GameObj) { await wait(time); - this.opacity = this.opacity ?? 1 + this.opacity = this.opacity ?? 1; if (fade > 0) { - await tween(this.opacity, 0, fade, (a) => this.opacity = a, easings.linear); + await tween( + this.opacity, + 0, + fade, + (a) => this.opacity = a, + easings.linear, + ); } this.destroy(); }, @@ -5481,6 +5495,26 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { return spatialMap[hash] || []; }, + raycast(origin: Vec2, direction: Vec2) { + origin = origin.scale( + 1 / this.tileWidth(), + 1 / this.tileHeight(), + ); + const hit = raycastGrid(origin, direction, (tilePos: Vec2) => { + const tiles = this.getAt(tilePos); + if (tiles.some(t => t.isObstacle)) { + return true; + } + }, 64); + if (hit) { + hit.point = hit.point.scale( + this.tileWidth(), + this.tileHeight(), + ); + } + return hit; + }, + update() { if (spatialMap) { updateSpatialMap(); @@ -5760,6 +5794,34 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { }; } + type RaycastHit = BaseRaycastHit & { + object?: GameObj; + }; + + type RaycastResult = RaycastHit | null; + + function raycast(origin: Vec2, direction: Vec2, exclude?: string[]) { + let minHit: RaycastResult; + const shapes = get("area"); + shapes.forEach(s => { + if (exclude && exclude.some(tag => s.is(tag))) return; + const shape = s.worldArea(); + const hit = shape.raycast(origin, direction); + if (hit) { + if (minHit) { + if (hit.fraction < minHit.fraction) { + minHit = hit; + minHit.object = s; + } + } else { + minHit = hit; + minHit.object = s; + } + } + }); + return minHit; + } + function record(frameRate?): Recording { const stream = app.canvas.captureStream(frameRate); const audioDest = audio.ctx.createMediaStreamDestination(); @@ -6680,6 +6742,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { drawon, tile, agent, + raycast, // group events on, onUpdate, diff --git a/src/math.ts b/src/math.ts index d4a4c080..2cc10da6 100644 --- a/src/math.ts +++ b/src/math.ts @@ -694,6 +694,25 @@ class Mat3 { - this.m12 * this.m21 * this.m33 - this.m11 * this.m23 * this.m32; } + rotate(radians) { + const c = Math.cos(radians); + const s = Math.sin(radians); + const oldA = this.m11; + const oldB = this.m12; + this.m11 = c * this.m11 + s * this.m21; + this.m12 = c * this.m12 + s * this.m22; + this.m21 = c * this.m21 - s * oldA; + this.m22 = c * this.m22 - s * oldB; + return this; + } + scale(x, y) { + this.m11 *= x; + this.m12 *= x; + this.m21 *= y; + this.m22 *= y; + return this; + } + get inverse(): Mat3 { const det = this.det; return new Mat3( @@ -1725,6 +1744,7 @@ export type RaycastHit = { fraction: number; normal: Vec2; point: Vec2; + gridPos?: Vec2; }; export type RaycastResult = RaycastHit | null; @@ -1911,6 +1931,59 @@ function raycastEllipse( return result; } +export function raycastGrid( + origin: Vec2, + direction: Vec2, + gridPosHit: (gridPos: Vec2) => boolean, + maxDistance: number = 64, +): RaycastResult | null { + const pos = origin; + const len = direction.len(); + const dir = direction.scale(1 / len); + let t = 0; + const gridPos = vec2(Math.floor(origin.x), Math.floor(origin.y)); + const step = vec2(dir.x > 0 ? 1 : -1, dir.y > 0 ? 1 : -1); + const tDelta = vec2(Math.abs(1 / dir.x), Math.abs(1 / dir.y)); + const dist = vec2( + (step.x > 0) ? (gridPos.x + 1 - origin.x) : (origin.x - gridPos.x), + (step.y > 0) ? (gridPos.y + 1 - origin.y) : (origin.y - gridPos.y), + ); + const tMax = vec2( + (tDelta.x < Infinity) ? tDelta.x * dist.x : Infinity, + (tDelta.y < Infinity) ? tDelta.y * dist.y : Infinity, + ); + let steppedIndex = -1; + while (t <= maxDistance) { + const hit = gridPosHit(gridPos); + if (hit === true) { + return { + point: pos.add(dir.scale(t)), + normal: vec2( + steppedIndex === 0 ? -step.x : 0, + steppedIndex === 1 ? -step.y : 0, + ), + fraction: t / len, // Since dir is normalized, t is len times too large + gridPos, + }; + } else if (hit) { + return hit; + } + if (tMax.x < tMax.y) { + gridPos.x += step.x; + t = tMax.x; + tMax.x += tDelta.x; + steppedIndex = 0; + } else { + gridPos.y += step.y; + t = tMax.y; + tMax.y += tDelta.y; + steppedIndex = 1; + } + } + + return null; +} + export class Line { p1: Vec2; p2: Vec2; @@ -2071,7 +2144,7 @@ export class Ellipse { } } toMat2(): Mat2 { - const a = deg2rad(-this.angle); + const a = deg2rad(this.angle); const c = Math.cos(a); const s = Math.sin(a); return new Mat2( @@ -2095,18 +2168,9 @@ export class Ellipse { // Get the transformation which maps the unit circle onto the ellipse let T = this.toMat2(); // Transform the transformation matrix with the rotation+scale matrix - const RS = new Mat3( - tr.m[0], - tr.m[1], - 0, - tr.m[4], - tr.m[5], - 0, - tr.m[12], - tr.m[13], - 1, - ); - const M = RS.transpose.mul(Mat3.fromMat2(T)).mul(RS); + const angle = tr.getRotation(); + const scale = tr.getScale(); + const M = Mat3.fromMat2(T).scale(scale.x, scale.y).rotate(angle); T = M.toMat2(); // Return the ellipse made from the transformed unit circle const ellipse = Ellipse.fromMat2(T); diff --git a/src/types.ts b/src/types.ts index 5066c9c2..ccdfd6c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -693,6 +693,7 @@ export interface KaboomCtx { * @since v3000.0 */ agent(opt?: AgentCompOpt): AgentComp; + raycast(origin: Vec2, direction: Vec2, exclude?: string[]): RaycastResult; /** * Register an event on all game objs with certain tag. * @@ -4293,6 +4294,8 @@ export type RaycastHit = { fraction: number; normal: Vec2; point: Vec2; + gridPos?: Vec2; + object?: GameObj; }; export type RaycastResult = RaycastHit | null; @@ -5597,6 +5600,10 @@ export interface LevelComp extends Comp { * Get all game objects that's currently inside a given tile. */ getAt(tilePos: Vec2): GameObj[]; + /** + * Raycast all game objects on the given path. + */ + raycast(origin: Vec2, direction: Vec2): RaycastResult; /** * Convert tile position to pixel position. */