diff --git a/examples/overlap.js b/examples/overlap.js index 4c1a5fe8..8816b802 100644 --- a/examples/overlap.js +++ b/examples/overlap.js @@ -105,12 +105,50 @@ add([ }) }, getShape() { - // This would be point if we had a real class for it + // This would be point if we had a real class for it return new Rect(vec2(-1, -1).add(this.pos), 3, 3) }, }, ]) +add([ + pos(280, 200), + color(BLUE), + "shape", + { + getShape() { + return new Ellipse(this.pos, 80, 30) + }, + draw() { + drawEllipse({ + radiusX: 80, + radiusY: 30, + color: this.color, + }) + }, + }, +]) + +add([ + pos(340, 120), + color(BLUE), + "shape", + { + getShape() { + return new Ellipse(this.pos, 40, 15, 45) + }, + draw() { + pushRotate(45) + drawEllipse({ + radiusX: 40, + radiusY: 15, + color: this.color, + }) + popTransform() + }, + }, +]) + onUpdate(() => { const shapes = get("shape") shapes.forEach(s1 => { @@ -141,3 +179,19 @@ onMouseMove((pos, delta) => { onMouseRelease(() => { selection = null }) + +onDraw(() => { + if (selection) { + const rect = selection.getShape().bbox() + drawRect({ + pos: rect.pos, + width: rect.width, + height: rect.height, + outline: { + width: 1, + color: YELLOW, + }, + fill: false, + }) + } +}) \ No newline at end of file diff --git a/examples/raycastShape.js b/examples/raycastShape.js index 048aa30f..47750ea5 100644 --- a/examples/raycastShape.js +++ b/examples/raycastShape.js @@ -105,12 +105,50 @@ add([ }) }, getShape() { - // This would be point if we had a real class for it + // This would be point if we had a real class for it return new Rect(vec2(-1, -1).add(this.pos), 3, 3) }, }, ]) +add([ + pos(280, 200), + color(BLUE), + "shape", + { + getShape() { + return new Ellipse(this.pos, 80, 30) + }, + draw() { + drawEllipse({ + radiusX: 80, + radiusY: 30, + color: this.color, + }) + }, + }, +]) + +add([ + pos(340, 120), + color(BLUE), + "shape", + { + getShape() { + return new Ellipse(this.pos, 40, 15, 45) + }, + draw() { + pushRotate(45) + drawEllipse({ + radiusX: 40, + radiusY: 15, + color: this.color, + }) + popTransform() + }, + }, +]) + function rayCastShapes(origin, direction) { let minHit const shapes = get("shape") @@ -210,30 +248,30 @@ function laser() { break } const pos = hit.point.sub(this.pos) - // Draw hit point + // Draw hit point drawCircle({ pos: pos, radius: 4, color: this.color, }) - // Draw hit normal + // Draw hit normal drawLine({ p1: pos, p2: pos.add(hit.normal.scale(20)), width: 1, color: BLUE, }) - // Draw hit distance + // 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 + // 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 + // Reflect vector direction = direction.reflect(hit.normal) traceDepth++ } diff --git a/src/kaboom.ts b/src/kaboom.ts index ab66d72b..f34a7c63 100644 --- a/src/kaboom.ts +++ b/src/kaboom.ts @@ -24,6 +24,7 @@ import { Polygon, Line, Circle, + Ellipse, Color, Vec2, Mat4, @@ -448,7 +449,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { // we use a texture for those so we can use only 1 pipeline for drawing sprites + shapes const emptyTex = Texture.fromImage( ggl, - new ImageData(new Uint8ClampedArray([ 255, 255, 255, 255 ]), 1, 1), + new ImageData(new Uint8ClampedArray([255, 255, 255, 255]), 1, 1), ) const frameBuffer = (gopt.width && gopt.height) @@ -532,7 +533,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { class SpriteData { tex: Texture - frames: Quad[] = [ new Quad(0, 0, 1, 1) ] + frames: Quad[] = [new Quad(0, 0, 1, 1)] anims: SpriteAnims = {} slice9: NineSlice | null = null @@ -1148,8 +1149,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { | BitmapFontData | Asset | string - | void - { + | void { if (!src) { return resolveFont(gopt.font ?? DEF_FONT) } @@ -2171,7 +2171,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { if (opt.outline) { drawLines({ - pts: [ ...opt.pts, opt.pts[0] ], + pts: [...opt.pts, opt.pts[0]], radius: opt.radius, width: opt.outline.width, color: opt.outline.color, @@ -3438,7 +3438,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { function follow(obj: GameObj, offset?: Vec2): FollowComp { return { id: "follow", - require: [ "pos" ], + require: ["pos"], follow: { obj: obj, offset: offset ?? vec2(0), @@ -3460,7 +3460,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { const d = typeof dir === "number" ? Vec2.fromAngle(dir) : dir.unit() return { id: "move", - require: [ "pos" ], + require: ["pos"], update(this: GameObj) { this.move(d.scale(speed)) }, @@ -3474,7 +3474,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { let isOut = false return { id: "offscreen", - require: [ "pos" ], + require: ["pos"], isOffScreen(this: GameObj): boolean { const pos = this.screenPos() const screenRect = new Rect(vec2(0), width(), height()) @@ -3852,24 +3852,24 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { const h2 = 1 - h1 - h3 const quads = [ // uv - quad(0, 0, w1, h1), - quad(w1, 0, w2, h1), - quad(w1 + w2, 0, w3, h1), - quad(0, h1, w1, h2), - quad(w1, h1, w2, h2), - quad(w1 + w2, h1, w3, h2), - quad(0, h1 + h2, w1, h3), - quad(w1, h1 + h2, w2, h3), + quad(0, 0, w1, h1), + quad(w1, 0, w2, h1), + quad(w1 + w2, 0, w3, h1), + quad(0, h1, w1, h2), + quad(w1, h1, w2, h2), + quad(w1 + w2, h1, w3, h2), + quad(0, h1 + h2, w1, h3), + quad(w1, h1 + h2, w2, h3), quad(w1 + w2, h1 + h2, w3, h3), // transform - quad(0, 0, left, top), - quad(left, 0, iw, top), - quad(left + iw, 0, right, top), - quad(0, top, left, ih), - quad(left, top, iw, ih), - quad(left + iw, top, right, ih), - quad(0, top + ih, left, bottom), - quad(left, top + ih, iw, bottom), + quad(0, 0, left, top), + quad(left, 0, iw, top), + quad(left + iw, 0, right, top), + quad(0, top, left, ih), + quad(left, top, iw, ih), + quad(left + iw, top, right, ih), + quad(0, top + ih, left, bottom), + quad(left, top + ih, iw, bottom), quad(left + iw, top + ih, right, bottom), ] for (let i = 0; i < 9; i++) { @@ -4017,7 +4017,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { loop: false, pingpong: false, speed: 0, - onEnd: () => {}, + onEnd: () => { }, } : { name: name, @@ -4025,7 +4025,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { loop: opt.loop ?? anim.loop ?? false, pingpong: opt.pingpong ?? anim.pingpong ?? false, speed: opt.speed ?? anim.speed ?? 10, - onEnd: opt.onEnd ?? (() => {}), + onEnd: opt.onEnd ?? (() => { }), } curAnimDir = typeof anim === "number" @@ -4155,7 +4155,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { } function polygon(pts: Vec2[], opt: PolygonCompOpt = {}): PolygonComp { - if(pts.length < 3) throw new Error(`Polygon's need more than two points, ${pts.length} points provided`) + if (pts.length < 3) throw new Error(`Polygon's need more than two points, ${pts.length} points provided`) return { id: "polygon", pts, @@ -4360,7 +4360,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { return { id: "body", - require: [ "pos" ], + require: ["pos"], vel: new Vec2(0), drag: opt.drag ?? 0, jumpForce: opt.jumpForce ?? DEF_JUMP_FORCE, @@ -4443,7 +4443,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { }, - update(this: GameObj) { + update(this: GameObj) { if (game.gravity && !this.isStatic) { @@ -4546,7 +4546,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { let jumpsLeft = numJumps return { id: "doubleJump", - require: [ "body" ], + require: ["body"], numJumps: numJumps, add(this: GameObj) { this.onGround(() => { @@ -4784,7 +4784,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { let t = 0 let done = false return { - require: [ "opacity" ], + require: ["opacity"], add(this: GameObj) { this.opacity = 0 }, @@ -5506,7 +5506,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { } - function agent(opts: AgentCompOpt = {}) : AgentComp { + function agent(opts: AgentCompOpt = {}): AgentComp { let target: Vec2 | null = null let path: Vec2[] | null = null let index: number | null = null @@ -5686,7 +5686,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { function boom(speed: number = 2, size: number = 1): Comp { let time = 0 return { - require: [ "scale" ], + require: ["scale"], update(this: GameObj) { const s = Math.sin(time * speed) * size if (s < 0) { @@ -5820,10 +5820,10 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { // insert & check against all covered grids for (let x = xmin; x <= xmax; x++) { for (let y = ymin; y <= ymax; y++) { - if(!grid[x]) { + if (!grid[x]) { grid[x] = {} grid[x][y] = [aobj] - } else if(!grid[x][y]) { + } else if (!grid[x][y]) { grid[x][y] = [aobj] } else { const cell = grid[x][y] @@ -6602,6 +6602,7 @@ export default (gopt: KaboomOpt = {}): KaboomCtx => { Line, Rect, Circle, + Ellipse, Polygon, Vec2, Color, diff --git a/src/math.ts b/src/math.ts index c42a551f..688e52a7 100644 --- a/src/math.ts +++ b/src/math.ts @@ -377,6 +377,279 @@ export function quad(x: number, y: number, w: number, h: number): Quad { return new Quad(x, y, w, h) } +// Internal class +class Mat2 { + // 2x2 matrix + a: number; b: number + c: number; d: number + + constructor(a: number, b: number, c: number, d: number) { + this.a = a + this.b = b + this.c = c + this.d = d + } + + mul(other: Mat2) { + return new Mat2( + this.a * other.a + this.b * other.c, + this.a * other.b + this.b * other.d, + this.c * other.a + this.d * other.c, + this.c * other.b + this.d * other.d, + ) + } + + transform(point: Vec2): Vec2 { + return vec2(this.a * point.x + this.b * point.y, this.c * point.x + this.d * point.y) + } + + get inverse() { + const det = this.det + return new Mat2( + this.d / det, -this.b / det, + -this.c / det, this.a / det, + ) + } + + get transpose() { + return new Mat2( + this.a, this.c, + this.b, this.d, + ) + } + + get eigenvalues() { + const m = this.trace / 2 + const d = this.det + const e1 = m + Math.sqrt(m * m - d) + const e2 = m - Math.sqrt(m * m - d) + return [e1, e2] + } + + eigenvectors(e1: number, e2: number) { + if (this.c != 0) { + return [[e1 - this.d, this.c], [e2 - this.d, this.c]] + } + else if (this.b != 0) { + return [[this.b, e1 - this.a], [this.b, e2 - this.a]] + } + else { + if (Math.abs(this.transform(vec2(1, 0)).x - e1) < Number.EPSILON) + return [[1, 0], [0, 1]] + else + return [[0, 1], [1, 0]] + } + } + + get det() { + return this.a * this.d - this.b * this.c + } + + get trace() { + return this.a + this.d + } + + static rotation(radians: number) { + const c = Math.cos(radians) + const s = Math.sin(radians) + return new Mat2( + c, s, + -s, c, + ) + } + + static scale(x: number, y: number) { + return new Mat2(x, 0, 0, y) + } +} + +// Internal class +class Mat23 { + // 2x3 matrix, since the last column is always (0, 0, 1) + a: number; b: number // 0 + c: number; d: number // 0 + e: number; f: number // 1 + constructor(a: number = 1, b: number = 0, c: number = 0, d: number = 1, e: number = 0, f: number = 0) { + this.a = a + this.b = b + this.c = c + this.d = d + this.e = e + this.f = f + } + static fromMat2(m: Mat2) { + return new Mat23( + m.a, m.b, + m.c, m.d, + 0, 0, + ) + } + toMat2() { + return new Mat2( + this.a, this.b, + this.c, this.d, + ) + } + static fromTranslation(t: Vec2) { + return new Mat23( + 1, 0, + 0, 1, + t.x, t.y, + ) + } + static fromRotation(radians: number) { + const c = Math.cos(radians) + const s = Math.sin(radians) + return new Mat23( + c, s, + -s, c, + 0, 0, + ) + } + static fromScale(s: Vec2): Mat23 { + return new Mat23( + s.x, 0, + 0, s.y, + 0, 0, + ) + } + mul(other: Mat23): Mat23 { + return new Mat23( + other.a * this.a + other.b * this.c, other.a * this.b + other.b * this.d, + other.c * this.a + other.d * this.c, other.c * this.b + other.d * this.d, + other.e * this.a + other.f * this.c + this.e, other.e * this.b + other.f * this.d + this.f, + ) + } + translate(t: Vec2): Mat23 { + this.e += t.x * this.a + t.y * this.c + this.f += t.y * this.b + t.x * this.d + return this + } + rotate(radians: number): Mat23 { + const c = Math.cos(radians) + const s = Math.sin(radians) + const oldA = this.a + const oldB = this.b + this.a = c * this.a + s * this.c + this.b = c * this.b + s * this.d + this.c = c * this.c - s * oldA + this.d = c * this.d - s * oldB + return this + } + scale(s: Vec2): Mat23 { + this.a *= s.x + this.b *= s.x + this.c *= s.y + this.d *= s.y + return this + } + transform(p: Vec2) { + return vec2(this.a * p.x + this.c * p.y + this.e, this.b * p.x + this.d * p.y + this.f) + } + + get det() { + return this.a * this.d - this.b * this.c + } + + get inverse() { + const det = this.det + return new Mat23( + this.d / det, -this.b / det, + -this.c / det, this.a / det, + (this.c * this.f - this.d * this.e) / det, (this.b * this.e - this.a * this.f) / det) + } +} + +// Internal class +class Mat3 { + // m11 m12 m13 + // m21 m22 m23 + // m31 m32 m33 + m11: number + m12: number + m13: number + m21: number + m22: number + m23: number + m31: number + m32: number + m33: number + + constructor(m11: number, m12: number, m13: number, + m21: number, m22: number, m23: number, + m31: number, m32: number, m33: number) { + this.m11 = m11 + this.m12 = m12 + this.m13 = m13 + this.m21 = m21 + this.m22 = m22 + this.m23 = m23 + this.m31 = m31 + this.m32 = m32 + this.m33 = m33 + } + + static fromMat2(m: Mat2) { + return new Mat3( + m.a, m.b, 0, + m.c, m.d, 0, + 0, 0, 1, + ) + } + + toMat2() { + return new Mat2( + this.m11, this.m12, + this.m21, this.m22, + ) + } + + mul(other: Mat3): Mat3 { + return new Mat3( + this.m11 * other.m11 + this.m12 * other.m21 + this.m13 * other.m31, + this.m11 * other.m12 + this.m12 * other.m22 + this.m13 * other.m32, + this.m11 * other.m13 + this.m12 * other.m23 + this.m13 * other.m33, + this.m21 * other.m11 + this.m22 * other.m21 + this.m23 * other.m31, + this.m21 * other.m12 + this.m22 * other.m22 + this.m23 * other.m32, + this.m21 * other.m13 + this.m22 * other.m23 + this.m23 * other.m33, + this.m31 * other.m11 + this.m32 * other.m21 + this.m33 * other.m31, + this.m31 * other.m12 + this.m32 * other.m22 + this.m33 * other.m32, + this.m31 * other.m13 + this.m32 * other.m23 + this.m33 * other.m33, + ) + } + + get det(): number { + return this.m11 * this.m22 * this.m33 + this.m12 * this.m23 * this.m31 + + this.m13 * this.m21 * this.m32 - this.m13 * this.m22 * this.m31 - + this.m12 * this.m21 * this.m33 - this.m11 * this.m23 * this.m32 + } + + get inverse(): Mat3 { + const det = this.det + return new Mat3( + (this.m22 * this.m33 - this.m23 * this.m32) / det, + (this.m13 * this.m32 - this.m12 * this.m33) / det, + (this.m12 * this.m23 - this.m13 * this.m22) / det, + + (this.m23 * this.m31 - this.m21 * this.m33) / det, + (this.m11 * this.m33 - this.m13 * this.m31) / det, + (this.m13 * this.m21 - this.m11 * this.m23) / det, + + (this.m21 * this.m32 - this.m22 * this.m31) / det, + (this.m12 * this.m31 - this.m11 * this.m32) / det, + (this.m11 * this.m22 - this.m12 * this.m21) / det, + ) + } + + get transpose(): Mat3 { + return new Mat3( + this.m11, this.m21, this.m31, + this.m12, this.m22, this.m32, + this.m13, this.m23, this.m33, + ) + } +} + export class Mat4 { m: number[] = [ @@ -937,11 +1210,143 @@ export function testPolygonPoint(poly: Polygon, pt: Point): boolean { return c } +export function testEllipsePoint(ellipse: Ellipse, pt: Point): boolean { + // Transform the point into the ellipse's unrotated coordinate system at the origin + pt = pt.sub(ellipse.center) + const angle = deg2rad(ellipse.angle) + const c = Math.cos(angle) + const s = Math.sin(angle) + const vx = pt.x * c + pt.y * s + const vy = -pt.x * s + pt.y * c + return vx * vx / (ellipse.radiusX * ellipse.radiusX) + vy * vy / (ellipse.radiusY * ellipse.radiusY) < 1 +} + +export function testEllipseCircle(ellipse: Ellipse, circle: Circle): boolean { + // This is an approximation, because the parallel curve of an ellipse is an octic algebraic curve, not just a larger ellipse. + // Transform the circle's center into the ellipse's unrotated coordinate system at the origin + const center = circle.center.sub(ellipse.center) + const angle = deg2rad(ellipse.angle) + const c = Math.cos(angle) + const s = Math.sin(angle) + const cx = center.x * c + center.y * s + const cy = -center.x * s + center.y * c + // Test with an approximate Minkowski sum of the ellipse and the circle + return testEllipsePoint(new Ellipse(vec2(), ellipse.radiusX + circle.radius, ellipse.radiusY + circle.radius, 0), vec2(cx, cy)) +} + +export function testEllipseLine(ellipse: Ellipse, line: Line): boolean { + // Transform the line to the coordinate system where the ellipse is a unit circle + const T = ellipse.toMat2().inverse + line = new Line(T.transform(line.p1.sub(ellipse.center)), T.transform(line.p2.sub(ellipse.center))) + return testLineCircle(line, new Circle(vec2(), 1)) +} + +export function testEllipseEllipse(ellipse1: Ellipse, ellipse2: Ellipse): boolean { + // First check if one of the ellipses isn't secretly a circle + if (ellipse1.radiusX === ellipse1.radiusY) { + return testEllipseCircle(ellipse2, new Circle(ellipse1.center, ellipse1.radiusX)) + } + else if (ellipse2.radiusX === ellipse2.radiusY) { + return testEllipseCircle(ellipse1, new Circle(ellipse2.center, ellipse2.radiusX)) + } + // No luck, we need to solve the equation + /* + Etayo, Fernando, Laureano Gonzalez-Vega, and Natalia del Rio. "A new approach to characterizing the relative position of two ellipses depending on one parameter." Computer aided geometric design 23, no. 4 (2006): 324-350. + */ + const A1 = new Mat3( + 1 / ellipse1.radiusX ** 2, 0, 0, + 0, 1 / ellipse1.radiusY ** 2, 0, + 0, 0, -1) + const A2 = new Mat3( + 1 / ellipse2.radiusX ** 2, 0, 0, + 0, 1 / ellipse2.radiusY ** 2, 0, + 0, 0, -1) + + const x1 = ellipse1.center.x + const y1 = ellipse1.center.y + const x2 = ellipse2.center.x + const y2 = ellipse2.center.y + const theta1 = deg2rad(ellipse1.angle) + const theta2 = deg2rad(ellipse2.angle) + + const M1 = new Mat3( + Math.cos(theta1), -Math.sin(theta1), x1, + Math.sin(theta1), Math.cos(theta1), y1, + 0, 0, 1) + const M2 = new Mat3( + Math.cos(theta2), -Math.sin(theta2), x2, + Math.sin(theta2), Math.cos(theta2), y2, + 0, 0, 1) + const M1inv = M1.inverse + const M2inv = M2.inverse + + const A = M1inv.transpose.mul(A1).mul(M1inv) + const B = M2inv.transpose.mul(A2).mul(M2inv) + + const a11 = A.m11 + const a12 = A.m12 + const a13 = A.m13 + const a21 = A.m21 + const a22 = A.m22 + const a23 = A.m23 + const a31 = A.m31 + const a32 = A.m32 + const a33 = A.m33 + + const b11 = B.m11 + const b12 = B.m12 + const b13 = B.m13 + const b21 = B.m21 + const b22 = B.m22 + const b23 = B.m23 + const b31 = B.m31 + const b32 = B.m32 + const b33 = B.m33 + + const factor = (a11 * a22 * a33 - a11 * a23 * a32 - a12 * a21 * a33 + a12 * a23 * a31 + a13 * a21 * a32 - a13 * a22 * a31) + const a = (a11 * a22 * b33 - a11 * a23 * b32 - a11 * a32 * b23 + a11 * a33 * b22 - a12 * a21 * b33 + a12 * a23 * b31 + a12 * a31 * b23 - a12 * a33 * b21 + a13 * a21 * b32 - a13 * a22 * b31 - a13 * a31 * b22 + a13 * a32 * b21 + a21 * a32 * b13 - a21 * a33 * b12 - a22 * a31 * b13 + a22 * a33 * b11 + a23 * a31 * b12 - a23 * a32 * b11) / factor + const b = (a11 * b22 * b33 - a11 * b23 * b32 - a12 * b21 * b33 + a12 * b23 * b31 + a13 * b21 * b32 - a13 * b22 * b31 - a21 * b12 * b33 + a21 * b13 * b32 + a22 * b11 * b33 - a22 * b13 * b31 - a23 * b11 * b32 + a23 * b12 * b31 + a31 * b12 * b23 - a31 * b13 * b22 - a32 * b11 * b23 + a32 * b13 * b21 + a33 * b11 * b22 - a33 * b12 * b21) / factor + const c = (b11 * b22 * b33 - b11 * b23 * b32 - b12 * b21 * b33 + b12 * b23 * b31 + b13 * b21 * b32 - b13 * b22 * b31) / factor + + if (a >= 0) { + const condition1 = -3 * b + a ** 2 + const condition2 = 3 * a * c + b * a ** 2 - 4 * b ** 2 + const condition3 = -27 * c ** 2 + 18 * c * a * b + a ** 2 * b ** 2 - 4 * a ** 3 * c - 4 * b ** 3 + if (condition1 > 0 && condition2 < 0 && condition3 > 0) { + return false + } + else { + return true + } + } + else { + const condition1 = -3 * b + a ** 2 + const condition2 = -27 * c ** 2 + 18 * c * a * b + a ** 2 * b ** 2 - 4 * a ** 3 * c - 4 * b ** 3 + if (condition1 > 0 && condition2 > 0) { + return false + } + else { + return true + } + } +} + +export function testEllipseRect(ellipse: Ellipse, rect: Rect): boolean { + return testEllipsePolygon(ellipse, new Polygon(rect.points())) +} + +export function testEllipsePolygon(ellipse: Ellipse, poly: Polygon): boolean { + // Transform the polygon to the coordinate system where the ellipse is a unit circle + const T = ellipse.toMat2().inverse + poly = new Polygon(poly.pts.map(p => T.transform(p.sub(ellipse.center)))) + return testCirclePolygon(new Circle(vec2(), 1), poly) +} + export function testPointPoint(p1: Point, p2: Point): boolean { return p1.x === p2.x && p1.y === p2.y } -export type ShapeType = Vec2 | Circle | Line | Rect | Polygon +export type ShapeType = Vec2 | Circle | Line | Rect | Polygon | Ellipse export function testPointShape(point: Point, shape: ShapeType): boolean { if (shape instanceof Vec2) { @@ -959,6 +1364,9 @@ export function testPointShape(point: Point, shape: ShapeType): boolean { else if (shape instanceof Polygon) { return testPolygonPoint(shape as Polygon, point) } + else if (shape instanceof Ellipse) { + return testEllipsePoint(shape as Ellipse, point) + } else { return false } @@ -980,6 +1388,9 @@ export function testLineShape(line: Line, shape: ShapeType): boolean { else if (shape instanceof Polygon) { return testLinePolygon(line, shape as Polygon) } + else if (shape instanceof Ellipse) { + return testEllipseLine(shape as Ellipse, line) + } else { return false } @@ -1001,6 +1412,9 @@ export function testCircleShape(circle: Circle, shape: ShapeType): boolean { else if (shape instanceof Polygon) { return testCirclePolygon(circle, shape as Polygon) } + else if (shape instanceof Ellipse) { + return testEllipseCircle(shape as Ellipse, circle) + } else { return false } @@ -1022,6 +1436,9 @@ export function testRectShape(rect: Rect, shape: ShapeType): boolean { else if (shape instanceof Polygon) { return testRectPolygon(rect, shape as Polygon) } + else if (shape instanceof Ellipse) { + return testEllipseRect(shape as Ellipse, rect) + } else { return false } @@ -1043,6 +1460,33 @@ export function testPolygonShape(polygon: Polygon, shape: ShapeType): boolean { else if (shape instanceof Polygon) { return testPolygonPolygon(shape as Polygon, polygon) } + else if (shape instanceof Ellipse) { + return testEllipsePolygon(shape as Ellipse, polygon) + } + else { + return false + } +} + +export function testEllipseShape(ellipse: Ellipse, shape: ShapeType): boolean { + if (shape instanceof Vec2) { + return testEllipsePoint(ellipse, shape as Point) + } + else if (shape instanceof Circle) { + return testEllipseCircle(ellipse, shape as Circle) + } + else if (shape instanceof Line) { + return testEllipseLine(ellipse, shape as Line) + } + else if (shape instanceof Rect) { + return testEllipseRect(ellipse, shape as Rect) + } + else if (shape instanceof Polygon) { + return testEllipsePolygon(ellipse, shape as Polygon) + } + else if (shape instanceof Ellipse) { + return testEllipseEllipse(shape as Ellipse, ellipse) + } else { return false } @@ -1064,6 +1508,9 @@ export function testShapeShape(shape1: ShapeType, shape2: ShapeType): boolean { else if (shape1 instanceof Polygon) { return testPolygonShape(shape1 as Polygon, shape2) } + else if (shape1 instanceof Ellipse) { + return testEllipseShape(shape1 as Ellipse, shape2) + } else { return false } @@ -1219,6 +1666,34 @@ function raycastPolygon(origin: Vec2, direction: Vec2, polygon: Polygon): Raycas return minHit } +function raycastEllipse(origin: Vec2, direction: Vec2, ellipse: Ellipse): RaycastResult { + // Transforms from unit circle to rotated ellipse + const T = ellipse.toMat2() + // Transforms from rotated ellipse to unit circle + const TI = T.inverse + // Transform both origin and direction into the unit circle coordinate system + const Torigin = TI.transform(origin.sub(ellipse.center)) + const Tdirection = TI.transform(direction) + // Raycast as if we have a circle + const result = raycastCircle(Torigin, Tdirection, new Circle(vec2(), 1)) + if (result) { + const R = Mat2.rotation(deg2rad(-ellipse.angle)) + const S = Mat2.scale(ellipse.radiusX, ellipse.radiusY) + // Scale the point so we have a point on the unrotated ellipse + const p = S.transform(result.point) + // transform the result point to the coordinate system of the rotated ellipse + const point = T.transform(result.point).add(ellipse.center) + const fraction = point.dist(origin) / direction.len() + return { + point: point, + // Calculate the normal at the unrotated ellipse, then rotate the normal to the rotated ellipse + normal: R.transform(vec2(ellipse.radiusY ** 2 * p.x, ellipse.radiusX ** 2 * p.y)), + fraction, + } + } + return result +} + export class Line { p1: Vec2 p2: Vec2 @@ -1339,34 +1814,118 @@ export class Circle { } } -// TODO: support rotation export class Ellipse { center: Vec2 radiusX: number radiusY: number - constructor(center: Vec2, rx: number, ry: number) { + angle: number + constructor(center: Vec2, rx: number, ry: number, degrees: number = 0) { this.center = center.clone() this.radiusX = rx this.radiusY = ry + this.angle = degrees + } + static fromMat2(tr: Mat2): Ellipse { + const inv = tr.inverse + const M = inv.transpose.mul(inv) + const [e1, e2] = M.eigenvalues + const [v1, v2] = M.eigenvectors(e1, e2) + + const [a, b] = [1 / Math.sqrt(e1), 1 / Math.sqrt(e2)] + + // Make sure we use the semi-major axis for the rotation + if (a > b) { + return new Ellipse(vec2(), a, b, rad2deg(Math.atan2(-v1[1], v1[0]))) + } + else { + return new Ellipse(vec2(), b, a, rad2deg(Math.atan2(-v2[1], v2[0]))) + } + } + toMat2(): Mat2 { + const a = deg2rad(-this.angle) + const c = Math.cos(a) + const s = Math.sin(a) + return new Mat2( + c * this.radiusX, s * this.radiusY, + -s * this.radiusX, c * this.radiusY) } transform(tr: Mat4): Ellipse { - return new Ellipse( - tr.multVec2(this.center), - tr.m[0] * this.radiusX, - tr.m[5] * this.radiusY, - ) + if (this.angle == 0 && tr.getRotation() == 0) { + // No rotation, so we can just take the scale and translation + return new Ellipse( + tr.multVec2(this.center), + tr.m[0] * this.radiusX, + tr.m[5] * this.radiusY, + ) + } + else { + // Rotation. We can't just add angles, as the scale can squeeze the + // ellipse and thus change the angle. + // 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) + T = M.toMat2() + // Return the ellipse made from the transformed unit circle + const ellipse = Ellipse.fromMat2(T) + ellipse.center = tr.multVec2(this.center) + return ellipse + } } bbox(): Rect { - return Rect.fromPoints( - this.center.sub(vec2(this.radiusX, this.radiusY)), - this.center.add(vec2(this.radiusX, this.radiusY)), - ) + if (this.angle == 0) { + // No rotation, so the semi-major and semi-minor axis give the extends + return Rect.fromPoints( + this.center.sub(vec2(this.radiusX, this.radiusY)), + this.center.add(vec2(this.radiusX, this.radiusY)), + ) + } + else { + // 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) + const s = Math.sin(angle) + const ux = this.radiusX * c + const uy = this.radiusX * s + const vx = this.radiusY * s + const vy = this.radiusY * c + + const halfwidth = Math.sqrt(ux * ux + vx * vx) + const halfheight = Math.sqrt(uy * uy + vy * vy) + + return Rect.fromPoints( + this.center.sub(vec2(halfwidth, halfheight)), + this.center.add(vec2(halfwidth, halfheight)), + ) + } } area(): number { return this.radiusX * this.radiusY * Math.PI } clone(): Ellipse { - return new Ellipse(this.center, this.radiusX, this.radiusY) + return new Ellipse(this.center, this.radiusX, this.radiusY, this.angle) + } + collides(shape: ShapeType): boolean { + return testEllipseShape(this, shape) + } + contains(point: Vec2): boolean { + // Both methods work, but the second one is faster + /*let T = this.toTransform() + point = point.sub(this.center) + point = T.inverse.transform(point) + return testCirclePoint(new Circle(vec2(), 1), point)*/ + point = point.sub(this.center) + const angle = deg2rad(this.angle) + const c = Math.cos(angle) + const s = Math.sin(angle) + const vx = point.x * c + point.y * s + const vy = -point.x * s + point.y * c + return vx * vx / (this.radiusX * this.radiusX) + vy * vy / (this.radiusY * this.radiusY) < 1 + } + raycast(origin: Vec2, direction: Vec2): RaycastResult { + return raycastEllipse(origin, direction, this) } } diff --git a/src/types.ts b/src/types.ts index d393ec3d..729bdc47 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1971,6 +1971,7 @@ export interface KaboomCtx { Line: typeof Line, Rect: typeof Rect, Circle: typeof Circle, + Ellipse: typeof Ellipse, Polygon: typeof Polygon, Vec2: typeof Vec2, Color: typeof Color, @@ -4058,6 +4059,7 @@ export type RNGValue = export type ShapeType = Vec2 | Circle + | Ellipse | Line | Rect | Polygon @@ -4124,6 +4126,9 @@ export declare class Ellipse { bbox(): Rect area(): number clone(): Ellipse + collides(shape: ShapeType): boolean + contains(point: Vec2): boolean + raycast(origin: Vec2, direction: Vec2): RaycastResult } export declare class Polygon {