diff --git a/examples/mazeRaycastedLight.js b/examples/mazeRaycastedLight.js new file mode 100644 index 00000000..15143ca7 --- /dev/null +++ b/examples/mazeRaycastedLight.js @@ -0,0 +1,137 @@ +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), + }) +}) \ No newline at end of file diff --git a/examples/raycastObject.js b/examples/raycastObject.js new file mode 100644 index 00000000..9674ba5a --- /dev/null +++ b/examples/raycastObject.js @@ -0,0 +1,199 @@ +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..7c20412f --- /dev/null +++ b/examples/transformShape.js @@ -0,0 +1,224 @@ +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")) +}) \ No newline at end of file diff --git a/src/kaboom.ts b/src/kaboom.ts index 32652749..21040dbd 100644 --- a/src/kaboom.ts +++ b/src/kaboom.ts @@ -56,6 +56,8 @@ import { deg2rad, rad2deg, evaluateBezier, + RaycastHit as BaseRaycastHit, + raycastGrid, } from "./math" import easings from "./easings" @@ -5364,6 +5366,20 @@ 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() @@ -5607,6 +5623,35 @@ 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) @@ -6544,6 +6589,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 341f58e7..57eaf697 100644 --- a/src/math.ts +++ b/src/math.ts @@ -624,6 +624,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( @@ -1532,6 +1551,7 @@ export type RaycastHit = { fraction: number normal: Vec2 point: Vec2 + gridPos?: Vec2 } export type RaycastResult = RaycastHit | null @@ -1706,6 +1726,49 @@ function raycastEllipse(origin: Vec2, direction: Vec2, ellipse: Ellipse): Raycas 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 @@ -1854,7 +1917,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( @@ -1876,8 +1939,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) @@ -1894,7 +1958,7 @@ export class Ellipse { ) } else { - // Rotation. We need to find the maximum x and y distance from the + // Rotation. We need to find the maximum x and y distance from the // center of the rotated ellipse const angle = deg2rad(this.angle) const c = Math.cos(angle) diff --git a/src/types.ts b/src/types.ts index b2da58d6..5da948e7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -690,6 +690,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. * @@ -4129,6 +4130,8 @@ export type RaycastHit = { fraction: number normal: Vec2 point: Vec2 + gridPos?: Vec2 + object?: GameObj } export type RaycastResult = RaycastHit | null @@ -5421,6 +5424,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. */