/*
title: Chromatic Automata - Screen Wrap
files:
head
pointlist
point
stage
mouse
dragging
stroke
---
456 colour creatures (56 large, 400 small), each half-sized and half-speed seek similar hues, blend on contact, and merge with close matches.
Drag them into each other to play genetic matchmaker. Big creatures that go 800 frames without merging burst into a random number of differently sized, random-coloured offspring while conserving mass.
*/
class MainStage extends Stage {
canvas = 'playspace'
mounted() {
this.points = new PointList()
const cx = this.canvas.width / 2, cy = this.canvas.height / 2
for (let i = 0; i < 56; i++) {
const a = i * Math.PI * 2 / 56, r = 95 + (i % 6) * 25
const p = new Point({x: cx + Math.cos(a)*r, y: cy + Math.sin(a)*r, radius: 4})
p.hue = Math.floor(Math.random()*12)*30
p.vx = (Math.random()-.5)*1
p.vy = (Math.random()-.5)*1
p.mass = .25
p.lastRadius = p.radius
p.speedScale = .5
p.cool = 0
p.hunger = 0
this.points.push(p)
}
// Two hundred half-sized, slower wanderers scattered throughout the stage.
for (let i = 0; i < 400; i++) {
const p = new Point({
x: 8 + Math.random() * Math.max(1, this.canvas.width - 16),
y: 8 + Math.random() * Math.max(1, this.canvas.height - 16),
radius: 2
})
p.hue = Math.floor(Math.random() * 12) * 30
p.vx = (Math.random() - .5) * .4
p.vy = (Math.random() - .5) * .4
p.mass = .0625
p.lastRadius = p.radius
p.cool = 0
p.speedScale = .225
this.points.push(p)
}
this.dragging.add(...this.points)
}
hueDiff(a,b) {
const d = Math.abs(a-b)%360
return Math.min(d, 360-d)
}
mixHue(a,b) {
const x = Math.cos(a*Math.PI/180)+Math.cos(b*Math.PI/180)
const y = Math.sin(a*Math.PI/180)+Math.sin(b*Math.PI/180)
return (Math.atan2(y,x)*180/Math.PI+360)%360
}
syncMouseMass() {
// A mouse resize changes Point.radius directly; mass follows its area.
// Automatic merge and split operations already update both together.
for (const p of this.points) {
const current = Math.max(1, Number(p.radius) || 1)
if (p.lastRadius !== undefined && Math.abs(current - p.lastRadius) > .001) {
p.mass = (current / 8) ** 2
}
p.lastRadius = current
}
}
step() {
this.syncMouseMass()
const ps = this.points
const width = this.canvas.width, height = this.canvas.height
for (const p of ps) {
if (p.cool > 0) p.cool--
p.hunger = (p.hunger || 0) + 1
let friend = null, best = Infinity
for (const q of ps) {
if (p === q) continue
const dx=q.x-p.x, dy=q.y-p.y
const dist=Math.hypot(dx,dy)||.001
const hue=this.hueDiff(p.hue,q.hue)
// Smaller creatures steer away from much larger neighbours.
const ratio = q.radius / Math.max(1, p.radius)
if (ratio > 1.4 && dist < 12 + q.radius * 5) {
const force = Math.min(2, ratio - 1.4) * (1 - dist / (24 + q.radius * 5)) * .12
p.vx -= dx / dist * force * p.speedScale
p.vy -= dy / dist * force * p.speedScale
}
const score = hue*3 + dist*.12
if (score < best) {best=score; friend=q}
// Gently repel neighbours so the screen stays lively.
const personalSpace = 10 + p.radius + q.radius
if (dist < personalSpace && dist > 0) {
const force = (personalSpace-dist)/personalSpace*.13 * p.speedScale
p.vx -= dx/dist*force
p.vy -= dy/dist*force
}
}
if (friend) {
const dx=friend.x-p.x, dy=friend.y-p.y
const dist=Math.hypot(dx,dy)||1
p.vx += dx/dist*.055*p.speedScale
p.vy += dy/dist*.055*p.speedScale
}
p.vx += (Math.random()-.5)*.08*p.speedScale
p.vy += (Math.random()-.5)*.08*p.speedScale
p.vx *= .97; p.vy *= .97
const speed=Math.hypot(p.vx,p.vy)
const limit = 2.3 * p.speedScale
if(speed>limit) {p.vx*=limit/speed; p.vy*=limit/speed}
if (Math.hypot(p.vx, p.vy) > 0.03) p.radians = Math.atan2(p.vy, p.vx)
p.x+=p.vx; p.y+=p.vy
// Toroidal screen wrapping: preserve heading and velocity.
if (p.x < -p.radius) p.x = width + p.radius
else if (p.x > width + p.radius) p.x = -p.radius
if (p.y < -p.radius) p.y = height + p.radius
else if (p.y > height + p.radius) p.y = -p.radius
}
const gone = new Set()
for (let i=0;i
= a.radius+b.radius)continue
const difference=this.hueDiff(a.hue,b.hue)
if(difference < 18 && ps.length-gone.size>3) {
const total=a.mass+b.mass
a.x=(a.x*a.mass+b.x*b.mass)/total
a.y=(a.y*a.mass+b.y*b.mass)/total
a.vx=(a.vx*a.mass+b.vx*b.mass)/total
a.vy=(a.vy*a.mass+b.vy*b.mass)/total
a.radians=Math.atan2(a.vy,a.vx)
a.hue=this.mixHue(a.hue,b.hue)
a.mass=total
a.speedScale=(a.speedScale*(total-b.mass)+b.speedScale*b.mass)/total
a.radius=Math.min(17,8*Math.sqrt(total))
a.lastRadius=a.radius
a.cool=25
a.hunger=0
gone.add(b)
} else {
const blended=this.mixHue(a.hue,b.hue)
a.hue=(a.hue*.7+blended*.3)%360
b.hue=(b.hue*.7+blended*.3)%360
a.cool=b.cool=22
const nx=dx/(dist||1),ny=dy/(dist||1)
a.vx-=nx*.35*a.speedScale;a.vy-=ny*.35*a.speedScale
b.vx+=nx*.35*b.speedScale;b.vy+=ny*.35*b.speedScale
}
}
}
// Starved adults burst into a random number of unequal pieces.
// Offspring masses sum to the parent's mass (last takes the remainder).
const newborns = []
let population = ps.length - gone.size
for (const p of ps) {
if (gone.has(p) || p.mass < .375 || p.hunger < 800) continue
const maxPieces = Math.min(7, Math.floor(p.mass / .0625), 800 - population + 1)
if (maxPieces < 2) continue
const count = 2 + Math.floor(Math.random() * (maxPieces - 1))
const weights = Array.from({length: count}, () => .3 + Math.random() * 2)
const weightSum = weights.reduce((a,b) => a+b,0)
let remaining = p.mass
const baseAngle = Math.random() * Math.PI * 2
const offspring = []
for (let i = 0; i < count; i++) {
const childMass = i === count - 1 ? remaining : p.mass * weights[i] / weightSum
remaining -= childMass
// Radius represents mass, not a mass deduction.
const childRadius = Math.max(1, Math.min(17, 8 * Math.sqrt(childMass)))
const theta = baseAngle + i * 2 * Math.PI / count
const offset = p.radius + childRadius + 2
const child = new Point({
x: Math.max(childRadius, Math.min(width-childRadius, p.x + Math.cos(theta)*offset)),
y: Math.max(childRadius, Math.min(height-childRadius, p.y + Math.sin(theta)*offset)),
radius: childRadius
})
child.mass = childMass
child.lastRadius = childRadius
child.hue = Math.random() * 360
child.vx = p.vx + Math.cos(theta) * .35
child.vy = p.vy + Math.sin(theta) * .35
child.speedScale = Math.max(.225, p.speedScale * .85)
child.cool = 45
child.hunger = 0
child.radians = Math.atan2(child.vy, child.vx)
offspring.push(child)
}
gone.add(p)
newborns.push(...offspring)
population += count - 1
}
if(gone.size || newborns.length) {
// Keep the surviving Point objects and re-register dragging.
const survivors=Array.from(ps).filter(p=>!gone.has(p))
this.points=new PointList(...survivors, ...newborns)
this.dragging.add(...newborns)
}
}
draw(ctx) {
this.clear(ctx)
this.step()
for(const p of this.points) {
p.pen.indicator(ctx, {color:`hsl(${p.hue} 85% 65%)`, width: Math.max(2,p.radius/5)})
}
}
}
const stage=MainStage.go()