1087 lines
32 KiB
JavaScript
1087 lines
32 KiB
JavaScript
// deno-fmt-ignore-file
|
|
// deno-lint-ignore-file
|
|
// This code was bundled using `deno bundle` and it's not recommended to edit it manually
|
|
|
|
const Constants = {
|
|
TWO_PI: Math.PI * 2
|
|
};
|
|
class Vector {
|
|
x;
|
|
y;
|
|
z;
|
|
constructor(x = 0, y = 0, z = 0){
|
|
this.x = x;
|
|
this.y = y;
|
|
this.z = z;
|
|
}
|
|
set(v, y, z) {
|
|
if (arguments.length === 1 && typeof v !== "number") {
|
|
this.set(v.x || v[0] || 0, v.y || v[1] || 0, v.z || v[2] || 0);
|
|
} else {
|
|
this.x = v;
|
|
this.y = y || 0;
|
|
this.z = z || 0;
|
|
}
|
|
}
|
|
get() {
|
|
return new Vector(this.x, this.y, this.z);
|
|
}
|
|
mag() {
|
|
const x = this.x, y = this.y, z = this.z;
|
|
return Math.sqrt(x * x + y * y + z * z);
|
|
}
|
|
magSq() {
|
|
const x = this.x, y = this.y, z = this.z;
|
|
return x * x + y * y + z * z;
|
|
}
|
|
setMag(v_or_len, len) {
|
|
if (len === undefined) {
|
|
len = v_or_len;
|
|
this.normalize();
|
|
this.mult(len);
|
|
} else {
|
|
const v = v_or_len;
|
|
v.normalize();
|
|
v.mult(len);
|
|
return v;
|
|
}
|
|
}
|
|
add(v, y, z) {
|
|
if (arguments.length === 1 && typeof v !== 'number') {
|
|
this.x += v.x;
|
|
this.y += v.y;
|
|
this.z += v.z;
|
|
} else if (arguments.length === 2) {
|
|
this.x += v;
|
|
this.y += y ?? 0;
|
|
} else {
|
|
this.x += v;
|
|
this.y += y ?? 0;
|
|
this.z += z ?? 0;
|
|
}
|
|
return this;
|
|
}
|
|
sub(v, y, z) {
|
|
if (arguments.length === 1 && typeof v !== 'number') {
|
|
this.x -= v.x;
|
|
this.y -= v.y;
|
|
this.z -= v.z;
|
|
} else if (arguments.length === 2) {
|
|
this.x -= v;
|
|
this.y -= y ?? 0;
|
|
} else {
|
|
this.x -= v;
|
|
this.y -= y ?? 0;
|
|
this.z -= z ?? 0;
|
|
}
|
|
return this;
|
|
}
|
|
mult(v) {
|
|
if (typeof v === 'number') {
|
|
this.x *= v;
|
|
this.y *= v;
|
|
this.z *= v;
|
|
} else {
|
|
this.x *= v.x;
|
|
this.y *= v.y;
|
|
this.z *= v.z;
|
|
}
|
|
return this;
|
|
}
|
|
div(v) {
|
|
if (typeof v === 'number') {
|
|
this.x /= v;
|
|
this.y /= v;
|
|
this.z /= v;
|
|
} else {
|
|
this.x /= v.x;
|
|
this.y /= v.y;
|
|
this.z /= v.z;
|
|
}
|
|
return this;
|
|
}
|
|
rotate(angle) {
|
|
const prev_x = this.x;
|
|
const c = Math.cos(angle);
|
|
const s = Math.sin(angle);
|
|
this.x = c * this.x - s * this.y;
|
|
this.y = s * prev_x + c * this.y;
|
|
return this;
|
|
}
|
|
dist(v) {
|
|
const dx = this.x - v.x, dy = this.y - v.y, dz = this.z - v.z;
|
|
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
}
|
|
dot(v, y, z) {
|
|
if (arguments.length === 1 && typeof v !== 'number') {
|
|
return this.x * v.x + this.y * v.y + this.z * v.z;
|
|
}
|
|
return this.x * v + this.y * y + this.z * z;
|
|
}
|
|
cross(v) {
|
|
const x = this.x, y = this.y, z = this.z;
|
|
return new Vector(y * v.z - v.y * z, z * v.x - v.z * x, x * v.y - v.x * y);
|
|
}
|
|
lerp(v_or_x, amt_or_y, z, amt) {
|
|
const lerp_val = (start, stop, amt)=>{
|
|
return start + (stop - start) * amt;
|
|
};
|
|
let x, y;
|
|
if (arguments.length === 2 && typeof v_or_x !== 'number') {
|
|
amt = amt_or_y;
|
|
x = v_or_x.x;
|
|
y = v_or_x.y;
|
|
z = v_or_x.z;
|
|
} else {
|
|
x = v_or_x;
|
|
y = amt_or_y;
|
|
}
|
|
this.x = lerp_val(this.x, x, amt);
|
|
this.y = lerp_val(this.y, y, amt);
|
|
this.z = lerp_val(this.z, z, amt);
|
|
return this;
|
|
}
|
|
normalize() {
|
|
const m = this.mag();
|
|
if (m > 0) {
|
|
this.div(m);
|
|
}
|
|
return this;
|
|
}
|
|
limit(high) {
|
|
if (this.mag() > high) {
|
|
this.normalize();
|
|
this.mult(high);
|
|
}
|
|
return this;
|
|
}
|
|
heading() {
|
|
return -Math.atan2(-this.y, this.x);
|
|
}
|
|
heading2D() {
|
|
return this.heading();
|
|
}
|
|
toString() {
|
|
return "[" + this.x + ", " + this.y + ", " + this.z + "]";
|
|
}
|
|
array() {
|
|
return [
|
|
this.x,
|
|
this.y,
|
|
this.z
|
|
];
|
|
}
|
|
copy() {
|
|
return new Vector(this.x, this.y, this.z);
|
|
}
|
|
drawDot() {
|
|
if (!doodler) return;
|
|
doodler.dot(this, {
|
|
weight: 2,
|
|
color: 'red'
|
|
});
|
|
}
|
|
static fromAngle(angle, v) {
|
|
if (v === undefined || v === null) {
|
|
v = new Vector();
|
|
}
|
|
v.x = Math.cos(angle);
|
|
v.y = Math.sin(angle);
|
|
return v;
|
|
}
|
|
static random2D(v) {
|
|
return Vector.fromAngle(Math.random() * (Math.PI * 2), v);
|
|
}
|
|
static random3D(v) {
|
|
const angle = Math.random() * Constants.TWO_PI;
|
|
const vz = Math.random() * 2 - 1;
|
|
const mult = Math.sqrt(1 - vz * vz);
|
|
const vx = mult * Math.cos(angle);
|
|
const vy = mult * Math.sin(angle);
|
|
if (v === undefined || v === null) {
|
|
v = new Vector(vx, vy, vz);
|
|
} else {
|
|
v.set(vx, vy, vz);
|
|
}
|
|
return v;
|
|
}
|
|
static dist(v1, v2) {
|
|
return v1.dist(v2);
|
|
}
|
|
static dot(v1, v2) {
|
|
return v1.dot(v2);
|
|
}
|
|
static cross(v1, v2) {
|
|
return v1.cross(v2);
|
|
}
|
|
static add(v1, v2) {
|
|
return new Vector(v1.x + v2.x, v1.y + v2.y, v1.z + v2.z);
|
|
}
|
|
static sub(v1, v2) {
|
|
return new Vector(v1.x - v2.x, v1.y - v2.y, v1.z - v2.z);
|
|
}
|
|
static angleBetween(v1, v2) {
|
|
return Math.acos(v1.dot(v2) / Math.sqrt(v1.magSq() * v2.magSq()));
|
|
}
|
|
static lerp(v1, v2, amt) {
|
|
const retval = new Vector(v1.x, v1.y, v1.z);
|
|
retval.lerp(v2, amt);
|
|
return retval;
|
|
}
|
|
static vectorProjection(v1, v2) {
|
|
v2 = v2.copy();
|
|
v2.normalize();
|
|
const sp = v1.dot(v2);
|
|
v2.mult(sp);
|
|
return v2;
|
|
}
|
|
static hypot2(a, b) {
|
|
return Vector.dot(Vector.sub(a, b), Vector.sub(a, b));
|
|
}
|
|
}
|
|
const init = (opt)=>{
|
|
if (window.doodler) throw 'Doodler has already been initialized in this window';
|
|
window.doodler = new Doodler(opt);
|
|
window.doodler.init();
|
|
};
|
|
class Doodler {
|
|
ctx;
|
|
_canvas;
|
|
layers = [];
|
|
bg;
|
|
framerate;
|
|
get width() {
|
|
return this.ctx.canvas.width;
|
|
}
|
|
get height() {
|
|
return this.ctx.canvas.height;
|
|
}
|
|
draggables = [];
|
|
clickables = [];
|
|
constructor({ width , height , canvas , bg , framerate }){
|
|
if (!canvas) {
|
|
canvas = document.createElement('canvas');
|
|
document.body.append(canvas);
|
|
}
|
|
this.bg = bg || 'white';
|
|
this.framerate = framerate || 60;
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
this._canvas = canvas;
|
|
const ctx = canvas.getContext('2d');
|
|
console.log(ctx);
|
|
if (!ctx) throw 'Unable to initialize Doodler: Canvas context not found';
|
|
this.ctx = ctx;
|
|
}
|
|
init() {
|
|
this._canvas.addEventListener('mousedown', (e)=>this.onClick(e));
|
|
this._canvas.addEventListener('mouseup', (e)=>this.offClick(e));
|
|
this._canvas.addEventListener('mousemove', (e)=>{
|
|
const rect = this._canvas.getBoundingClientRect();
|
|
this.mouseX = e.clientX - rect.left;
|
|
this.mouseY = e.clientY - rect.top;
|
|
for (const d of this.draggables.filter((d)=>d.beingDragged)){
|
|
d.point.add(e.movementX, e.movementY);
|
|
d.onDrag && d.onDrag({
|
|
x: e.movementX,
|
|
y: e.movementY
|
|
});
|
|
}
|
|
});
|
|
this.startDrawLoop();
|
|
}
|
|
timer;
|
|
startDrawLoop() {
|
|
this.timer = setInterval(()=>this.draw(), 1000 / this.framerate);
|
|
}
|
|
draw() {
|
|
this.ctx.fillStyle = this.bg;
|
|
this.ctx.fillRect(0, 0, this.width, this.height);
|
|
for (const [i, l] of (this.layers || []).entries()){
|
|
l(this.ctx, i);
|
|
}
|
|
this.drawUI();
|
|
}
|
|
createLayer(layer) {
|
|
this.layers.push(layer);
|
|
}
|
|
deleteLayer(layer) {
|
|
this.layers = this.layers.filter((l)=>l !== layer);
|
|
}
|
|
moveLayer(layer, index) {
|
|
let temp = this.layers.filter((l)=>l !== layer);
|
|
temp = [
|
|
...temp.slice(0, index),
|
|
layer,
|
|
...temp.slice(index)
|
|
];
|
|
this.layers = temp;
|
|
}
|
|
line(start, end, style) {
|
|
this.setStyle(style);
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(start.x, start.y);
|
|
this.ctx.lineTo(end.x, end.y);
|
|
this.ctx.stroke();
|
|
}
|
|
dot(at, style) {
|
|
this.setStyle({
|
|
...style,
|
|
weight: 1
|
|
});
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(at.x, at.y, style?.weight || 1, 0, Constants.TWO_PI);
|
|
this.ctx.fill();
|
|
}
|
|
drawCircle(at, radius, style) {
|
|
this.setStyle(style);
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(at.x, at.y, radius, 0, Constants.TWO_PI);
|
|
this.ctx.stroke();
|
|
}
|
|
fillCircle(at, radius, style) {
|
|
this.setStyle(style);
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(at.x, at.y, radius, 0, Constants.TWO_PI);
|
|
this.ctx.fill();
|
|
}
|
|
drawRect(at, width, height, style) {
|
|
this.setStyle(style);
|
|
this.ctx.strokeRect(at.x, at.y, width, height);
|
|
}
|
|
fillRect(at, width, height, style) {
|
|
this.setStyle(style);
|
|
this.ctx.fillRect(at.x, at.y, width, height);
|
|
}
|
|
drawSquare(at, size, style) {
|
|
this.drawRect(at, size, size, style);
|
|
}
|
|
fillSquare(at, size, style) {
|
|
this.fillRect(at, size, size, style);
|
|
}
|
|
drawCenteredRect(at, width, height, style) {
|
|
this.ctx.save();
|
|
this.ctx.translate(-width / 2, -height / 2);
|
|
this.drawRect(at, width, height, style);
|
|
this.ctx.restore();
|
|
}
|
|
fillCenteredRect(at, width, height, style) {
|
|
this.ctx.save();
|
|
this.ctx.translate(-width / 2, -height / 2);
|
|
this.fillRect(at, width, height, style);
|
|
this.ctx.restore();
|
|
}
|
|
drawCenteredSquare(at, size, style) {
|
|
this.drawCenteredRect(at, size, size, style);
|
|
}
|
|
fillCenteredSquare(at, size, style) {
|
|
this.fillCenteredRect(at, size, size, style);
|
|
}
|
|
drawBezier(a, b, c, d, style) {
|
|
this.setStyle(style);
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(a.x, a.y);
|
|
this.ctx.bezierCurveTo(b.x, b.y, c.x, c.y, d.x, d.y);
|
|
this.ctx.stroke();
|
|
}
|
|
drawRotated(origin, angle, cb) {
|
|
this.ctx.save();
|
|
this.ctx.translate(origin.x, origin.y);
|
|
this.ctx.rotate(angle);
|
|
this.ctx.translate(-origin.x, -origin.y);
|
|
cb();
|
|
this.ctx.restore();
|
|
}
|
|
drawImage(img, at, w, h) {
|
|
w && h ? this.ctx.drawImage(img, at.x, at.y, w, h) : this.ctx.drawImage(img, at.x, at.y);
|
|
}
|
|
drawSprite(img, spritePos, sWidth, sHeight, at, width, height) {
|
|
this.ctx.drawImage(img, spritePos.x, spritePos.y, sWidth, sHeight, at.x, at.y, width, height);
|
|
}
|
|
setStyle(style) {
|
|
const ctx = this.ctx;
|
|
ctx.fillStyle = style?.color || style?.fillColor || 'black';
|
|
ctx.strokeStyle = style?.color || style?.strokeColor || 'black';
|
|
ctx.lineWidth = style?.weight || 1;
|
|
}
|
|
mouseX = 0;
|
|
mouseY = 0;
|
|
registerDraggable(point, radius, style) {
|
|
if (this.draggables.find((d)=>d.point === point)) return;
|
|
const id = this.addUIElement('circle', point, radius, {
|
|
fillColor: '#5533ff50',
|
|
strokeColor: '#5533ff50'
|
|
});
|
|
this.draggables.push({
|
|
point,
|
|
radius,
|
|
style,
|
|
id
|
|
});
|
|
}
|
|
unregisterDraggable(point) {
|
|
for (const d of this.draggables){
|
|
if (d.point === point) {
|
|
this.removeUIElement(d.id);
|
|
}
|
|
}
|
|
this.draggables = this.draggables.filter((d)=>d.point !== point);
|
|
}
|
|
registerClickable(p1, p2, cb) {
|
|
const top = Math.min(p1.y, p2.y);
|
|
const left = Math.min(p1.x, p2.x);
|
|
const bottom = Math.max(p1.y, p2.y);
|
|
const right = Math.max(p1.x, p2.x);
|
|
this.clickables.push({
|
|
onClick: cb,
|
|
checkBound: (p)=>p.y >= top && p.x >= left && p.y <= bottom && p.x <= right
|
|
});
|
|
}
|
|
unregisterClickable(cb) {
|
|
this.clickables = this.clickables.filter((c)=>c.onClick !== cb);
|
|
}
|
|
addDragEvents({ onDragEnd , onDragStart , onDrag , point }) {
|
|
const d = this.draggables.find((d)=>d.point === point);
|
|
if (d) {
|
|
d.onDragEnd = onDragEnd;
|
|
d.onDragStart = onDragStart;
|
|
d.onDrag = onDrag;
|
|
}
|
|
}
|
|
onClick(e) {
|
|
const mouse = new Vector(this.mouseX, this.mouseY);
|
|
for (const d of this.draggables){
|
|
if (d.point.dist(mouse) <= d.radius) {
|
|
d.beingDragged = true;
|
|
d.onDragStart?.call(null);
|
|
} else d.beingDragged = false;
|
|
}
|
|
for (const c of this.clickables){
|
|
if (c.checkBound(mouse)) {
|
|
c.onClick();
|
|
}
|
|
}
|
|
}
|
|
offClick(e) {
|
|
for (const d of this.draggables){
|
|
d.beingDragged = false;
|
|
d.onDragEnd?.call(null);
|
|
}
|
|
}
|
|
uiElements = new Map();
|
|
uiDrawing = {
|
|
rectangle: (...args)=>{
|
|
!args[3].noFill && this.fillRect(args[0], args[1], args[2], args[3]);
|
|
!args[3].noStroke && this.drawRect(args[0], args[1], args[2], args[3]);
|
|
},
|
|
square: (...args)=>{
|
|
!args[2].noFill && this.fillSquare(args[0], args[1], args[2]);
|
|
!args[2].noStroke && this.drawSquare(args[0], args[1], args[2]);
|
|
},
|
|
circle: (...args)=>{
|
|
!args[2].noFill && this.fillCircle(args[0], args[1], args[2]);
|
|
!args[2].noStroke && this.drawCircle(args[0], args[1], args[2]);
|
|
}
|
|
};
|
|
drawUI() {
|
|
for (const [shape, ...args] of this.uiElements.values()){
|
|
this.uiDrawing[shape].apply(null, args);
|
|
}
|
|
}
|
|
addUIElement(shape, ...args) {
|
|
const id = crypto.randomUUID();
|
|
for (const arg of args){
|
|
delete arg.color;
|
|
}
|
|
this.uiElements.set(id, [
|
|
shape,
|
|
...args
|
|
]);
|
|
return id;
|
|
}
|
|
removeUIElement(id) {
|
|
this.uiElements.delete(id);
|
|
}
|
|
}
|
|
class Train {
|
|
nodes = [];
|
|
cars = [];
|
|
path;
|
|
t;
|
|
engineLength = 40;
|
|
spacing = 30;
|
|
constructor(track, cars = []){
|
|
this.path = track;
|
|
this.t = 0;
|
|
this.nodes.push(this.path.followEvenPoints(this.t));
|
|
this.nodes.push(this.path.followEvenPoints(this.t - this.real2Track(40)));
|
|
this.cars.push(new TrainCar(55, document.getElementById('engine-sprites'), 80, 20, {
|
|
at: new Vector(0, 60),
|
|
width: 80,
|
|
height: 20
|
|
}));
|
|
this.cars[0].points = this.nodes.map((n)=>n);
|
|
let currentOffset = 40;
|
|
for (const car of cars){
|
|
currentOffset += this.spacing;
|
|
const a = this.path.followEvenPoints(this.t - currentOffset);
|
|
currentOffset += car.length;
|
|
const b = this.path.followEvenPoints(this.t - currentOffset);
|
|
car.points = [
|
|
a,
|
|
b
|
|
];
|
|
this.cars.push(car);
|
|
}
|
|
}
|
|
move() {
|
|
this.t = (this.t + 1) % this.path.evenPoints.length;
|
|
let currentOffset = 0;
|
|
for (const car of this.cars){
|
|
if (!car.points) return;
|
|
const [a, b] = car.points;
|
|
a.set(this.path.followEvenPoints(this.t - currentOffset));
|
|
currentOffset += car.length;
|
|
b.set(this.path.followEvenPoints(this.t - currentOffset));
|
|
currentOffset += this.spacing;
|
|
car.draw();
|
|
}
|
|
}
|
|
real2Track(length) {
|
|
return length / this.path.pointSpacing;
|
|
}
|
|
}
|
|
class TrainCar {
|
|
img;
|
|
imgWidth;
|
|
imgHeight;
|
|
sprite;
|
|
points;
|
|
length;
|
|
constructor(length, img, w, h, sprite){
|
|
this.img = img;
|
|
this.sprite = sprite;
|
|
this.imgWidth = w;
|
|
this.imgHeight = h;
|
|
this.length = length;
|
|
}
|
|
draw() {
|
|
if (!this.points) return;
|
|
const [a, b] = this.points;
|
|
const origin = Vector.add(Vector.sub(a, b).div(2), b);
|
|
const angle = Vector.sub(b, a).heading();
|
|
doodler.drawCircle(origin, 4, {
|
|
color: 'blue'
|
|
});
|
|
doodler.drawRotated(origin, angle, ()=>{
|
|
this.sprite ? doodler.drawSprite(this.img, this.sprite.at, this.sprite.width, this.sprite.height, origin.copy().sub(this.imgWidth / 2, this.imgHeight / 2), this.imgWidth, this.imgHeight) : doodler.drawImage(this.img, origin.copy().sub(this.imgWidth / 2, this.imgHeight / 2));
|
|
});
|
|
}
|
|
}
|
|
class PathSegment {
|
|
points;
|
|
ctx;
|
|
length;
|
|
constructor(points){
|
|
this.points = points;
|
|
this.length = this.calculateApproxLength(100);
|
|
}
|
|
setContext(ctx) {
|
|
this.ctx = ctx;
|
|
}
|
|
draw() {
|
|
const [a, b, c, d] = this.points;
|
|
doodler.drawBezier(a, b, c, d, {
|
|
strokeColor: '#ffffff50'
|
|
});
|
|
}
|
|
getPointAtT(t) {
|
|
const [a, b, c, d] = this.points;
|
|
const res = a.copy();
|
|
res.add(Vector.add(a.copy().mult(-3), b.copy().mult(3)).mult(t));
|
|
res.add(Vector.add(Vector.add(a.copy().mult(3), b.copy().mult(-6)), c.copy().mult(3)).mult(Math.pow(t, 2)));
|
|
res.add(Vector.add(Vector.add(a.copy().mult(-1), b.copy().mult(3)), Vector.add(c.copy().mult(-3), d.copy())).mult(Math.pow(t, 3)));
|
|
return res;
|
|
}
|
|
getClosestPoint(v) {
|
|
const resolution = 1 / 25;
|
|
let closest = this.points[0];
|
|
let closestDistance = this.points[0].dist(v);
|
|
let closestT = 0;
|
|
for(let i = 0; i < 25; i++){
|
|
const point = this.getPointAtT(i * resolution);
|
|
const distance = v.dist(point);
|
|
if (distance < closestDistance) {
|
|
closest = point;
|
|
closestDistance = distance;
|
|
closestT = i * resolution;
|
|
}
|
|
}
|
|
return [
|
|
closest,
|
|
closestDistance,
|
|
closestT
|
|
];
|
|
}
|
|
getPointsWithinRadius(v, r) {
|
|
const points = [];
|
|
const resolution = 1 / 25;
|
|
for(let i = 0; i < 25; i++){
|
|
const point = this.getPointAtT(i * resolution);
|
|
const distance = v.dist(point);
|
|
if (distance < r) {
|
|
points.push([
|
|
i * resolution,
|
|
this
|
|
]);
|
|
}
|
|
}
|
|
return points;
|
|
}
|
|
tangent(t) {
|
|
const [a, b, c, d] = this.points;
|
|
const res = Vector.sub(b, a).mult(3 * Math.pow(1 - t, 2));
|
|
res.add(Vector.add(Vector.sub(c, b).mult(6 * (1 - t) * t), Vector.sub(d, c).mult(3 * Math.pow(t, 2))));
|
|
return res;
|
|
}
|
|
doesIntersectCircle(x, y, r) {
|
|
const v = new Vector(x, y);
|
|
const resolution = 1 / 25;
|
|
let distance = Infinity;
|
|
let t;
|
|
for(let i = 0; i < 25; i++){
|
|
if (i !== 25 - 1) {
|
|
const a = this.getPointAtT(i * resolution);
|
|
const b = this.getPointAtT((i + 1) * resolution);
|
|
const ac = Vector.sub(v, a);
|
|
const ab = Vector.sub(b, a);
|
|
const d = Vector.add(Vector.vectorProjection(ac, ab), a);
|
|
const ad = Vector.sub(d, a);
|
|
const k = Math.abs(ab.x) > Math.abs(ab.y) ? ad.x / ab.x : ad.y / ab.y;
|
|
let dist;
|
|
if (k <= 0.0) {
|
|
dist = Vector.hypot2(v, a);
|
|
} else if (k >= 1.0) {
|
|
dist = Vector.hypot2(v, b);
|
|
}
|
|
dist = Vector.hypot2(v, d);
|
|
if (dist < distance) {
|
|
distance = dist;
|
|
t = i * resolution;
|
|
}
|
|
}
|
|
}
|
|
if (distance < r) return t;
|
|
return false;
|
|
}
|
|
calculateApproxLength(resolution = 25) {
|
|
const stepSize = 1 / resolution;
|
|
const points = [];
|
|
for(let i = 0; i <= resolution; i++){
|
|
const current = stepSize * i;
|
|
points.push(this.getPointAtT(current));
|
|
}
|
|
this.length = points.reduce((acc, cur)=>{
|
|
const prev = acc.prev;
|
|
acc.prev = cur;
|
|
if (!prev) return acc;
|
|
acc.length += cur.dist(prev);
|
|
return acc;
|
|
}, {
|
|
prev: undefined,
|
|
length: 0
|
|
}).length;
|
|
return this.length;
|
|
}
|
|
calculateEvenlySpacedPoints(spacing, resolution = 1) {
|
|
const points = [];
|
|
points.push(this.points[0]);
|
|
let prev = points[0];
|
|
let distSinceLastEvenPoint = 0;
|
|
let t = 0;
|
|
const div = Math.ceil(this.length * resolution * 10);
|
|
while(t < 1){
|
|
t += 1 / div;
|
|
const point = this.getPointAtT(t);
|
|
distSinceLastEvenPoint += prev.dist(point);
|
|
if (distSinceLastEvenPoint >= spacing) {
|
|
const overshoot = distSinceLastEvenPoint - spacing;
|
|
const evenPoint = Vector.add(point, Vector.sub(point, prev).normalize().mult(overshoot));
|
|
distSinceLastEvenPoint = overshoot;
|
|
points.push(evenPoint);
|
|
prev = evenPoint;
|
|
}
|
|
prev = point;
|
|
}
|
|
return points;
|
|
}
|
|
}
|
|
class Track extends PathSegment {
|
|
editable = false;
|
|
next;
|
|
prev;
|
|
id;
|
|
constructor(points, next, prev){
|
|
super(points);
|
|
this.id = crypto.randomUUID();
|
|
this.next = next || this;
|
|
this.prev = prev || this;
|
|
}
|
|
getNearestPoint(p) {
|
|
let [closest, closestDistance] = this.getClosestPoint(p);
|
|
if (this.next !== this) {
|
|
const [point, distance, t] = this.next.getClosestPoint(p);
|
|
if (distance < closestDistance) {
|
|
closest = point;
|
|
closestDistance = distance;
|
|
}
|
|
}
|
|
if (this.prev !== this) {
|
|
const [point1, distance1, t1] = this.next.getClosestPoint(p);
|
|
if (distance1 < closestDistance) {
|
|
closest = point1;
|
|
closestDistance = distance1;
|
|
}
|
|
}
|
|
return closest;
|
|
}
|
|
getAllPointsInRange(v, r) {
|
|
const points = this.getPointsWithinRadius(v, r).concat(this.next.getPointsWithinRadius(v, r), this.prev.getPointsWithinRadius(v, r));
|
|
return points;
|
|
}
|
|
draw() {
|
|
super.draw();
|
|
if (this.editable) {
|
|
const [a, b, c, d] = this.points;
|
|
doodler.line(a, b);
|
|
doodler.line(c, d);
|
|
}
|
|
}
|
|
setNext(t) {
|
|
this.next = t;
|
|
this.next.points[0] = this.points[3];
|
|
}
|
|
setPrev(t) {
|
|
this.prev = t;
|
|
this.prev.points[3] = this.points[0];
|
|
}
|
|
}
|
|
class Spline {
|
|
segments = [];
|
|
ctx;
|
|
evenPoints;
|
|
pointSpacing;
|
|
get points() {
|
|
return Array.from(new Set(this.segments.flatMap((s)=>s.points)));
|
|
}
|
|
nodes;
|
|
constructor(segs){
|
|
this.segments = segs;
|
|
this.pointSpacing = 1;
|
|
this.evenPoints = this.calculateEvenlySpacedPoints(1);
|
|
this.nodes = [];
|
|
for(let i = 0; i < this.points.length; i += 3){
|
|
const node = {
|
|
anchor: this.points[i],
|
|
controls: [
|
|
this.points.at(i - 1),
|
|
this.points[(i + 1) % this.points.length]
|
|
],
|
|
mirrored: false,
|
|
tangent: true
|
|
};
|
|
this.nodes.push(node);
|
|
}
|
|
}
|
|
setContext(ctx) {
|
|
this.ctx = ctx;
|
|
for (const segment of this.segments){
|
|
segment.setContext(ctx);
|
|
}
|
|
}
|
|
draw() {
|
|
for (const segment of this.segments){
|
|
segment.draw();
|
|
}
|
|
}
|
|
calculateEvenlySpacedPoints(spacing, resolution = 1) {
|
|
this.pointSpacing = 1;
|
|
const points = [];
|
|
points.push(this.segments[0].points[0]);
|
|
let prev = points[0];
|
|
let distSinceLastEvenPoint = 0;
|
|
for (const seg of this.segments){
|
|
let t = 0;
|
|
const div = Math.ceil(seg.length * resolution * 10);
|
|
while(t < 1){
|
|
t += 1 / div;
|
|
const point = seg.getPointAtT(t);
|
|
distSinceLastEvenPoint += prev.dist(point);
|
|
if (distSinceLastEvenPoint >= spacing) {
|
|
const overshoot = distSinceLastEvenPoint - spacing;
|
|
const evenPoint = Vector.add(point, Vector.sub(point, prev).normalize().mult(overshoot));
|
|
distSinceLastEvenPoint = overshoot;
|
|
points.push(evenPoint);
|
|
prev = evenPoint;
|
|
}
|
|
prev = point;
|
|
}
|
|
}
|
|
this.evenPoints = points;
|
|
return points;
|
|
}
|
|
followEvenPoints(t) {
|
|
if (t < 0) t += this.evenPoints.length;
|
|
const i = Math.floor(t);
|
|
const a = this.evenPoints[i];
|
|
const b = this.evenPoints[(i + 1) % this.evenPoints.length];
|
|
return Vector.lerp(a, b, t % 1);
|
|
}
|
|
calculateApproxLength() {
|
|
for (const s of this.segments){
|
|
s.calculateApproxLength();
|
|
}
|
|
}
|
|
toggleNodeTangent(p) {
|
|
const node = this.nodes.find((n)=>n.anchor === p);
|
|
node && (node.tangent = !node.tangent);
|
|
}
|
|
toggleNodeMirrored(p) {
|
|
const node = this.nodes.find((n)=>n.anchor === p);
|
|
node && (node.mirrored = !node.mirrored);
|
|
}
|
|
handleNodeEdit(p, movement) {
|
|
const node = this.nodes.find((n)=>n.anchor === p || n.controls.includes(p));
|
|
if (!node || !(node.mirrored || node.tangent)) return;
|
|
if (node.anchor !== p) {
|
|
if (node.mirrored || node.tangent) {
|
|
const mover = node.controls.find((e)=>e !== p);
|
|
const v = Vector.sub(node.anchor, p);
|
|
if (!node.mirrored) v.setMag(Vector.sub(node.anchor, mover).mag());
|
|
mover.set(Vector.add(v, node.anchor));
|
|
}
|
|
} else {
|
|
for (const control of node.controls){
|
|
control.add(movement.x, movement.y);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const generateSquareTrack = ()=>{
|
|
const first = new Track([
|
|
new Vector(20, 40),
|
|
new Vector(20, 100),
|
|
new Vector(20, 300),
|
|
new Vector(20, 360)
|
|
]);
|
|
const second = new Track([
|
|
first.points[3],
|
|
new Vector(20, 370),
|
|
new Vector(30, 380),
|
|
new Vector(40, 380)
|
|
]);
|
|
const third = new Track([
|
|
second.points[3],
|
|
new Vector(100, 380),
|
|
new Vector(300, 380),
|
|
new Vector(360, 380)
|
|
]);
|
|
const fourth = new Track([
|
|
third.points[3],
|
|
new Vector(370, 380),
|
|
new Vector(380, 370),
|
|
new Vector(380, 360)
|
|
]);
|
|
const fifth = new Track([
|
|
fourth.points[3],
|
|
new Vector(380, 300),
|
|
new Vector(380, 100),
|
|
new Vector(380, 40)
|
|
]);
|
|
const sixth = new Track([
|
|
fifth.points[3],
|
|
new Vector(380, 30),
|
|
new Vector(370, 20),
|
|
new Vector(360, 20)
|
|
]);
|
|
const seventh = new Track([
|
|
sixth.points[3],
|
|
new Vector(300, 20),
|
|
new Vector(100, 20),
|
|
new Vector(40, 20)
|
|
]);
|
|
const eighth = new Track([
|
|
seventh.points[3],
|
|
new Vector(30, 20),
|
|
new Vector(20, 30),
|
|
first.points[0]
|
|
]);
|
|
const tracks = [
|
|
first,
|
|
second,
|
|
third,
|
|
fourth,
|
|
fifth,
|
|
sixth,
|
|
seventh,
|
|
eighth
|
|
];
|
|
for (const [i, track] of tracks.entries()){
|
|
track.next = tracks[(i + 1) % tracks.length];
|
|
track.prev = tracks.at(i - 1);
|
|
}
|
|
return new Spline([
|
|
first,
|
|
second,
|
|
third,
|
|
fourth,
|
|
fifth,
|
|
sixth,
|
|
seventh,
|
|
eighth
|
|
]);
|
|
};
|
|
const loadFromJson = ()=>{
|
|
const json = JSON.parse(localStorage.getItem('railPath') || '');
|
|
if (!json) return generateSquareTrack();
|
|
const segments = [];
|
|
for (const { points } of json.segments){
|
|
segments.push(new Track(points.map((p)=>new Vector(p.x, p.y))));
|
|
}
|
|
for (const [i, s] of segments.entries()){
|
|
s.setNext(segments[(i + 1) % segments.length]);
|
|
s.setPrev(segments.at(i - 1));
|
|
}
|
|
return new Spline(segments);
|
|
};
|
|
const engineSprites = document.createElement('img');
|
|
engineSprites.src = './sprites/EngineSprites.png';
|
|
engineSprites.style.display = 'none';
|
|
engineSprites.id = 'engine-sprites';
|
|
document.body.append(engineSprites);
|
|
init({
|
|
width: 400,
|
|
height: 400,
|
|
bg: '#333'
|
|
});
|
|
const path = loadFromJson();
|
|
let speed = 1;
|
|
const car = new TrainCar(55, engineSprites, 80, 20, {
|
|
at: new Vector(0, 80),
|
|
height: 20,
|
|
width: 80
|
|
});
|
|
const train = new Train(path, [
|
|
car
|
|
]);
|
|
let dragEndCounter = 0;
|
|
let selectedNode;
|
|
doodler.createLayer(()=>{
|
|
for(let i = 0; i < path.evenPoints.length; i += 10){
|
|
const p = path.evenPoints[i];
|
|
const next = path.evenPoints[(i + 1) % path.evenPoints.length];
|
|
const last = path.evenPoints.at(i - 1);
|
|
if (!last) break;
|
|
const tan = Vector.sub(last, next);
|
|
doodler.drawRotated(p, tan.heading(), ()=>{
|
|
doodler.line(p, p.copy().add(0, 10), {
|
|
color: '#291b17',
|
|
weight: 4
|
|
});
|
|
doodler.line(p, p.copy().add(0, -10), {
|
|
color: '#291b17',
|
|
weight: 4
|
|
});
|
|
doodler.line(p.copy().add(-6, 5), p.copy().add(6, 5), {
|
|
color: 'grey',
|
|
weight: 2
|
|
});
|
|
doodler.line(p.copy().add(-6, -5), p.copy().add(6, -5), {
|
|
color: 'grey',
|
|
weight: 2
|
|
});
|
|
});
|
|
}
|
|
path.draw();
|
|
train.move();
|
|
selectedNode?.anchor.drawDot();
|
|
selectedNode?.controls.forEach((e)=>e.drawDot());
|
|
});
|
|
let editable = false;
|
|
const clickables = new Map();
|
|
let selectedPoint;
|
|
document.addEventListener('keyup', (e)=>{
|
|
if (e.key === 'd') {}
|
|
if (e.key === 'ArrowUp') {
|
|
speed += .1;
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
speed -= .1;
|
|
}
|
|
if (e.key === 'm' && selectedPoint) {
|
|
const points = path.points;
|
|
const index = points.findIndex((p)=>p === selectedPoint);
|
|
if (index > -1) {
|
|
const prev = points.at(index - 1);
|
|
const next = points[(index + 1) % points.length];
|
|
const toPrev = Vector.sub(prev, selectedPoint);
|
|
toPrev.setMag(next.dist(selectedPoint));
|
|
toPrev.rotate(Math.PI);
|
|
const toNext = Vector.add(toPrev, selectedPoint);
|
|
next.set(toNext);
|
|
path.calculateApproxLength();
|
|
path.calculateEvenlySpacedPoints(1);
|
|
}
|
|
}
|
|
if (e.key === 'e') {
|
|
editable = !editable;
|
|
for (const t of path.segments){
|
|
t.editable = !t.editable;
|
|
for (const p of t.points){
|
|
if (t.editable) {
|
|
doodler.registerDraggable(p, 10);
|
|
doodler.addDragEvents({
|
|
point: p,
|
|
onDragEnd: ()=>{
|
|
dragEndCounter++;
|
|
t.length = t.calculateApproxLength(100);
|
|
path.evenPoints = path.calculateEvenlySpacedPoints(1);
|
|
},
|
|
onDrag: (movement)=>{
|
|
path.handleNodeEdit(p, movement);
|
|
}
|
|
});
|
|
} else {
|
|
doodler.unregisterDraggable(p);
|
|
}
|
|
}
|
|
}
|
|
for (const p1 of path.points){
|
|
if (editable) {
|
|
const onClick = ()=>{
|
|
selectedPoint = p1;
|
|
selectedNode = path.nodes.find((e)=>e.anchor === p1 || e.controls.includes(p1));
|
|
};
|
|
clickables.set(p1, onClick);
|
|
doodler.registerClickable(p1.copy().sub(10, 10), p1.copy().add(10, 10), onClick);
|
|
} else {
|
|
const the = clickables.get(p1);
|
|
doodler.unregisterClickable(the);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
document.addEventListener('keydown', (e)=>{
|
|
if (e.ctrlKey && e.key === 's') {
|
|
e.preventDefault();
|
|
path.segments.forEach((s)=>{
|
|
s.next = s.next.id;
|
|
s.prev = s.prev.id;
|
|
delete s.ctx;
|
|
});
|
|
delete path.ctx;
|
|
const json = JSON.stringify(path);
|
|
localStorage.setItem('railPath', json);
|
|
}
|
|
});
|