mirror of
https://github.com/osmarks/website
synced 2024-12-23 16:40:31 +00:00
new experiment, fix comments
This commit is contained in:
parent
d9a3eeae1e
commit
356f79ad02
334
experiments/wave-function-collapse/index.html
Normal file
334
experiments/wave-function-collapse/index.html
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
---
|
||||||
|
title: Wave Function Collapse
|
||||||
|
description: A basic implementation of the WFC procedural generation algorithm.
|
||||||
|
slug: wfc
|
||||||
|
---
|
||||||
|
<style>
|
||||||
|
canvas {
|
||||||
|
border: 1px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
#colors .color {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: 5px;
|
||||||
|
border: 1px solid gray;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<canvas id="output"></canvas>
|
||||||
|
<canvas id="pattern"></canvas>
|
||||||
|
<div id="controls">
|
||||||
|
<button id="step">Step</button>
|
||||||
|
<button id="run">Run</button>
|
||||||
|
<button id="reset">Reset</button>
|
||||||
|
<input type="checkbox" id="hard-borders" checked><label for="hard-borders">Hard borders</label>
|
||||||
|
<input type="checkbox" id="ignore-directions" checked><label for="ignore-directions">Ignore directions</label>
|
||||||
|
<input type="checkbox" id="diagonals" checked><label for="diagonals">Diagonals</label>
|
||||||
|
</div>
|
||||||
|
<div id="colors"></div>
|
||||||
|
<div id="out"></div>
|
||||||
|
<script>
|
||||||
|
const write = line => {
|
||||||
|
const out = document.querySelector("#out")
|
||||||
|
out.innerHTML = ""
|
||||||
|
out.appendChild(document.createTextNode(line))
|
||||||
|
out.appendChild(document.createElement("br"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const PATTERN_W = 6
|
||||||
|
const W = 16
|
||||||
|
const PX = 24
|
||||||
|
const PATTERN_PX = 64
|
||||||
|
|
||||||
|
const map = ([x, y]) => x + y * W
|
||||||
|
const unmap = a => [a % W, Math.floor(a / W)]
|
||||||
|
const vsum = ([a, b], [c, d]) => [a + c, b + d]
|
||||||
|
const inRange = ([x, y]) => x >= 0 && x < W && y >= 0 && y < W
|
||||||
|
const modclamp = x => x < 0 ? 10 + x : x % 10
|
||||||
|
|
||||||
|
let currentConstraintSet = {}
|
||||||
|
let colorsInUse = new Set()
|
||||||
|
|
||||||
|
let ignoreDirections = false
|
||||||
|
const readIgnoreDirections = () => ignoreDirections = document.querySelector("#ignore-directions").checked
|
||||||
|
readIgnoreDirections()
|
||||||
|
document.querySelector("#ignore-directions").addEventListener("change", readIgnoreDirections)
|
||||||
|
let hardBorders = false
|
||||||
|
const readHardBorders = () => hardBorders = document.querySelector("#hard-borders").checked
|
||||||
|
readHardBorders()
|
||||||
|
document.querySelector("#hard-borders").addEventListener("change", readHardBorders)
|
||||||
|
|
||||||
|
const getAdjacent = diagonals => diagonals ? [[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [1, -1], [-1, 1], [-1, -1]] : [[0, 1], [0, -1], [1, 0], [-1, 0]]
|
||||||
|
|
||||||
|
let adj = null
|
||||||
|
|
||||||
|
const ctx = document.querySelector("canvas#output").getContext("2d")
|
||||||
|
ctx.canvas.width = PX * W
|
||||||
|
ctx.canvas.height = PX * W
|
||||||
|
|
||||||
|
let grid = Array(W * W).fill(null).map(x => ({ value: null, options: new Set() }))
|
||||||
|
|
||||||
|
const joinConstraintKey = adjs => {
|
||||||
|
if (ignoreDirections) adjs.sort()
|
||||||
|
return adjs.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
const regenerateOptionsAt = (coord) => {
|
||||||
|
const data = grid[map(coord)]
|
||||||
|
const slots = []
|
||||||
|
for (const a of adj) {
|
||||||
|
const n = vsum(a, coord)
|
||||||
|
if (inRange(n)) {
|
||||||
|
const value = grid[map(n)].value
|
||||||
|
slots.push(value === null ? undefined : value)
|
||||||
|
} else {
|
||||||
|
slots.push(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slots.every(x => x === undefined)) {
|
||||||
|
data.options = colorsInUse // no constraints apply - shortcut
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const out = new Set()
|
||||||
|
let hasMatch = false
|
||||||
|
// N-way Cartesian product of slots
|
||||||
|
const go = (slots, acc) => {
|
||||||
|
if (slots.length === 0) {
|
||||||
|
for (const prod of acc) {
|
||||||
|
const ckey = joinConstraintKey(prod)
|
||||||
|
if (currentConstraintSet[ckey]) {
|
||||||
|
for (const val of currentConstraintSet[ckey]) {
|
||||||
|
out.add(val)
|
||||||
|
}
|
||||||
|
hasMatch = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const [fst, ...rest] = slots
|
||||||
|
if (fst === undefined) {
|
||||||
|
return go(rest, Array.from(colorsInUse).concat(hardBorders ? [] : [null]).flatMap(x => acc.map(xs => [x].concat(xs))))
|
||||||
|
} else {
|
||||||
|
return go(rest, acc.map(xs => [fst].concat(xs)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go(slots, [[]])
|
||||||
|
data.options = out
|
||||||
|
}
|
||||||
|
|
||||||
|
const regenerateOptions = () => {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
for (let y = 0; y < W; y++) {
|
||||||
|
regenerateOptionsAt([x, y])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readAdjacent = () => {
|
||||||
|
adj = getAdjacent(document.querySelector("#diagonals").checked)
|
||||||
|
regenerateOptions()
|
||||||
|
}
|
||||||
|
readAdjacent()
|
||||||
|
document.querySelector("#diagonals").addEventListener("change", readAdjacent)
|
||||||
|
|
||||||
|
const updatePos = (pos, value) => {
|
||||||
|
grid[map(pos)].value = value
|
||||||
|
grid[map(pos)].options = null
|
||||||
|
for (const a of adj) {
|
||||||
|
const n = vsum(a, pos)
|
||||||
|
if (inRange(n) && grid[map(n)].value === null && grid[map(n)].options !== null) {
|
||||||
|
regenerateOptionsAt(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const findBestCandidates = grid => grid.reduce(([bestQty, bestPos], val, index) => {
|
||||||
|
if (val.value !== null) {
|
||||||
|
return [bestQty, bestPos]
|
||||||
|
}
|
||||||
|
if (val.options.size < bestQty) {
|
||||||
|
return [val.options.size, [unmap(index)]]
|
||||||
|
}
|
||||||
|
if (val.options.size == bestQty) {
|
||||||
|
bestPos.push(unmap(index))
|
||||||
|
}
|
||||||
|
return [bestQty, bestPos]
|
||||||
|
}, [colorsInUse.size + 1, []])
|
||||||
|
|
||||||
|
const render = grid => {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
for (let y = 0; y < W; y++) {
|
||||||
|
const data = grid[map([x, y])]
|
||||||
|
const level = data.options && Math.floor((data.options.size + 1) / (colorsInUse.size + 1) * 255).toString(16).padStart(2, "0") || "00"
|
||||||
|
ctx.fillStyle = data.value !== null ? data.value : `#${level}${level}${level}`
|
||||||
|
ctx.fillRect(x * PX, y * PX, PX, PX)
|
||||||
|
if (data.value === null) {
|
||||||
|
ctx.strokeStyle = "#000000"
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x * PX, y * PX)
|
||||||
|
ctx.lineTo((x + 1) * PX, (y + 1) * PX)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.strokeStyle = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let recentColors = ["#ffffff", "#000000", "#ff0000", "#00ff00", "#0000ff", "#ffff00", "#00ffff", "#ff00ff"]
|
||||||
|
const recentColorsDiv = document.querySelector("#colors")
|
||||||
|
let currentColor = recentColors[0]
|
||||||
|
|
||||||
|
const pushColor = color => {
|
||||||
|
recentColors = [color].concat(recentColors.filter(x => x !== color))
|
||||||
|
if (recentColors.length > 10) {
|
||||||
|
recentColors.shift()
|
||||||
|
}
|
||||||
|
currentColor = color
|
||||||
|
updateRecentColors()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRecentColors = () => {
|
||||||
|
recentColorsDiv.innerHTML = ""
|
||||||
|
for (const color of recentColors) {
|
||||||
|
recentColorsDiv.appendChild(document.createElement("div"))
|
||||||
|
recentColorsDiv.lastChild.classList.add("color")
|
||||||
|
recentColorsDiv.lastChild.style.backgroundColor = color
|
||||||
|
recentColorsDiv.lastChild.addEventListener("click", () => {
|
||||||
|
pushColor(color)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCanvasMouse = ev => {
|
||||||
|
if ((ev.buttons & 1) === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [x, y] = [Math.floor(ev.offsetX / PX), Math.floor(ev.offsetY / PX)]
|
||||||
|
if (x < 0 || x >= W || y < 0 || y >= W) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const coord = [x, y]
|
||||||
|
const data = grid[map(coord)]
|
||||||
|
updatePos(coord, currentColor)
|
||||||
|
render(grid)
|
||||||
|
}
|
||||||
|
ctx.canvas.addEventListener("mousedown", handleCanvasMouse)
|
||||||
|
ctx.canvas.addEventListener("mousemove", handleCanvasMouse)
|
||||||
|
|
||||||
|
const pick = arr => arr[Math.floor(Math.random() * arr.length)]
|
||||||
|
|
||||||
|
render(grid)
|
||||||
|
|
||||||
|
step.onclick = () => {
|
||||||
|
const [qty, pos] = findBestCandidates(grid)
|
||||||
|
if (pos.length === 0) {
|
||||||
|
write("Done.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (qty === 0) {
|
||||||
|
write("Contradiction.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
write(`${qty} options on ${pos.length} tiles.`)
|
||||||
|
if (qty === 1) {
|
||||||
|
write(`Resolving ${pos.length} tiles.`)
|
||||||
|
for (const p of pos) {
|
||||||
|
const newValue = Array.from(grid[map(p)].options)[0]
|
||||||
|
updatePos(p, newValue)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const p = pick(pos)
|
||||||
|
const newValue = pick(Array.from(grid[map(p)].options))
|
||||||
|
updatePos(p, newValue)
|
||||||
|
}
|
||||||
|
render(grid)
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer
|
||||||
|
const runButton = document.querySelector("#run")
|
||||||
|
runButton.addEventListener("click", () => {
|
||||||
|
if (runButton.innerHTML === "Run") {
|
||||||
|
runButton.innerHTML = "Stop"
|
||||||
|
timer = setInterval(step.onclick, 100)
|
||||||
|
} else {
|
||||||
|
runButton.innerHTML = "Run"
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
grid = Array(W * W).fill(null).map(x => ({ value: null, options: new Set() }))
|
||||||
|
clearInterval(timer)
|
||||||
|
runButton.innerHTML = "Run"
|
||||||
|
render(grid)
|
||||||
|
regenerateOptions()
|
||||||
|
}
|
||||||
|
document.querySelector("#reset").addEventListener("click", reset)
|
||||||
|
|
||||||
|
const patternEditor = () => {
|
||||||
|
const map = ([x, y]) => x + y * PATTERN_W
|
||||||
|
const pattern = document.querySelector("canvas#pattern")
|
||||||
|
const ctx = pattern.getContext("2d")
|
||||||
|
|
||||||
|
let grid = Array(PATTERN_W * PATTERN_W).fill(null).map(x => ({ value: currentColor }))
|
||||||
|
const inRange = ([x, y]) => x >= 0 && x < PATTERN_W && y >= 0 && y < PATTERN_W
|
||||||
|
|
||||||
|
pattern.width = PATTERN_PX * PATTERN_W
|
||||||
|
pattern.height = PATTERN_PX * PATTERN_W
|
||||||
|
|
||||||
|
ctx.fillStyle = currentColor
|
||||||
|
ctx.fillRect(0, 0, PATTERN_PX * PATTERN_W, PATTERN_PX * PATTERN_W)
|
||||||
|
|
||||||
|
const constraintKeyFor = coord => {
|
||||||
|
let constraintKey = []
|
||||||
|
for (const a of adj) {
|
||||||
|
const n = vsum(a, coord)
|
||||||
|
if (inRange(n)) {
|
||||||
|
constraintKey.push(grid[map(n)].value)
|
||||||
|
} else {
|
||||||
|
constraintKey.push(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return joinConstraintKey(constraintKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const recomputeConstraintSet = () => {
|
||||||
|
currentConstraintSet = new Set()
|
||||||
|
colorsInUse = new Set()
|
||||||
|
for (let x = 0; x < PATTERN_W; x++) {
|
||||||
|
for (let y = 0; y < PATTERN_W; y++) {
|
||||||
|
const coord = [x, y]
|
||||||
|
const data = grid[map(coord)]
|
||||||
|
const constraintKey = constraintKeyFor(coord)
|
||||||
|
currentConstraintSet[constraintKey] ??= new Set()
|
||||||
|
currentConstraintSet[constraintKey].add(data.value)
|
||||||
|
colorsInUse.add(data.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
regenerateOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePatternMouse = ev => {
|
||||||
|
if ((ev.buttons & 1) === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [x, y] = [Math.floor(ev.offsetX / PATTERN_PX), Math.floor(ev.offsetY / PATTERN_PX)]
|
||||||
|
if (x < 0 || x >= PATTERN_W || y < 0 || y >= PATTERN_W) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.fillStyle = currentColor
|
||||||
|
ctx.fillRect(PATTERN_PX * x, PATTERN_PX * y, PATTERN_PX, PATTERN_PX)
|
||||||
|
grid[map([x, y])].value = currentColor
|
||||||
|
recomputeConstraintSet()
|
||||||
|
}
|
||||||
|
pattern.addEventListener("mousedown", handlePatternMouse)
|
||||||
|
pattern.addEventListener("mousemove", handlePatternMouse)
|
||||||
|
|
||||||
|
recomputeConstraintSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRecentColors()
|
||||||
|
patternEditor()
|
||||||
|
</script>
|
@ -1 +1 @@
|
|||||||
.name.svelte-m0gh76{font-weight:600}.internal.svelte-m0gh76{background:linear-gradient(to right,red,lime,blue);background-clip:text;color:transparent}textarea.svelte-1xxfb50{width:100%;resize:vertical}.error.svelte-1xxfb50{padding:.5em}.controls.svelte-1xxfb50{display:flex;justify-content:space-between}.children.svelte-10v2gkf.svelte-10v2gkf{padding-left:.5em;border-left:3px solid black}.comment.svelte-10v2gkf.svelte-10v2gkf{margin-top:.5em;margin-bottom:1.5em}.comment.svelte-10v2gkf .content.svelte-10v2gkf{padding:.25em}.comment.svelte-10v2gkf .container.svelte-10v2gkf{display:flex}.comment.svelte-10v2gkf .container .avatar.svelte-10v2gkf{width:3em;height:3em;margin-right:1em}.comment.svelte-10v2gkf.svelte-10v2gkf:last-of-type{margin-bottom:none}.controls.svelte-10v2gkf a.svelte-10v2gkf{text-decoration:none;color:#76cd26}
|
.name.svelte-m0gh76{font-weight:600}.internal.svelte-m0gh76{background:linear-gradient(to right,red,lime,blue);background-clip:text;color:transparent}textarea.svelte-1xxfb50{width:100%;resize:vertical}.error.svelte-1xxfb50{padding:.5em}.controls.svelte-1xxfb50{display:flex;justify-content:space-between}.children.svelte-1v7b0ag.svelte-1v7b0ag{padding-left:.5em;border-left:3px solid black}@media (prefers-color-scheme: dark){.children.svelte-1v7b0ag.svelte-1v7b0ag{border-left:3px solid white}}.comment.svelte-1v7b0ag.svelte-1v7b0ag{margin-top:.5em;margin-bottom:1.5em}.comment.svelte-1v7b0ag .content.svelte-1v7b0ag{padding:.25em}.comment.svelte-1v7b0ag .container.svelte-1v7b0ag{display:flex}.comment.svelte-1v7b0ag .container .avatar.svelte-1v7b0ag{width:3em;height:3em;margin-right:1em}.comment.svelte-1v7b0ag.svelte-1v7b0ag:last-of-type{margin-bottom:none}.controls.svelte-1v7b0ag a.svelte-1v7b0ag{text-decoration:none;color:#76cd26}.error.svelte-12gp6sl{padding:.5em;background:salmon;margin-top:.5em;margin-bottom:.5em}
|
||||||
|
@ -118,7 +118,7 @@ const renderContainer = (tokens, idx) => {
|
|||||||
if (opening) {
|
if (opening) {
|
||||||
if (blockType === "captioned") {
|
if (blockType === "captioned") {
|
||||||
const link = `<a href="${md.utils.escapeHtml(options.src)}">`
|
const link = `<a href="${md.utils.escapeHtml(options.src)}">`
|
||||||
return `<div class="${options.wide ? "caption wider" : "caption"}">${options.link ? link : ""}<img src="${md.utils.escapeHtml(options.src)}">${options.link ? "</a>" : ""}`
|
return `${options.wide ? "<div class=wider>" : ""}<div class=caption>${options.link ? link : ""}<img src="${md.utils.escapeHtml(options.src)}">${options.link ? "</a>" : ""}`
|
||||||
} else if (blockType === "epigraph") {
|
} else if (blockType === "epigraph") {
|
||||||
return `<div class="epigraph"><div>`
|
return `<div class="epigraph"><div>`
|
||||||
} else if (blockType === "buttons") {
|
} else if (blockType === "buttons") {
|
||||||
@ -130,7 +130,7 @@ const renderContainer = (tokens, idx) => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (blockType === "captioned") {
|
if (blockType === "captioned") {
|
||||||
return `</div>`
|
return options.wide ? `</div></div>` : `</div>`
|
||||||
} else if (blockType === "epigraph") {
|
} else if (blockType === "epigraph") {
|
||||||
let ret = `</div></div>`
|
let ret = `</div></div>`
|
||||||
if (options.attribution) {
|
if (options.attribution) {
|
||||||
|
@ -182,6 +182,9 @@ button, select, input, textarea, .textarea
|
|||||||
padding: 1em
|
padding: 1em
|
||||||
margin-bottom: 0.5em
|
margin-bottom: 0.5em
|
||||||
margin-top: 0.5em
|
margin-top: 0.5em
|
||||||
|
box-sizing: border-box
|
||||||
|
> a
|
||||||
|
display: block
|
||||||
img, picture
|
img, picture
|
||||||
width: 100%
|
width: 100%
|
||||||
p
|
p
|
||||||
@ -193,11 +196,10 @@ blockquote
|
|||||||
margin-left: 0.2rem
|
margin-left: 0.2rem
|
||||||
|
|
||||||
.wider
|
.wider
|
||||||
|
> *
|
||||||
width: calc(100vw - 2 * $content-margin)
|
width: calc(100vw - 2 * $content-margin)
|
||||||
max-width: 80em
|
max-width: 80em
|
||||||
min-width: 40em
|
min-width: calc(40rem - 2 * $content-margin)
|
||||||
> *
|
|
||||||
min-width: 40em
|
|
||||||
position: relative
|
position: relative
|
||||||
z-index: 1
|
z-index: 1
|
||||||
|
|
||||||
@ -258,6 +260,11 @@ blockquote
|
|||||||
main.fullwidth
|
main.fullwidth
|
||||||
margin-left: $content-margin
|
margin-left: $content-margin
|
||||||
margin-right: $content-margin
|
margin-right: $content-margin
|
||||||
|
.wider
|
||||||
|
> *
|
||||||
|
width: calc(100vw - 2 * $content-margin - $navbar-width)
|
||||||
|
max-width: 80em
|
||||||
|
min-width: calc(40rem - 2 * $content-margin - $navbar-width)
|
||||||
|
|
||||||
$hl-border: 3px
|
$hl-border: 3px
|
||||||
.footnote-item.hl1, .footnote-item.hl2
|
.footnote-item.hl1, .footnote-item.hl2
|
||||||
@ -298,6 +305,10 @@ $hl-border: 3px
|
|||||||
blockquote
|
blockquote
|
||||||
border-left: 0.4rem solid white
|
border-left: 0.4rem solid white
|
||||||
|
|
||||||
|
button, select, input, textarea, .textarea
|
||||||
|
background: #333
|
||||||
|
color: white
|
||||||
|
|
||||||
.sidenotes img, .footnotes img
|
.sidenotes img, .footnotes img
|
||||||
width: 100%
|
width: 100%
|
||||||
max-width: 15em
|
max-width: 15em
|
||||||
|
Loading…
Reference in New Issue
Block a user