This is page 2 of 2. Use http://codebase.md/ertdfgcvb/play.core?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitignore ├── LICENSE ├── README.md ├── src │ ├── core │ │ ├── canvasrenderer.js │ │ ├── fps.js │ │ ├── storage.js │ │ ├── textrenderer.js │ │ └── version.js │ ├── makefile │ ├── modules │ │ ├── buffer.js │ │ ├── camera.js │ │ ├── canvas.js │ │ ├── color.js │ │ ├── drawbox.js │ │ ├── exportframe.js │ │ ├── filedownload.js │ │ ├── image.js │ │ ├── list.sh │ │ ├── load.js │ │ ├── num.js │ │ ├── sdf.js │ │ ├── sort.js │ │ ├── string.js │ │ ├── vec2.js │ │ └── vec3.js │ ├── programs │ │ ├── addheader.sh │ │ ├── basics │ │ │ ├── 10print.js │ │ │ ├── coordinates_index.js │ │ │ ├── coordinates_xy.js │ │ │ ├── cursor.js │ │ │ ├── how_to_draw_a_circle.js │ │ │ ├── how_to_draw_a_square.js │ │ │ ├── how_to_log.js │ │ │ ├── name_game.js │ │ │ ├── performance_test.js │ │ │ ├── rendering_to_canvas.js │ │ │ ├── sequence_export.js │ │ │ ├── simple_output.js │ │ │ ├── time_frames.js │ │ │ └── time_milliseconds.js │ │ ├── camera │ │ │ ├── camera_double_res.js │ │ │ ├── camera_gray.js │ │ │ └── camera_rgb.js │ │ ├── contributed │ │ │ ├── color_waves.js │ │ │ ├── emoji_wave.js │ │ │ ├── equal_tea_talk.js │ │ │ ├── ernst.js │ │ │ ├── game_of_life.js │ │ │ ├── pathfinder.js │ │ │ ├── sand_game.js │ │ │ ├── slime_dish.js │ │ │ └── stacked_sin_waves.js │ │ ├── demos │ │ │ ├── box_fun.js │ │ │ ├── chromaspiral.js │ │ │ ├── donut.js │ │ │ ├── doom_flame_full_color.js │ │ │ ├── doom_flame.js │ │ │ ├── dyna.js │ │ │ ├── gol_double_res.js │ │ │ ├── hotlink.js │ │ │ ├── mod_xor.js │ │ │ ├── moire_explorer.js │ │ │ ├── numbers.js │ │ │ ├── plasma.js │ │ │ ├── sinsin_checker.js │ │ │ ├── sinsin_wave.js │ │ │ ├── spiral.js │ │ │ └── wobbly.js │ │ ├── header.old.txt │ │ ├── list.sh │ │ └── sdf │ │ ├── balls.js │ │ ├── circle.js │ │ ├── cube.js │ │ ├── rectangles.js │ │ └── two_circles.js │ └── run.js └── tests ├── benchmark.html ├── browser_bugs │ ├── console_error_bug.html │ ├── error_listener_bug.html │ └── font_ready_bug.html ├── font.html ├── multi.html ├── promise_chain.html ├── proxy_test.html └── single.html ``` # Files -------------------------------------------------------------------------------- /tests/benchmark.html: -------------------------------------------------------------------------------- ```html 1 | <!DOCTYPE html> 2 | <html lang="en" > 3 | <head> 4 | <meta charset="utf-8"> 5 | <title>Test single</title> 6 | <link rel="stylesheet" type="text/css" href="/css/simple_console.css"> 7 | <style type="text/css" media="screen"> 8 | body { 9 | padding: 0; 10 | margin: 2em; 11 | font-size: 1em; 12 | line-height: 1.2; 13 | font-family: 'Simple Console', monospace; 14 | } 15 | pre { 16 | font-family: inherit; 17 | margin:0 0 2em 0; 18 | } 19 | </style> 20 | </head> 21 | <body> 22 | <pre></pre> 23 | <pre></pre> 24 | <script type="module"> 25 | 26 | const cols = 100 27 | const rows = 40 28 | const chars = 'ABCDEFG0123456789. '.split('') 29 | const colors = ['red', 'blue'] 30 | const NUM_FRAMES = 100 31 | 32 | const target = document.querySelectorAll('pre')[0] 33 | const output = document.querySelectorAll('pre')[1] 34 | const functions = [baseline, a, b, c]//, d] 35 | 36 | 37 | let frame = 0 38 | let step = 0 39 | let t0 40 | let fun 41 | 42 | fun = functions[step] 43 | 44 | function loop(t) { 45 | 46 | const af = requestAnimationFrame(loop) 47 | 48 | if (frame == 0) t0 = performance.now(); 49 | 50 | fun(target, frame) 51 | frame++ 52 | 53 | if (frame == NUM_FRAMES) { 54 | const elapsed = performance.now() - t0 55 | let out = [] 56 | out.push('-----------------------------------') 57 | out.push('step: ' + (step+1) + '/' + functions.length) 58 | out.push('function: ' + fun.name) 59 | out.push('elapsed: ' + elapsed) 60 | out.push('avg: ' + (elapsed / NUM_FRAMES)) 61 | out.push('') 62 | output.innerHTML += out.join('<br>') 63 | 64 | frame = 0 65 | step++ 66 | if (step < functions.length) { 67 | fun = functions[step] 68 | } else { 69 | cancelAnimationFrame(af) 70 | } 71 | } 72 | } 73 | 74 | requestAnimationFrame(loop) 75 | 76 | // --------------------------------------------------------------------- 77 | 78 | // Unstyled; should run at 60fps 79 | // Direct write to innerHTML 80 | function baseline(target, frame) { 81 | let html = '' 82 | for (let j=0; j<rows; j++) { 83 | for (let i=0; i<cols; i++) { 84 | const idx = (i + j * rows + frame) % chars.length 85 | html += chars[idx] 86 | } 87 | html += '<br>' 88 | } 89 | target.innerHTML = html 90 | } 91 | 92 | // --------------------------------------------------------------------- 93 | 94 | // Every char is wrapped in a span, same style 95 | // Direct write to innerHTML 96 | function a(target, frame) { 97 | let html = '' 98 | for (let j=0; j<rows; j++) { 99 | for (let i=0; i<cols; i++) { 100 | const idx = (i + j * rows + frame) % chars.length 101 | html += `<span>${chars[idx % chars.length]}</span>` 102 | } 103 | html += '<br>' 104 | } 105 | target.innerHTML = html 106 | } 107 | 108 | // --------------------------------------------------------------------- 109 | 110 | // Every char is wrapped in a span, foreground and background change 111 | // Direct write to innerHTML 112 | function b(target, frame) { 113 | let html = '' 114 | for (let j=0; j<rows; j++) { 115 | for (let i=0; i<cols; i++) { 116 | const idx = (i + j * rows + frame) 117 | const style = `color:${colors[idx % colors.length]};background-color:${colors[(idx+1) % colors.length]};` 118 | html += `<span style="${style}">${chars[idx % chars.length]}</span>` 119 | } 120 | html += '<br>' 121 | } 122 | target.innerHTML = html 123 | } 124 | 125 | // --------------------------------------------------------------------- 126 | 127 | // Direct write to innerHTML of each span 128 | // Re-use of <spans> 129 | const r = new Array(rows).fill(null).map(function(e) { 130 | const span = document.createElement('span') 131 | span.style.display = 'block' 132 | return span 133 | }) 134 | 135 | function c(target, frame) { 136 | if (frame == 0) { 137 | target.innerHTML = '' 138 | for (let j=0; j<rows; j++) { 139 | target.appendChild(r[j]) 140 | } 141 | } 142 | // for (let j=0; j<rows; j++) { 143 | // r[j].style.display = 'none' 144 | // } 145 | for (let j=0; j<rows; j++) { 146 | let html = '' 147 | for (let i=0; i<cols; i++) { 148 | const idx = (i + j * rows + frame) 149 | const style = `color:${colors[idx % colors.length]};background-color:${colors[(idx+1) % colors.length]};` 150 | html += `<span style="${style}">${chars[idx % chars.length]}</span>` 151 | } 152 | r[j].innerHTML = html 153 | } 154 | // for (let j=0; j<rows; j++) { 155 | // r[j].style.display = 'block' 156 | // } 157 | } 158 | 159 | // --------------------------------------------------------------------- 160 | 161 | // Document fragments 162 | /* 163 | const fragment = new DocumentFragment() 164 | const p = document.createElement("pre") 165 | fragment.appendChild(p) 166 | 167 | function d(target, frame) { 168 | p.innerHTML = '' 169 | for (let j=0; j<rows; j++) { 170 | // let html = '' 171 | for (let i=0; i<cols; i++) { 172 | const idx = (i + j * rows + frame) 173 | const style = `color:${colors[idx % colors.length]};background-color:${colors[(idx+1) % colors.length]};` 174 | p.innerHTML += `<span style="${style}">${chars[idx % chars.length]}</span>` 175 | } 176 | // r[j].innerHTML = html 177 | // fragment.appendChild(r[j]) 178 | p.innerHTML += '<br>' 179 | } 180 | target.innerHTML = '' 181 | target.appendChild(fragment) 182 | // for (let j=0; j<rows; j++) { 183 | // r[j].style.display = 'block' 184 | // } 185 | } 186 | */ 187 | 188 | </script> 189 | </body> 190 | </html> ``` -------------------------------------------------------------------------------- /src/modules/vec3.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | @module vec3.js 3 | @desc 3D vector helper functions 4 | @category public 5 | 6 | - No vector class (a 'vector' is just any object with {x, y, z}) 7 | - The functions never modify the original object. 8 | - An optional destination object can be passed as last paremeter to all 9 | the functions (except vec3()). 10 | - All function can be exported individually or grouped via default export. 11 | - For the default export use: 12 | import * as Vec3 from '/src/modules/vec3.js' 13 | */ 14 | 15 | // Creates a vector 16 | export function vec3(x, y, z) { 17 | return {x, y, z} 18 | } 19 | 20 | // Copies a vector 21 | export function copy(a, out) { 22 | out = out || vec3(0, 0, 0) 23 | 24 | out.x = a.x 25 | out.y = a.y 26 | out.z = a.z 27 | 28 | return out 29 | } 30 | 31 | // Adds two vectors 32 | export function add(a, b, out) { 33 | out = out || vec3(0, 0, 0) 34 | 35 | out.x = a.x + b.x 36 | out.y = a.y + b.y 37 | out.z = a.z + b.z 38 | 39 | return out 40 | } 41 | 42 | // Subtracts two vectors 43 | export function sub(a, b, out) { 44 | out = out || vec3(0, 0, 0) 45 | 46 | out.x = a.x - b.x 47 | out.y = a.y - b.y 48 | out.z = a.z - b.z 49 | 50 | return out 51 | } 52 | 53 | // Multiplies a vector by another vector (component-wise) 54 | export function mul(a, b, out) { 55 | out = out || vec3(0, 0, 0) 56 | 57 | out.x = a.x * b.x 58 | out.y = a.y * b.y 59 | out.z = a.z * b.z 60 | 61 | return out 62 | } 63 | 64 | // Divides a vector by another vector (component-wise) 65 | export function div(a, b, out) { 66 | out = out || vec3(0, 0, 0) 67 | 68 | out.x = a.x / b.x 69 | out.y = a.y / b.y 70 | out.z = a.z / b.z 71 | 72 | return out 73 | } 74 | 75 | // Adds a scalar to a vector 76 | export function addN(a, k, out) { 77 | out = out || vec3(0, 0, 0) 78 | 79 | out.x = a.x + k 80 | out.y = a.y + k 81 | out.z = a.z + k 82 | 83 | return out 84 | } 85 | 86 | // Subtracts a scalar from a vector 87 | export function subN(a, k, out) { 88 | out = out || vec3(0, 0, 0) 89 | 90 | out.x = a.x - k 91 | out.y = a.y - k 92 | out.z = a.z - k 93 | 94 | return out 95 | } 96 | 97 | // Mutiplies a vector by a scalar 98 | export function mulN(a, k, out) { 99 | out = out || vec3(0, 0, 0) 100 | 101 | out.x = a.x * k 102 | out.y = a.y * k 103 | out.z = a.z * k 104 | 105 | return out 106 | } 107 | 108 | // Divides a vector by a scalar 109 | export function divN(a, k, out) { 110 | out = out || vec3(0, 0, 0) 111 | 112 | out.x = a.x / k 113 | out.y = a.y / k 114 | out.z = a.z / k 115 | 116 | return out 117 | } 118 | 119 | // Computes the dot product of two vectors 120 | export function dot(a, b) { 121 | return a.x * b.x + a.y * b.y + a.z * b.z 122 | } 123 | 124 | // Computes the cross product of two vectors 125 | export function cross (a, b, out) { 126 | out = out || vec3(0, 0, 0) 127 | 128 | out.x = a.y * b.z - a.z * b.y 129 | out.y = a.z * b.x - a.x * b.z 130 | out.z = a.x * b.y - a.y * b.x 131 | return out 132 | } 133 | // Computes the length of vector 134 | export function length(a) { 135 | return Math.sqrt(a.x * a.x + a.y * a.y + a.z * a.z) 136 | } 137 | 138 | // Computes the square of the length of vector 139 | export function lengthSq(a) { 140 | return a.x * a.x + a.y * a.y + a.z * a.z 141 | } 142 | 143 | // Computes the distance between 2 points 144 | export function dist(a, b) { 145 | const dx = a.x - b.x 146 | const dy = a.y - b.y 147 | const dz = a.z - b.z 148 | 149 | return Math.sqrt(dx * dx + dy * dy + dz * dz) 150 | } 151 | 152 | // Computes the square of the distance between 2 points 153 | export function distSq(a, b) { 154 | const dx = a.x - b.x 155 | const dy = a.y - b.y 156 | 157 | return dx * dx + dy * dy 158 | } 159 | 160 | // Divides a vector by its Euclidean length and returns the quotient 161 | export function norm(a, out) { 162 | out = out || vec3(0, 0, 0) 163 | 164 | const l = length(a) 165 | if (l > 0.00001) { 166 | out.x = a.x / l 167 | out.y = a.y / l 168 | out.z = a.z / l 169 | } else { 170 | out.x = 0 171 | out.y = 0 172 | out.z = 0 173 | } 174 | 175 | return out 176 | } 177 | 178 | // Negates a vector 179 | export function neg(v, out) { 180 | out = out || vec3(0, 0, 0) 181 | 182 | out.x = -a.x 183 | out.y = -a.y 184 | out.z = -a.z 185 | 186 | return out 187 | } 188 | 189 | // Rotates a vector around the x axis 190 | export function rotX(v, ang, out) { 191 | out = out || vec3(0, 0, 0) 192 | const c = Math.cos(ang) 193 | const s = Math.sin(ang) 194 | out.x = v.x 195 | out.y = v.y * c - v.z * s 196 | out.z = v.y * s + v.z * c 197 | return out 198 | } 199 | 200 | // Rotates a vector around the y axis 201 | export function rotY(v, ang, out) { 202 | out = out || vec3(0, 0, 0) 203 | const c = Math.cos(ang) 204 | const s = Math.sin(ang) 205 | out.x = v.x * c + v.z * s 206 | out.y = v.y 207 | out.z = -v.x * s + v.z * c 208 | return out 209 | } 210 | 211 | // Rotates a vector around the z axis 212 | export function rotZ(v, ang, out) { 213 | out = out || vec3(0, 0, 0) 214 | const c = Math.cos(ang) 215 | const s = Math.sin(ang) 216 | out.x = v.x * c - v.y * s 217 | out.y = v.x * s + v.y * c 218 | out.z = v.z 219 | return out 220 | } 221 | 222 | // Performs linear interpolation on two vectors 223 | export function mix(a, b, t, out) { 224 | out = out || vec3(0, 0, 0) 225 | 226 | out.x = (1 - t) * a.x + t * b.x 227 | out.y = (1 - t) * a.y + t * b.y 228 | out.z = (1 - t) * a.z + t * b.z 229 | 230 | return out 231 | } 232 | 233 | // Computes the abs of a vector (component-wise) 234 | export function abs(a, out) { 235 | out = out || vec3(0, 0, 0) 236 | 237 | out.x = Math.abs(a.x) 238 | out.y = Math.abs(a.y) 239 | out.z = Math.abs(a.z) 240 | 241 | return out 242 | } 243 | 244 | // Computes the max of two vectors (component-wise) 245 | export function max(a, b, out) { 246 | out = out || vec3(0, 0, 0) 247 | 248 | out.x = Math.max(a.x, b.x) 249 | out.y = Math.max(a.y, b.y) 250 | out.z = Math.max(a.z, b.z) 251 | 252 | return out 253 | } 254 | 255 | // Computes the min of two vectors (component-wise) 256 | export function min(a, b, out) { 257 | out = out || vec3(0, 0, 0) 258 | 259 | out.x = Math.min(a.x, b.x) 260 | out.y = Math.min(a.y, b.y) 261 | out.z = Math.min(a.z, b.z) 262 | 263 | return out 264 | } 265 | 266 | // Returns the fractional part of the vector (component-wise) 267 | export function fract(a, out) { 268 | out = out || vec2(0, 0) 269 | out.x = a.x - Math.floor(a.x) 270 | out.y = a.y - Math.floor(a.y) 271 | out.z = a.z - Math.floor(a.z) 272 | return out 273 | } 274 | 275 | // Returns the floored vector (component-wise) 276 | export function floor(a, out) { 277 | out = out || vec2(0, 0) 278 | out.x = Math.floor(a.x) 279 | out.y = Math.floor(a.y) 280 | out.z = Math.floor(a.z) 281 | return out 282 | } 283 | 284 | // Returns the ceiled vector (component-wise) 285 | export function ceil(a, out) { 286 | out = out || vec2(0, 0) 287 | out.x = Math.ceil(a.x) 288 | out.y = Math.ceil(a.y) 289 | out.z = Math.ceil(a.z) 290 | return out 291 | } 292 | 293 | // Returns the rounded vector (component-wise) 294 | export function round(a, out) { 295 | out = out || vec2(0, 0) 296 | out.x = Math.round(a.x) 297 | out.y = Math.round(a.y) 298 | out.z = Math.round(a.z) 299 | return out 300 | } 301 | 302 | ``` -------------------------------------------------------------------------------- /src/modules/drawbox.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | @module drawbox.js 3 | @desc Draw text boxes with optional custom styles 4 | @category public 5 | 6 | A style object can be passed to override the default style: 7 | 8 | const style = { 9 | x : 3, 10 | y : 2, 11 | width : 0, 12 | height : 0, 13 | backgroundColor : 'white', 14 | color : 'black', 15 | fontWeight : 'normal', 16 | shadowStyle : 'none', 17 | borderStyle : 'round' 18 | paddingX : 2, 19 | paddingY : 1, 20 | } 21 | */ 22 | 23 | // The drawing styles for the borders. 24 | const borderStyles = { 25 | double : { 26 | topleft : '╔', 27 | topright : '╗', 28 | bottomright : '╝', 29 | bottomleft : '╚', 30 | top : '═', 31 | bottom : '═', 32 | left : '║', 33 | right : '║', 34 | bg : ' ', 35 | }, 36 | single : { 37 | topleft : '┌', 38 | topright : '┐', 39 | bottomright : '┘', 40 | bottomleft : '╰', 41 | top : '─', 42 | bottom : '─', 43 | left : '│', 44 | right : '│', 45 | bg : ' ', 46 | }, 47 | round : { 48 | topleft : '╭', 49 | topright : '╮', 50 | bottomright : '╯', 51 | bottomleft : '╰', 52 | top : '─', 53 | bottom : '─', 54 | left : '│', 55 | right : '│', 56 | bg : ' ', 57 | }, 58 | singleDouble : { 59 | topleft : '┌', 60 | topright : '╖', 61 | bottomright : '╝', 62 | bottomleft : '╘', 63 | top : '─', 64 | bottom : '═', 65 | left : '│', 66 | right : '║', 67 | bg : ' ', 68 | }, 69 | fat : { 70 | topleft : '█' , 71 | topright : '█', 72 | bottomright : '█', 73 | bottomleft : '█', 74 | top : '▀', 75 | bottom : '▄', 76 | left : '█', 77 | right : '█', 78 | bg : ' ', 79 | }, 80 | none : { 81 | topleft : ' ', 82 | topright : ' ', 83 | bottomright : ' ', 84 | bottomleft : ' ', 85 | top : ' ', 86 | bottom : ' ', 87 | left : ' ', 88 | right : ' ', 89 | bg : ' ', 90 | } 91 | } 92 | 93 | // The glyphs to draw a shadow. 94 | const shadowStyles = { 95 | light : { 96 | char : '░', 97 | }, 98 | medium : { 99 | char : '▒', 100 | }, 101 | dark : { 102 | char : '▓', 103 | }, 104 | solid : { 105 | char : '█', 106 | }, 107 | checker : { 108 | char : '▚', 109 | }, 110 | x : { 111 | char : '╳', 112 | }, 113 | gray : { 114 | color : 'dimgray', 115 | backgroundColor : 'lightgray' 116 | }, 117 | none : { 118 | } 119 | } 120 | 121 | const defaultTextBoxStyle = { 122 | x : 2, 123 | y : 1, 124 | width : 0, // auto width 125 | height : 0, // auto height 126 | paddingX : 2, // text offset from the left border 127 | paddingY : 1, // text offset from the top border 128 | backgroundColor : 'white', 129 | color : 'black', 130 | fontWeight : 'normal', 131 | shadowStyle : 'none', 132 | borderStyle : 'round', 133 | shadowX : 2, // horizontal shadow offset 134 | shadowY : 1, // vertical shadow offset 135 | } 136 | 137 | import { wrap, measure } from './string.js' 138 | import { merge, setRect, mergeRect, mergeText } from './buffer.js' 139 | 140 | export function drawBox(text, style, target, targetCols, targetRows) { 141 | 142 | const s = {...defaultTextBoxStyle, ...style} 143 | 144 | let boxWidth = s.width 145 | let boxHeight = s.height 146 | 147 | if (!boxWidth || !boxHeight) { 148 | const m = measure(text) 149 | boxWidth = boxWidth || m.maxWidth + s.paddingX * 2 150 | boxHeight = boxHeight || m.numLines + s.paddingY * 2 151 | } 152 | 153 | const x1 = s.x 154 | const y1 = s.y 155 | const x2 = s.x + boxWidth - 1 156 | const y2 = s.y + boxHeight - 1 157 | const w = boxWidth 158 | const h = boxHeight 159 | 160 | const border = borderStyles[s.borderStyle] || borderStyles['round'] 161 | 162 | // Background, overwrite the buffer 163 | setRect({ 164 | char : border.bg, 165 | color : s.color, 166 | fontWeight : s.fontWeight, 167 | backgroundColor : s.backgroundColor 168 | }, x1, y1, w, h, target, targetCols, targetRows) 169 | 170 | // Corners 171 | merge({ char : border.topleft }, x1, y1, target, targetCols, targetRows) 172 | merge({ char : border.topright }, x2, y1, target, targetCols, targetRows) 173 | merge({ char : border.bottomright }, x2, y2, target, targetCols, targetRows) 174 | merge({ char : border.bottomleft }, x1, y2, target, targetCols, targetRows) 175 | 176 | // Top & Bottom 177 | mergeRect({ char : border.top }, x1+1, y1, w-2, 1, target, targetCols, targetRows) 178 | mergeRect({ char : border.bottom }, x1+1, y2, w-2, 1, target, targetCols, targetRows) 179 | 180 | // Left & Right 181 | mergeRect({ char : border.left }, x1, y1+1, 1, h-2, target, targetCols, targetRows) 182 | mergeRect({ char : border.right }, x2, y1+1, 1, h-2, target, targetCols, targetRows) 183 | 184 | // Shadows 185 | const ss = shadowStyles[s.shadowStyle] || shadowStyles['none'] 186 | if (ss !== shadowStyles['none']) { 187 | const ox = s.shadowX 188 | const oy = s.shadowY 189 | // Shadow Bottom 190 | mergeRect(ss, x1+ox, y2+1, w, oy, target, targetCols, targetRows) 191 | // Shadow Right 192 | mergeRect(ss, x2+1, y1+oy, ox, h-oy, target, targetCols, targetRows) 193 | } 194 | 195 | // Txt 196 | mergeText({ 197 | text, 198 | color : style.color, 199 | backgroundColor : style.backgroundColor, 200 | fontWeight : style.weght 201 | }, x1+s.paddingX, y1+s.paddingY, target, targetCols, targetRows) 202 | } 203 | 204 | // -- Utility for some info output --------------------------------------------- 205 | 206 | const defaultInfoStyle = { 207 | width : 24, 208 | backgroundColor : 'white', 209 | color : 'black', 210 | fontWeight : 'normal', 211 | shadowStyle : 'none', 212 | borderStyle : 'round', 213 | } 214 | 215 | export function drawInfo(context, cursor, target, style) { 216 | 217 | let info = '' 218 | info += 'FPS ' + Math.round(context.runtime.fps) + '\n' 219 | info += 'frame ' + context.frame + '\n' 220 | info += 'time ' + Math.floor(context.time) + '\n' 221 | info += 'size ' + context.cols + '×' + context.rows + '\n' 222 | // info += 'row repaint ' + context.runtime.updatedRowNum + '\n' 223 | info += 'font aspect ' + context.metrics.aspect.toFixed(2) + '\n' 224 | info += 'cursor ' + Math.floor(cursor.x) + ',' + Math.floor(cursor.y) + '\n' 225 | // NOTE: width and height can be a float in case of user zoom 226 | // info += 'context ' + Math.floor(context.width) + '×' + Math.floor(context.height) + '\n' 227 | 228 | const textBoxStyle = {...defaultInfoStyle, ...style} 229 | 230 | drawBox(info, textBoxStyle, target, context.cols, context.rows) 231 | } 232 | ``` -------------------------------------------------------------------------------- /src/programs/contributed/slime_dish.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | [header] 3 | @author zspotter 4 | (IG @zzz_desu, TW @zspotter) 5 | @title Slime Dish 6 | @desc Low-res physarum slime mold simulation 7 | 8 | 🔍 Tap and hold to magnify. 9 | 10 | With inspiration from: 11 | - https://sagejenson.com/physarum 12 | - https://uwe-repository.worktribe.com/output/980579 13 | - http://www.tech-algorithm.com/articles/nearest-neighbor-image-scaling 14 | */ 15 | 16 | import * as v2 from '/src/modules/vec2.js' 17 | import { map } from '/src/modules/num.js' 18 | 19 | // Environment 20 | const WIDTH = 400; 21 | const HEIGHT = 400; 22 | const NUM_AGENTS = 1500; 23 | const DECAY = 0.9; 24 | const MIN_CHEM = 0.0001; 25 | 26 | // Agents 27 | const SENS_ANGLE = 45 * Math.PI / 180; 28 | const SENS_DIST = 9; 29 | const AGT_SPEED = 1; 30 | const AGT_ANGLE = 45 * Math.PI / 180; 31 | const DEPOSIT = 1; 32 | 33 | // Rendering 34 | const TEXTURE = [ 35 | " ``^@", 36 | " ..„v0", 37 | ]; 38 | const OOB = ' '; 39 | 40 | // 0@0@0@0@0@0^v^v^v^v^v^„`„`„`„`„`„`.`.`.`.`.`.`. . . . . . 41 | // @0@0@0@0@0@v^v^v^v^v^v`„`„`„`„`„`„`.`.`.`.`.`.`. . . . . . 42 | // 0@0@0@0@0@0^v^v^v^v^v^„`„`„`„`„`„`.`.`.`.`.`.`. . . . . . 43 | // @0@0@0@0@0@v^v^v^v^v^v`„`„`„`„`„`„`.`.`.`.`.`.`. . . . . . 44 | 45 | export const settings = { 46 | backgroundColor : 'black', 47 | color : 'white', 48 | fontSize: '12px', 49 | } 50 | 51 | export function boot(context, buffer, data) { 52 | document.body.style.cursor = 'crosshair'; 53 | 54 | data.chem = new Float32Array(HEIGHT*WIDTH); 55 | data.wip = new Float32Array(HEIGHT*WIDTH); 56 | 57 | data.agents = []; 58 | for (let agent = 0; agent < NUM_AGENTS; agent++) { 59 | data.agents.push(new Agent( 60 | // Random position 61 | v2.mulN(v2.addN(v2.mulN(randCircle(), 0.5), 1), 0.5 * WIDTH), 62 | // Random direction 63 | v2.rot(v2.vec2(1, 0), Math.random()*2*Math.PI), 64 | )); 65 | } 66 | 67 | data.viewScale = { y: 100/context.metrics.aspect, x: 100 }; 68 | data.viewFocus = { y: 0.5, x: 0.5 }; 69 | } 70 | 71 | export function pre(context, cursor, buffer, data) { 72 | // Diffuse & decay 73 | for (let row = 0; row < HEIGHT; row++) { 74 | for (let col = 0; col < WIDTH; col++) { 75 | let val = DECAY * blur(row, col, data.chem); 76 | if (val < MIN_CHEM) 77 | val = 0; 78 | data.wip[row*HEIGHT+col] = val; 79 | } 80 | } 81 | const swap = data.chem; 82 | data.chem = data.wip; 83 | data.wip = swap; 84 | 85 | const { chem, agents, view } = data; 86 | 87 | // Sense, rotate, and move 88 | const isScattering = Math.sin(context.frame/150) > 0.8; 89 | for (const agent of agents) { 90 | agent.scatter = isScattering; 91 | agent.react(chem); 92 | } 93 | 94 | // Deposit 95 | for (const agent of agents) { 96 | agent.deposit(chem); 97 | } 98 | 99 | // Update view params 100 | updateView(cursor, context, data); 101 | } 102 | 103 | export function main(coord, context, cursor, buffer, data) { 104 | const { viewFocus, viewScale } = data; 105 | 106 | // A down and upscaling algorithm based on nearest-neighbor image scaling. 107 | 108 | const offset = { 109 | y: Math.floor(viewFocus.y * (HEIGHT - viewScale.y * context.rows)), 110 | x: Math.floor(viewFocus.x * (WIDTH - viewScale.x * context.cols)), 111 | }; 112 | 113 | // The "nearest neighbor" 114 | const sampleFrom = { 115 | y: offset.y + Math.floor(coord.y * viewScale.y), 116 | x: offset.x + Math.floor(coord.x * viewScale.x), 117 | }; 118 | 119 | // The next nearest-neighbor cell, which we look up to the border of 120 | const sampleTo = { 121 | y: offset.y + Math.floor((coord.y+1) * viewScale.y), 122 | x: offset.x + Math.floor((coord.x+1) * viewScale.x), 123 | }; 124 | 125 | if (!bounded(sampleFrom) || !bounded(sampleTo)) 126 | return OOB; 127 | 128 | // When upscaling, sample W/H may be 0 129 | const sampleH = Math.max(1, sampleTo.y - sampleFrom.y); 130 | const sampleW = Math.max(1, sampleTo.x - sampleFrom.x); 131 | 132 | // Combine all cells in [sampleFrom, sampleTo) into a single value. 133 | // For this case, the value half way between the average and max works well. 134 | let max = 0; 135 | let sum = 0; 136 | for (let x = sampleFrom.x; x < sampleFrom.x + sampleW; x++) { 137 | for (let y = sampleFrom.y; y < sampleFrom.y + sampleH; y++) { 138 | const v = data.chem[y*HEIGHT+x]; 139 | max = Math.max(max, v); 140 | sum += v; 141 | } 142 | } 143 | let val = sum / (sampleW * sampleH); 144 | val = (val + max) / 2; 145 | 146 | // Weight val so we get better distribution of textures 147 | val = Math.pow(val, 1/3); 148 | 149 | // Convert the cell value into a character from the texture map 150 | const texRow = (coord.x+coord.y) % TEXTURE.length; 151 | const texCol = Math.ceil(val * (TEXTURE[0].length-1)); 152 | const char = TEXTURE[texRow][texCol]; 153 | if (!char) 154 | throw new Error(`Invalid char for ${val}`); 155 | 156 | return char; 157 | } 158 | 159 | // import { drawInfo } from '/src/modules/drawbox.js' 160 | // export function post(context, cursor, buffer) { 161 | // drawInfo(context, cursor, buffer) 162 | // } 163 | 164 | function updateView(cursor, context, data) { 165 | let targetScale; 166 | if (cursor.pressed) { 167 | // 1 display char = 1 grid cell 168 | targetScale = { 169 | y: 1 / context.metrics.aspect, 170 | x: 1, 171 | }; 172 | } 173 | else if (context.rows / context.metrics.aspect < context.cols) { 174 | // Fit whole grid on wide window 175 | targetScale = { 176 | y: 1.1*WIDTH / context.rows, 177 | x: 1.1*WIDTH / context.rows * context.metrics.aspect, 178 | }; 179 | } 180 | else { 181 | // Fit whole grid on tall window 182 | targetScale = { 183 | y: 1.1*WIDTH / context.cols / context.metrics.aspect, 184 | x: 1.1*WIDTH / context.cols, 185 | }; 186 | } 187 | 188 | if (data.viewScale.y !== targetScale.y || data.viewScale.x !== targetScale.x) { 189 | data.viewScale.y += 0.1 * (targetScale.y - data.viewScale.y); 190 | data.viewScale.x += 0.1 * (targetScale.x - data.viewScale.x); 191 | } 192 | 193 | let targetFocus = !cursor.pressed 194 | ? { y: 0.5, x: 0.5 } 195 | : { y: cursor.y / context.rows, x: cursor.x / context.cols }; 196 | if (data.viewFocus.y !== targetFocus.y || data.viewFocus.x !== targetFocus.x) { 197 | data.viewFocus.y += 0.1 * (targetFocus.y - data.viewFocus.y); 198 | data.viewFocus.x += 0.1 * (targetFocus.x - data.viewFocus.x); 199 | } 200 | } 201 | 202 | // 0@0@0@0@0@0^v^v^v^v^v^„`„`„`„`„`„`.`.`.`.`.`.`. . . . . . 203 | // @0@0@0@0@0@v^v^v^v^v^v`„`„`„`„`„`„`.`.`.`.`.`.`. . . . . . 204 | // 0@0@0@0@0@0^v^v^v^v^v^„`„`„`„`„`„`.`.`.`.`.`.`. . . . . . 205 | // @0@0@0@0@0@v^v^v^v^v^v`„`„`„`„`„`„`.`.`.`.`.`.`. . . . . . 206 | 207 | class Agent { 208 | constructor(pos, dir) { 209 | this.pos = pos; 210 | this.dir = dir; 211 | this.scatter = false; 212 | } 213 | 214 | sense(m, chem) { 215 | const senseVec = v2.mulN(v2.rot(this.dir, m * SENS_ANGLE), SENS_DIST); 216 | const pos = v2.floor(v2.add(this.pos, senseVec)); 217 | if (!bounded(pos)) 218 | return -1; 219 | const sensed = chem[pos.y*HEIGHT+pos.x]; 220 | if (this.scatter) 221 | return 1 - sensed; 222 | return sensed; 223 | } 224 | 225 | react(chem) { 226 | // Sense 227 | let forwardChem = this.sense(0, chem); 228 | let leftChem = this.sense(-1, chem); 229 | let rightChem = this.sense(1, chem); 230 | 231 | // Rotate 232 | let rotate = 0; 233 | if (forwardChem > leftChem && forwardChem > rightChem) { 234 | rotate = 0; 235 | } 236 | else if (forwardChem < leftChem && forwardChem < rightChem) { 237 | if (Math.random() < 0.5) { 238 | rotate = -AGT_ANGLE; 239 | } 240 | else { 241 | rotate = AGT_ANGLE; 242 | } 243 | } 244 | else if (leftChem < rightChem) { 245 | rotate = AGT_ANGLE; 246 | } 247 | else if (rightChem < leftChem) { 248 | rotate = -AGT_ANGLE; 249 | } 250 | else if (forwardChem < 0) { 251 | // Turn around at edge 252 | rotate = Math.PI/2; 253 | } 254 | this.dir = v2.rot(this.dir, rotate); 255 | 256 | // Move 257 | this.pos = v2.add(this.pos, v2.mulN(this.dir, AGT_SPEED)); 258 | } 259 | 260 | deposit(chem) { 261 | const {y, x} = v2.floor(this.pos); 262 | const i = y*HEIGHT+x; 263 | chem[i] = Math.min(1, chem[i] + DEPOSIT); 264 | } 265 | } 266 | 267 | const R = Math.min(WIDTH, HEIGHT)/2; 268 | 269 | function bounded(vec) { 270 | return ((vec.x-R)**2 + (vec.y-R)**2 <= R**2); 271 | } 272 | 273 | function blur(row, col, data) { 274 | let sum = 0; 275 | for (let dy = -1; dy <= 1; dy++) { 276 | for (let dx = -1; dx <= 1; dx++) { 277 | sum += data[(row+dy)*HEIGHT + col + dx] ?? 0; 278 | } 279 | } 280 | return sum / 9; 281 | } 282 | 283 | function randCircle() { 284 | const r = Math.sqrt(Math.random()); 285 | const theta = Math.random() * 2 * Math.PI; 286 | return { 287 | x: r * Math.cos(theta), 288 | y: r * Math.sin(theta) 289 | }; 290 | } ``` -------------------------------------------------------------------------------- /src/modules/canvas.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | @module canvas.js 3 | @desc A wrapper for a canvas element 4 | @category public 5 | 6 | A canvas 'wrapper' class. 7 | The purpose is to offer a ready to use buffer (a "pixel" array 8 | of {r, g, b, (a, v)} objects) of the same size of the ASCII context (or not) 9 | which can be read or sampled. 10 | Some convenience functions are provided. 11 | 12 | Resizes the canvas: 13 | - resize(w, h) 14 | 15 | Five main functions are implemented to copy a source (canvas, video, image) 16 | to the internal canvas: 17 | - image(source) // resizes the canvas to the source image and copies it 18 | - copy(source, ...) 19 | - cover(source, ...) 20 | - fit(source, ...) 21 | - center(source, ...) 22 | 23 | A call to these functions will also update the internal 'pixels' array trough: 24 | - loadPixels() 25 | 26 | A few extra functions are provided to manipulate the array directly: 27 | - mirrorX() 28 | - normalize() // only v values 29 | - quantize() 30 | 31 | Finally the whole buffer can be copied to a destination trough: 32 | - writeTo() 33 | 34 | Or accessed with: 35 | - get(x, y) 36 | - sample(x, y) 37 | */ 38 | 39 | import { map, mix, clamp } from './num.js' 40 | 41 | export const MODE_COVER = Symbol() 42 | export const MODE_FIT = Symbol() 43 | export const MODE_CENTER = Symbol() 44 | 45 | const BLACK = { r:0, g:0, b:0, a:1, v:0 } 46 | const WHITE = { r:255, g:255, b:255, a:1, v:1 } 47 | 48 | export default class Canvas { 49 | 50 | constructor(sourceCanvas) { 51 | this.canvas = sourceCanvas || document.createElement('canvas') 52 | 53 | // Initialize the canvas as a black 1x1 image so it can be used 54 | this.canvas.width = 1 55 | this.canvas.height = 1 56 | this.ctx = this.canvas.getContext('2d') 57 | this.ctx.putImageData(this.ctx.createImageData(1, 1), 0, 0); 58 | 59 | // A flat buffer to store image data 60 | // in the form of {r, g, b, [a, v]} 61 | this.pixels = [] 62 | this.loadPixels() 63 | } 64 | 65 | get width() { 66 | return this.canvas.width 67 | } 68 | 69 | get height() { 70 | return this.canvas.height 71 | } 72 | 73 | // -- Functions that act on the canvas ------------------------------------- 74 | 75 | resize(dWidth, dHeight) { 76 | this.canvas.width = dWidth 77 | this.canvas.height = dHeight 78 | this.pixels.length = 0 79 | return this 80 | } 81 | 82 | // Copies the source canvas or video element to dest via drawImage 83 | // allows distortion, offsets, etc. 84 | copy(source, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight) { 85 | 86 | sx = sx || 0 87 | sy = sy || 0 88 | sWidth = sWidth || source.videoWidth || source.width 89 | sHeight = sHeight || source.videoHeight || source.height 90 | 91 | dx = dx || 0 92 | dy = dy || 0 93 | dWidth = dWidth || this.canvas.width 94 | dHeight = dHeight || this.canvas.height 95 | 96 | this.ctx.drawImage(source, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight) 97 | this.loadPixels() 98 | 99 | return this 100 | } 101 | 102 | // Resizes the canvas to the size of the source image 103 | // and paints the image on it. 104 | image(source) { 105 | const w = source.videoWidth || source.width 106 | const h = source.videoHeight || source.height 107 | this.resize(w, h) 108 | this.copy(source, 0, 0, w, h, 0, 0, w, h) 109 | return this 110 | } 111 | 112 | // Covers the destintation canvas with the source image 113 | // without resizing the canvas. 114 | // An otional aspect factor can be passed. 115 | cover(source, aspect=1) { 116 | centerImage(source, this.canvas, 1, aspect, MODE_COVER) 117 | this.loadPixels() 118 | return this 119 | } 120 | 121 | // Fits the source image on the destintation canvas 122 | // without resizing the canvas. 123 | // An otional aspect factor can be passed. 124 | fit(source, aspect=1) { 125 | centerImage(source, this.canvas, 1, aspect, MODE_FIT) 126 | this.loadPixels() 127 | return this 128 | } 129 | 130 | // Centers the source image on the destination canvas 131 | // without resizing the canvas. 132 | // Optional scaling factors can be passed. 133 | center(source, scaleX=1, scaleY=1) { 134 | centerImage(source, this.canvas, scaleX, scaleY, MODE_CENTER) 135 | this.loadPixels() 136 | return this 137 | } 138 | 139 | // -- Functions that act directly on the pixel array ----------------------- 140 | 141 | mirrorX() { 142 | const w = this.canvas.width 143 | const h = this.canvas.height 144 | const buf = this.pixels 145 | const half = Math.floor(w / 2) 146 | for (let j=0; j<h; j++) { 147 | for (let i=0; i<half; i++) { 148 | const a = w * j + i 149 | const b = w * (j + 1) - i - 1 150 | const t = buf[b] 151 | buf[b] = buf[a] 152 | buf[a] = t 153 | } 154 | } 155 | return this 156 | } 157 | 158 | normalize() { 159 | normalizeGray(this.pixels, this.pixels, 0.0, 1.0) 160 | return this 161 | } 162 | 163 | quantize(palette) { 164 | paletteQuantize(this.pixels, this.pixels, palette) 165 | return this 166 | } 167 | 168 | // -- Getters (pixel array) ------------------------------------------------ 169 | 170 | // Get color at coord 171 | get(x, y) { 172 | if (x < 0 || x >= this.canvas.width) return BLACK 173 | if (y < 0 || y >= this.canvas.height) return BLACK 174 | return this.pixels[x + y * this.canvas.width] 175 | } 176 | 177 | // Sample value at coord (0-1) 178 | sample(sx, sy, gray=false) { 179 | const w = this.canvas.width 180 | const h = this.canvas.height 181 | 182 | const x = sx * w - 0.5 183 | const y = sy * h - 0.5 184 | 185 | let l = Math.floor(x) 186 | let b = Math.floor(y) 187 | let r = l + 1 188 | let t = b + 1 189 | const lr = x - l 190 | const bt = y - b 191 | 192 | // Instead of clamping use safe "get()" 193 | // l = clamp(l, 0, w - 1) // left 194 | // r = clamp(r, 0, w - 1) // right 195 | // b = clamp(b, 0, h - 1) // bottom 196 | // t = clamp(t, 0, h - 1) // top 197 | 198 | // Avoid 9 extra interpolations if only gray is needed 199 | if (gray) { 200 | const p1 = mix(this.get(l, b).v, this.get(r, b).v, lr) 201 | const p2 = mix(this.get(l, t).v, this.get(r, t).v, lr) 202 | return mix(p1, p2, bt) 203 | } else { 204 | const p1 = mixColors(this.get(l, b), this.get(r, b), lr) 205 | const p2 = mixColors(this.get(l, t), this.get(r, t), lr) 206 | return mixColors(p1, p2, bt) 207 | } 208 | } 209 | 210 | // Read 211 | loadPixels() { 212 | // New data could be shorter, 213 | // empty without loosing the ref. 214 | this.pixels.length = 0 215 | const w = this.canvas.width 216 | const h = this.canvas.height 217 | const data = this.ctx.getImageData(0, 0, w, h).data 218 | let idx = 0 219 | for (let i=0; i<data.length; i+=4) { 220 | const r = data[i ] // / 255.0, 221 | const g = data[i+1] // / 255.0, 222 | const b = data[i+2] // / 255.0, 223 | const a = data[i+3] / 255.0 // CSS style 224 | this.pixels[idx++] = { 225 | r, g, b, a, 226 | v : toGray(r, g, b) 227 | } 228 | } 229 | return this 230 | } 231 | 232 | // -- Helpers -------------------------------------------------------------- 233 | 234 | writeTo(buf) { 235 | if (Array.isArray(buf)) { 236 | for (let i=0; i<this.pixels.length; i++) buf[i] = this.pixels[i] 237 | } 238 | return this 239 | } 240 | 241 | // Debug ------------------------------------------------------------------- 242 | 243 | // Attaches the canvas to a target element for debug purposes 244 | display(target, x=0, y=0) { 245 | target = target || document.body 246 | this.canvas.style.position = 'absolute' 247 | this.canvas.style.left = x + 'px' 248 | this.canvas.style.top = y + 'px' 249 | this.canvas.style.width = 'auto' 250 | this.canvas.style.height = 'auto' 251 | this.canvas.style.zIndex = 10 252 | document.body.appendChild(this.canvas) 253 | } 254 | } 255 | 256 | 257 | // Helpers --------------------------------------------------------------------- 258 | 259 | function mixColors(a, b, amt) { 260 | return { 261 | r : mix(a.r, b.r, amt), 262 | g : mix(a.g, b.g, amt), 263 | b : mix(a.b, b.b, amt), 264 | v : mix(a.v, b.v, amt) 265 | } 266 | } 267 | 268 | function getElementSize(source) { 269 | const type = source.nodeName 270 | const width = type == 'VIDEO' ? source.videoWidth : source.width || 0 271 | const height = type == 'VIDEO' ? source.videoHeight : source.height || 0 272 | return { width, height } 273 | } 274 | 275 | function centerImage(sourceCanvas, targetCanvas, scaleX=1, scaleY=1, mode=MODE_CENTER) { 276 | 277 | // Source size 278 | const s = getElementSize(sourceCanvas) 279 | 280 | // Source aspect (scaled) 281 | const sa = (scaleX * s.width) / (s.height * scaleY) 282 | 283 | // Target size and aspect 284 | const tw = targetCanvas.width 285 | const th = targetCanvas.height 286 | const ta = tw / th 287 | 288 | // Destination width and height (adjusted for cover / fit) 289 | let dw, dh 290 | 291 | // Cover the entire dest canvas with image content 292 | if (mode == MODE_COVER) { 293 | if (sa > ta) { 294 | dw = th * sa 295 | dh = th 296 | } else { 297 | dw = tw 298 | dh = tw / sa 299 | } 300 | } 301 | // Fit the entire source image in dest tanvas (with black bars) 302 | else if (mode == MODE_FIT) { 303 | if (sa > ta) { 304 | dw = tw 305 | dh = tw / sa 306 | } else { 307 | dw = th * sa 308 | dh = th 309 | } 310 | } 311 | // Center the image 312 | else if (mode == MODE_CENTER) { 313 | dw = s.width * scaleX 314 | dh = s.height * scaleY 315 | } 316 | 317 | // Update the targetCanvas with correct aspect ratios 318 | const ctx = targetCanvas.getContext('2d') 319 | 320 | // Fill the canvas in case of 'fit' 321 | ctx.fillStyle = 'black' 322 | ctx.fillRect(0, 0, tw, th) 323 | ctx.save() 324 | ctx.translate(tw/2, th/2) 325 | ctx.drawImage(sourceCanvas, -dw/2, -dh/2, dw, dh) 326 | ctx.restore() 327 | } 328 | 329 | // Use this or import 'rgb2gray' from color.js 330 | // https://en.wikipedia.org/wiki/Grayscale 331 | function toGray(r, g, b) { 332 | return Math.round(r * 0.2126 + g * 0.7152 + b * 0.0722) / 255.0 333 | } 334 | 335 | function paletteQuantize(arrayIn, arrayOut, palette) { 336 | arrayOut = arrayOut || [] 337 | 338 | // Euclidean: 339 | // const distFn = (a, b) => Math.pow(a.r - b.r, 2) + Math.pow(a.g - b.g, 2) + Math.pow(a.b - b.b, 2) 340 | 341 | // Redmean: 342 | // https://en.wikipedia.org/wiki/Color_difference 343 | const distFn = (a, b) => { 344 | const r = (a.r + b.r) * 0.5 345 | let s = 0 346 | s += (2 + r / 256) * Math.pow(a.r - b.r, 2) 347 | s += 4 * Math.pow(a.g - b.g, 2) 348 | s += (2 + (255 - r) / 256) * Math.pow(a.b - b.b, 2) 349 | return Math.sqrt(s) 350 | } 351 | 352 | for (let i=0; i<arrayIn.length; i++) { 353 | const a = arrayIn[i] 354 | let dist = Number.MAX_VALUE 355 | let nearest 356 | for (const b of palette) { 357 | const d = distFn(a, b) 358 | if (d < dist) { 359 | dist = d 360 | nearest = b 361 | } 362 | } 363 | arrayOut[i] = {...nearest, v : arrayIn[i].v } // Keep the original gray value intact 364 | } 365 | return arrayOut 366 | } 367 | 368 | // Normalizes the gray component (auto levels) 369 | function normalizeGray(arrayIn, arrayOut, lower=0.0, upper=1.0) { 370 | arrayOut = arrayOut || [] 371 | 372 | let min = Number.MAX_VALUE 373 | let max = 0 374 | for (let i=0; i<arrayIn.length; i++) { 375 | min = Math.min(arrayIn[i].v, min) 376 | max = Math.max(arrayIn[i].v, max) 377 | } 378 | // return target.map( v => { 379 | // return map(v, min, max, 0, 1) 380 | // }) 381 | for (let i=0; i<arrayIn.length; i++) { 382 | const v = min == max ? min : map(arrayIn[i].v, min, max, lower, upper) 383 | arrayOut[i] = {...arrayOut[i], v} 384 | } 385 | return arrayOut 386 | } 387 | ``` -------------------------------------------------------------------------------- /src/modules/color.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | @module color.js 3 | @desc Some common palettes and simple color helpers 4 | @category public 5 | 6 | Colors can be defined as: 7 | 8 | rgb : { r:255, g:0, b:0 } 9 | int : 16711680 (0xff0000) 10 | hex : '#FF0000' 11 | css : 'rgb(255,0,0)' 12 | 13 | CSS1 and CSS3 palettes are exported as maps 14 | C64 and CGA palettes are exported as arrays 15 | 16 | Most of the times colors are ready to use as in CSS: 17 | this means r,g,b have 0-255 range but alpha 0-1 18 | 19 | Colors in exported palettes are augmented to: 20 | { 21 | name : 'red', 22 | r : 255, // 0-255 (as in CSS) 23 | g : 0, // 0-255 (as in CSS) 24 | b : 0, // 0-255 (as in CSS) 25 | a : 1.0, // 0-1 (as in CSS) 26 | v : 0.6, // 0-1 (gray value) 27 | hex : '#FF0000', 28 | css : 'rgb(255,0,0)' 29 | int : 16711680 30 | } 31 | 32 | */ 33 | 34 | // Convert r,g,b,a values to {r,g,b,a} 35 | export function rgb(r,g,b,a=1.0) { 36 | return {r,g,b,a} 37 | } 38 | 39 | // Convert r,g,b,a values to {r,g,b,a} 40 | export function hex(r,g,b,a=1.0) { 41 | return rgb2hex({r,g,b,a}) 42 | } 43 | 44 | // Convert r,g,b,a values to 'rgb(r,g,b,a)' 45 | export function css(r,g,b,a=1.0) { 46 | if (a === 1.0) return `rgb(${r},${g},${b})` 47 | // CSS3 (in CSS4 we could return rgb(r g b a)) 48 | return `rgba(${r},${g},${b},${a})`} 49 | 50 | // Convert {r,g,b,a} values to 'rgb(r,g,b,a)' 51 | export function rgb2css(rgb) { 52 | if (rgb.a === undefined || rgb.a === 1.0) { 53 | return `rgb(${rgb.r},${rgb.g},${rgb.b})` 54 | } 55 | // CSS3 (in CSS4 we could return rgb(r g b a)) 56 | return `rgba(${rgb.r},${rgb.g},${rgb.b},${rgb.a})` 57 | } 58 | 59 | // Convert {r,g,b} values to '#RRGGBB' or '#RRGGBBAA' 60 | export function rgb2hex(rgb) { 61 | 62 | let r = Math.round(rgb.r).toString(16).padStart(2, '0') 63 | let g = Math.round(rgb.g).toString(16).padStart(2, '0') 64 | let b = Math.round(rgb.b).toString(16).padStart(2, '0') 65 | 66 | // Alpha not set 67 | if (rgb.a === undefined) { 68 | return '#' + r + g + b 69 | } 70 | 71 | let a = Math.round(rgb.a * 255).toString(16).padStart(2, '0') 72 | return '#' + r + g + b + a 73 | } 74 | 75 | // Convert {r,g,b} values to gray value [0-1] 76 | export function rgb2gray(rgb) { 77 | return Math.round(rgb.r * 0.2126 + rgb.g * 0.7152 + rgb.b * 0.0722) / 255.0 78 | } 79 | 80 | // hex is not a string but an int number 81 | export function int2rgb(int) { 82 | return { 83 | a : 1.0, 84 | r : int >> 16 & 0xff, 85 | g : int >> 8 & 0xff, 86 | b : int & 0xff 87 | } 88 | } 89 | 90 | // export function int2hex(int) { 91 | // return '#' + (int).toString(16) 92 | // } 93 | 94 | // https://www.c64-wiki.com/wiki/Color 95 | const _C64 = [ 96 | { int : 0x000000, name : 'black' }, // 0 97 | { int : 0xffffff, name : 'white' }, // 1 98 | { int : 0x880000, name : 'red' }, // 2 99 | { int : 0xaaffee, name : 'cyan' }, // 3 100 | { int : 0xcc44cc, name : 'violet' }, // 4 101 | { int : 0x00cc55, name : 'green' }, // 5 102 | { int : 0x0000aa, name : 'blue' }, // 6 103 | { int : 0xeeee77, name : 'yellow' }, // 7 104 | { int : 0xdd8855, name : 'orange' }, // 8 105 | { int : 0x664400, name : 'brown' }, // 9 106 | { int : 0xff7777, name : 'lightred' }, // 10 107 | { int : 0x333333, name : 'darkgrey' }, // 11 108 | { int : 0x777777, name : 'grey' }, // 12 109 | { int : 0xaaff66, name : 'lightgreen' }, // 13 110 | { int : 0x0088ff, name : 'lightblue' }, // 14 111 | { int : 0xbbbbbb, name : 'lightgrey' } // 15 112 | ] 113 | 114 | const _CGA = [ 115 | { int : 0x000000, name : 'black' }, // 0 116 | { int : 0x0000aa, name : 'blue' }, // 1 117 | { int : 0x00aa00, name : 'green' }, // 2 118 | { int : 0x00aaaa, name : 'cyan' }, // 3 119 | { int : 0xaa0000, name : 'red' }, // 4 120 | { int : 0xaa00aa, name : 'magenta' }, // 5 121 | { int : 0xaa5500, name : 'brown' }, // 6 122 | { int : 0xaaaaaa, name : 'lightgray' }, // 7 123 | { int : 0x555555, name : 'darkgray' }, // 8 124 | { int : 0x5555ff, name : 'lightblue' }, // 9 125 | { int : 0x55ff55, name : 'lightgreen' }, // 10 126 | { int : 0x55ffff, name : 'lightcyan' }, // 11 127 | { int : 0xff5555, name : 'lightred' }, // 12 128 | { int : 0xff55ff, name : 'lightmagenta' }, // 13 129 | { int : 0xffff55, name : 'yellow' }, // 14 130 | { int : 0xffffff, name : 'white' } // 15 131 | ] 132 | 133 | // https://developer.mozilla.org/en-US/docs/Web/CSS/color_value 134 | const _CSS1 = [ 135 | { int : 0x000000, name : 'black' }, 136 | { int : 0xc0c0c0, name : 'silver' }, 137 | { int : 0x808080, name : 'gray' }, 138 | { int : 0xffffff, name : 'white' }, 139 | { int : 0x800000, name : 'maroon' }, 140 | { int : 0xff0000, name : 'red' }, 141 | { int : 0x800080, name : 'purple' }, 142 | { int : 0xff00ff, name : 'fuchsia' }, 143 | { int : 0x008000, name : 'green' }, 144 | { int : 0x00ff00, name : 'lime' }, 145 | { int : 0x808000, name : 'olive' }, 146 | { int : 0xffff00, name : 'yellow' }, 147 | { int : 0x000080, name : 'navy' }, 148 | { int : 0x0000ff, name : 'blue' }, 149 | { int : 0x008080, name : 'teal' }, 150 | { int : 0x00ffff, name : 'aqua' } 151 | ] 152 | 153 | const _CSS2 = [..._CSS1, 154 | {int : 0xffa500, name :'orange'} 155 | ] 156 | 157 | const _CSS3 = [..._CSS2, 158 | { int : 0xf0f8ff, name : 'aliceblue' }, 159 | { int : 0xfaebd7, name : 'antiquewhite' }, 160 | { int : 0x7fffd4, name : 'aquamarine' }, 161 | { int : 0xf0ffff, name : 'azure' }, 162 | { int : 0xf5f5dc, name : 'beige' }, 163 | { int : 0xffe4c4, name : 'bisque' }, 164 | { int : 0xffebcd, name : 'blanchedalmond' }, 165 | { int : 0x8a2be2, name : 'blueviolet' }, 166 | { int : 0xa52a2a, name : 'brown' }, 167 | { int : 0xdeb887, name : 'burlywood' }, 168 | { int : 0x5f9ea0, name : 'cadetblue' }, 169 | { int : 0x7fff00, name : 'chartreuse' }, 170 | { int : 0xd2691e, name : 'chocolate' }, 171 | { int : 0xff7f50, name : 'coral' }, 172 | { int : 0x6495ed, name : 'cornflowerblue' }, 173 | { int : 0xfff8dc, name : 'cornsilk' }, 174 | { int : 0xdc143c, name : 'crimson' }, 175 | { int : 0x00ffff, name : 'aqua' }, 176 | { int : 0x00008b, name : 'darkblue' }, 177 | { int : 0x008b8b, name : 'darkcyan' }, 178 | { int : 0xb8860b, name : 'darkgoldenrod' }, 179 | { int : 0xa9a9a9, name : 'darkgray' }, 180 | { int : 0x006400, name : 'darkgreen' }, 181 | { int : 0xa9a9a9, name : 'darkgrey' }, 182 | { int : 0xbdb76b, name : 'darkkhaki' }, 183 | { int : 0x8b008b, name : 'darkmagenta' }, 184 | { int : 0x556b2f, name : 'darkolivegreen' }, 185 | { int : 0xff8c00, name : 'darkorange' }, 186 | { int : 0x9932cc, name : 'darkorchid' }, 187 | { int : 0x8b0000, name : 'darkred' }, 188 | { int : 0xe9967a, name : 'darksalmon' }, 189 | { int : 0x8fbc8f, name : 'darkseagreen' }, 190 | { int : 0x483d8b, name : 'darkslateblue' }, 191 | { int : 0x2f4f4f, name : 'darkslategray' }, 192 | { int : 0x2f4f4f, name : 'darkslategrey' }, 193 | { int : 0x00ced1, name : 'darkturquoise' }, 194 | { int : 0x9400d3, name : 'darkviolet' }, 195 | { int : 0xff1493, name : 'deeppink' }, 196 | { int : 0x00bfff, name : 'deepskyblue' }, 197 | { int : 0x696969, name : 'dimgray' }, 198 | { int : 0x696969, name : 'dimgrey' }, 199 | { int : 0x1e90ff, name : 'dodgerblue' }, 200 | { int : 0xb22222, name : 'firebrick' }, 201 | { int : 0xfffaf0, name : 'floralwhite' }, 202 | { int : 0x228b22, name : 'forestgreen' }, 203 | { int : 0xdcdcdc, name : 'gainsboro' }, 204 | { int : 0xf8f8ff, name : 'ghostwhite' }, 205 | { int : 0xffd700, name : 'gold' }, 206 | { int : 0xdaa520, name : 'goldenrod' }, 207 | { int : 0xadff2f, name : 'greenyellow' }, 208 | { int : 0x808080, name : 'grey' }, 209 | { int : 0xf0fff0, name : 'honeydew' }, 210 | { int : 0xff69b4, name : 'hotpink' }, 211 | { int : 0xcd5c5c, name : 'indianred' }, 212 | { int : 0x4b0082, name : 'indigo' }, 213 | { int : 0xfffff0, name : 'ivory' }, 214 | { int : 0xf0e68c, name : 'khaki' }, 215 | { int : 0xe6e6fa, name : 'lavender' }, 216 | { int : 0xfff0f5, name : 'lavenderblush' }, 217 | { int : 0x7cfc00, name : 'lawngreen' }, 218 | { int : 0xfffacd, name : 'lemonchiffon' }, 219 | { int : 0xadd8e6, name : 'lightblue' }, 220 | { int : 0xf08080, name : 'lightcoral' }, 221 | { int : 0xe0ffff, name : 'lightcyan' }, 222 | { int : 0xfafad2, name : 'lightgoldenrodyellow' }, 223 | { int : 0xd3d3d3, name : 'lightgray' }, 224 | { int : 0x90ee90, name : 'lightgreen' }, 225 | { int : 0xd3d3d3, name : 'lightgrey' }, 226 | { int : 0xffb6c1, name : 'lightpink' }, 227 | { int : 0xffa07a, name : 'lightsalmon' }, 228 | { int : 0x20b2aa, name : 'lightseagreen' }, 229 | { int : 0x87cefa, name : 'lightskyblue' }, 230 | { int : 0x778899, name : 'lightslategray' }, 231 | { int : 0x778899, name : 'lightslategrey' }, 232 | { int : 0xb0c4de, name : 'lightsteelblue' }, 233 | { int : 0xffffe0, name : 'lightyellow' }, 234 | { int : 0x32cd32, name : 'limegreen' }, 235 | { int : 0xfaf0e6, name : 'linen' }, 236 | { int : 0xff00ff, name : 'fuchsia' }, 237 | { int : 0x66cdaa, name : 'mediumaquamarine' }, 238 | { int : 0x0000cd, name : 'mediumblue' }, 239 | { int : 0xba55d3, name : 'mediumorchid' }, 240 | { int : 0x9370db, name : 'mediumpurple' }, 241 | { int : 0x3cb371, name : 'mediumseagreen' }, 242 | { int : 0x7b68ee, name : 'mediumslateblue' }, 243 | { int : 0x00fa9a, name : 'mediumspringgreen' }, 244 | { int : 0x48d1cc, name : 'mediumturquoise' }, 245 | { int : 0xc71585, name : 'mediumvioletred' }, 246 | { int : 0x191970, name : 'midnightblue' }, 247 | { int : 0xf5fffa, name : 'mintcream' }, 248 | { int : 0xffe4e1, name : 'mistyrose' }, 249 | { int : 0xffe4b5, name : 'moccasin' }, 250 | { int : 0xffdead, name : 'navajowhite' }, 251 | { int : 0xfdf5e6, name : 'oldlace' }, 252 | { int : 0x6b8e23, name : 'olivedrab' }, 253 | { int : 0xff4500, name : 'orangered' }, 254 | { int : 0xda70d6, name : 'orchid' }, 255 | { int : 0xeee8aa, name : 'palegoldenrod' }, 256 | { int : 0x98fb98, name : 'palegreen' }, 257 | { int : 0xafeeee, name : 'paleturquoise' }, 258 | { int : 0xdb7093, name : 'palevioletred' }, 259 | { int : 0xffefd5, name : 'papayawhip' }, 260 | { int : 0xffdab9, name : 'peachpuff' }, 261 | { int : 0xcd853f, name : 'peru' }, 262 | { int : 0xffc0cb, name : 'pink' }, 263 | { int : 0xdda0dd, name : 'plum' }, 264 | { int : 0xb0e0e6, name : 'powderblue' }, 265 | { int : 0xbc8f8f, name : 'rosybrown' }, 266 | { int : 0x4169e1, name : 'royalblue' }, 267 | { int : 0x8b4513, name : 'saddlebrown' }, 268 | { int : 0xfa8072, name : 'salmon' }, 269 | { int : 0xf4a460, name : 'sandybrown' }, 270 | { int : 0x2e8b57, name : 'seagreen' }, 271 | { int : 0xfff5ee, name : 'seashell' }, 272 | { int : 0xa0522d, name : 'sienna' }, 273 | { int : 0x87ceeb, name : 'skyblue' }, 274 | { int : 0x6a5acd, name : 'slateblue' }, 275 | { int : 0x708090, name : 'slategray' }, 276 | { int : 0x708090, name : 'slategrey' }, 277 | { int : 0xfffafa, name : 'snow' }, 278 | { int : 0x00ff7f, name : 'springgreen' }, 279 | { int : 0x4682b4, name : 'steelblue' }, 280 | { int : 0xd2b48c, name : 'tan' }, 281 | { int : 0xd8bfd8, name : 'thistle' }, 282 | { int : 0xff6347, name : 'tomato' }, 283 | { int : 0x40e0d0, name : 'turquoise' }, 284 | { int : 0xee82ee, name : 'violet' }, 285 | { int : 0xf5deb3, name : 'wheat' }, 286 | { int : 0xf5f5f5, name : 'whitesmoke' }, 287 | { int : 0x9acd32, name : 'yellowgreen' } 288 | ] 289 | 290 | const _CSS4 = [..._CSS3, 291 | { int: 0x663399, name : 'rebeccapurple'} 292 | ] 293 | 294 | 295 | // Helper function 296 | function augment(pal) { 297 | return pal.map(el => { 298 | const rgb = int2rgb(el.int) 299 | const hex = rgb2hex(rgb) 300 | const css = rgb2css(rgb) 301 | const v = rgb2gray(rgb) 302 | return {...el, ...rgb, v, hex, css} 303 | }) 304 | } 305 | 306 | // Helper function 307 | function toMap(pal) { 308 | const out = {} 309 | pal.forEach(el => { 310 | out[el.name] = el 311 | }) 312 | return out 313 | } 314 | 315 | export const CSS4 = toMap(augment(_CSS4)) 316 | export const CSS3 = toMap(augment(_CSS3)) 317 | export const CSS2 = toMap(augment(_CSS2)) 318 | export const CSS1 = toMap(augment(_CSS1)) 319 | export const C64 = augment(_C64) 320 | export const CGA = augment(_CGA) 321 | 322 | ``` -------------------------------------------------------------------------------- /src/run.js: -------------------------------------------------------------------------------- ```javascript 1 | /** 2 | Runner 3 | */ 4 | 5 | // Both available renderers are imported 6 | import textRenderer from './core/textrenderer.js' 7 | import canvasRenderer from './core/canvasrenderer.js' 8 | import FPS from './core/fps.js' 9 | import storage from './core/storage.js' 10 | import RUNNER_VERSION from './core/version.js' 11 | 12 | export { RUNNER_VERSION } 13 | 14 | const renderers = { 15 | 'canvas' : canvasRenderer, 16 | 'text' : textRenderer 17 | } 18 | 19 | // Default settings for the program runner. 20 | // They can be overwritten by the parameters of the runner 21 | // or as a settings object exported by the program (in this order). 22 | const defaultSettings = { 23 | element : null, // target element for output 24 | cols : 0, // number of columns, 0 is equivalent to 'auto' 25 | rows : 0, // number of columns, 0 is equivalent to 'auto' 26 | once : false, // if set to true the renderer will run only once 27 | fps : 30, // fps capping 28 | renderer : 'text', // can be 'canvas', anything else falls back to 'text' 29 | allowSelect : false, // allows selection of the rendered element 30 | restoreState : false, // will store the "state" object in local storage 31 | // this is handy for live-coding situations 32 | } 33 | 34 | // CSS styles which can be passed to the container element via settings 35 | const CSSStyles = [ 36 | 'backgroundColor', 37 | 'color', 38 | 'fontFamily', 39 | 'fontSize', 40 | 'fontWeight', 41 | 'letterSpacing', 42 | 'lineHeight', 43 | 'textAlign', 44 | ] 45 | 46 | // Program runner. 47 | // Takes a program object (usually an imported module), 48 | // and some optional settings (see above) as arguments. 49 | // Finally, an optional userData object can be passed which will be available 50 | // as last parameter in all the module functions. 51 | // The program object should export at least a main(), pre() or post() function. 52 | export function run(program, runSettings, userData = {}) { 53 | 54 | // Everything is wrapped inside a promise; 55 | // in case of errors in ‘program’ it will reject without reaching the bottom. 56 | // If the program reaches the bottom of the first frame the promise is resolved. 57 | return new Promise(function(resolve) { 58 | // Merge of user- and default settings 59 | const settings = {...defaultSettings, ...runSettings, ...program.settings} 60 | 61 | // State is stored in local storage and will loaded on program launch 62 | // if settings.restoreState == true. 63 | // The purpose of this is to live edit the code without resetting 64 | // time and the frame counter. 65 | const state = { 66 | time : 0, // The time in ms 67 | frame : 0, // The frame number (int) 68 | cycle : 0 // An cycle count for debugging purposes 69 | } 70 | 71 | // Name of local storage key 72 | const LOCAL_STORAGE_KEY_STATE = 'currentState' 73 | 74 | if (settings.restoreState) { 75 | storage.restore(LOCAL_STORAGE_KEY_STATE, state) 76 | state.cycle++ // Keep track of the cycle count for debugging purposes 77 | } 78 | 79 | // If element is not provided create a default element based 80 | // on the renderer settings. 81 | // Then choose the renderer: 82 | // If the parent element is a canvas the canvas renderer is selected, 83 | // for any other type a text node (PRE or any othe text node) 84 | // is expected and the text renderer is used. 85 | // TODO: better / more generic renderer init 86 | let renderer 87 | if (!settings.element) { 88 | renderer = renderers[settings.renderer] || renderers['text'] 89 | settings.element = document.createElement(renderer.preferredElementNodeName) 90 | document.body.appendChild(settings.element) 91 | } else { 92 | if (settings.renderer == 'canvas') { 93 | if (settings.element.nodeName == 'CANVAS') { 94 | renderer = renderers[settings.renderer] 95 | } else { 96 | console.warn("This renderer expects a canvas target element.") 97 | } 98 | } else { 99 | if (settings.element.nodeName != 'CANVAS') { 100 | renderer = renderers[settings.renderer] 101 | } else { 102 | console.warn("This renderer expects a text target element.") 103 | } 104 | } 105 | } 106 | 107 | // Apply CSS settings to element 108 | for (const s of CSSStyles) { 109 | if (settings[s]) settings.element.style[s] = settings[s] 110 | } 111 | 112 | // Eventqueue 113 | // Stores events and pops them at the end of the renderloop 114 | // TODO: needed? 115 | const eventQueue = [] 116 | 117 | // Input pointer updated by DOM events 118 | const pointer = { 119 | x : 0, 120 | y : 0, 121 | pressed : false, 122 | px : 0, 123 | py : 0, 124 | ppressed : false, 125 | } 126 | 127 | settings.element.addEventListener('pointermove', e => { 128 | const rect = settings.element.getBoundingClientRect() 129 | pointer.x = e.clientX - rect.left 130 | pointer.y = e.clientY - rect.top 131 | eventQueue.push('pointerMove') 132 | }) 133 | 134 | settings.element.addEventListener('pointerdown', e => { 135 | pointer.pressed = true 136 | eventQueue.push('pointerDown') 137 | }) 138 | 139 | settings.element.addEventListener('pointerup', e => { 140 | pointer.pressed = false 141 | eventQueue.push('pointerUp') 142 | }) 143 | 144 | const touchHandler = e => { 145 | const rect = settings.element.getBoundingClientRect() 146 | pointer.x = e.touches[0].clientX - rect.left 147 | pointer.y = e.touches[0].clientY - rect.top 148 | eventQueue.push('pointerMove') 149 | } 150 | 151 | settings.element.addEventListener('touchmove', touchHandler) 152 | settings.element.addEventListener('touchstart', touchHandler) 153 | settings.element.addEventListener('touchend', touchHandler) 154 | 155 | 156 | // CSS fix 157 | settings.element.style.fontStrech = 'normal' 158 | 159 | // Text selection may be annoing in case of interactive programs 160 | if (!settings.allowSelect) disableSelect(settings.element) 161 | 162 | // Method to load a font via the FontFace object. 163 | // The load promise works 100% of the times. 164 | // But a definition of the font via CSS is preferable and more flexible. 165 | /* 166 | const CSSInfo = getCSSInfo(settings.element) 167 | var font = new FontFace('Simple Console', 'url(/css/fonts/simple/SimpleConsole-Light.woff)', { style: 'normal', weight: 400 }) 168 | font.load().then(function(f) { 169 | ... 170 | }) 171 | */ 172 | 173 | // Metrics needs to be calculated before boot 174 | // Even with the "fonts.ready" the font may STILL not be loaded yet 175 | // on Safari 13.x and also 14.0. 176 | // A (shitty) workaround is to wait 3! rAF. 177 | // Submitted: https://bugs.webkit.org/show_bug.cgi?id=217047 178 | document.fonts.ready.then((e) => { 179 | // Run this three times... 180 | let count = 3 181 | ;(function __run_thrice__() { 182 | if (--count > 0) { 183 | requestAnimationFrame(__run_thrice__) 184 | } else { 185 | // settings.element.style.lineHeight = Math.ceil(metrics.lineHeightf) + 'px' 186 | // console.log(`Using font faimily: ${ci.fontFamily} @ ${ci.fontSize}/${ci.lineHeight}`) 187 | // console.log(`Metrics: cellWidth: ${metrics.cellWidth}, lineHeightf: ${metrics.lineHeightf}`) 188 | // Finally Boot! 189 | boot() 190 | } 191 | })() 192 | // Ideal mode: 193 | // metrics = calcMetrics(settings.element) 194 | // etc. 195 | // requestAnimationFrame(loop) 196 | }) 197 | 198 | // FPS object (keeps some state for precise FPS measure) 199 | const fps = new FPS() 200 | 201 | // A cell with no value at all is just a space 202 | const EMPTY_CELL = ' ' 203 | 204 | // Default cell style inserted in case of undefined / null 205 | const DEFAULT_CELL_STYLE = Object.freeze({ 206 | color : settings.color, 207 | backgroundColor : settings.backgroundColor, 208 | fontWeight : settings.fontWeight 209 | }) 210 | 211 | // Buffer needed for the final DOM rendering, 212 | // each array entry represents a cell. 213 | const buffer = [] 214 | 215 | // Metrics object, calc once (below) 216 | let metrics 217 | 218 | function boot() { 219 | metrics = calcMetrics(settings.element) 220 | const context = getContext(state, settings, metrics, fps) 221 | if (typeof program.boot == 'function') { 222 | program.boot(context, buffer, userData) 223 | } 224 | requestAnimationFrame(loop) 225 | } 226 | 227 | // Time sample to calculate precise offset 228 | let timeSample = 0 229 | // Previous time step to increment state.time (with state.time initial offset) 230 | let ptime = 0 231 | const interval = 1000 / settings.fps 232 | const timeOffset = state.time 233 | 234 | // Used to track window resize 235 | let cols, rows 236 | 237 | // Main program loop 238 | function loop(t) { 239 | 240 | // Timing 241 | const delta = t - timeSample 242 | if (delta < interval) { 243 | // Skip the frame 244 | if (!settings.once) requestAnimationFrame(loop) 245 | return 246 | } 247 | 248 | // Snapshot of context data 249 | const context = getContext(state, settings, metrics, fps) 250 | 251 | // FPS update 252 | fps.update(t) 253 | 254 | // Timing update 255 | timeSample = t - delta % interval // adjust timeSample 256 | state.time = t + timeOffset // increment time + initial offs 257 | state.frame++ // increment frame counter 258 | storage.store(LOCAL_STORAGE_KEY_STATE, state) // store state 259 | 260 | // Cursor update 261 | const cursor = { 262 | // The canvas might be slightly larger than the number 263 | // of cols/rows, min is required! 264 | x : Math.min(context.cols-1, pointer.x / metrics.cellWidth), 265 | y : Math.min(context.rows-1, pointer.y / metrics.lineHeight), 266 | pressed : pointer.pressed, 267 | p : { // state of previous frame 268 | x : pointer.px / metrics.cellWidth, 269 | y : pointer.py / metrics.lineHeight, 270 | pressed : pointer.ppressed, 271 | } 272 | } 273 | 274 | // Pointer: store previous state 275 | pointer.px = pointer.x 276 | pointer.py = pointer.y 277 | pointer.ppressed = pointer.pressed 278 | 279 | // 1. -------------------------------------------------------------- 280 | // In case of resize / init normalize the buffer 281 | if (cols != context.cols || rows != context.rows) { 282 | cols = context.cols 283 | rows = context.rows 284 | buffer.length = context.cols * context.rows 285 | for (let i=0; i<buffer.length; i++) { 286 | buffer[i] = {...DEFAULT_CELL_STYLE, char : EMPTY_CELL} 287 | } 288 | } 289 | 290 | // 2. -------------------------------------------------------------- 291 | // Call pre(), if defined 292 | if (typeof program.pre == 'function') { 293 | program.pre(context, cursor, buffer, userData) 294 | } 295 | 296 | // 3. -------------------------------------------------------------- 297 | // Call main(), if defined 298 | if (typeof program.main == 'function') { 299 | for (let j=0; j<context.rows; j++) { 300 | const offs = j * context.cols 301 | for (let i=0; i<context.cols; i++) { 302 | const idx = i + offs 303 | // Override content: 304 | // buffer[idx] = program.main({x:i, y:j, index:idx}, context, cursor, buffer, userData) 305 | const out = program.main({x:i, y:j, index:idx}, context, cursor, buffer, userData) 306 | if (typeof out == 'object' && out !== null) { 307 | buffer[idx] = {...buffer[idx], ...out} 308 | } else { 309 | buffer[idx] = {...buffer[idx], char : out} 310 | } 311 | // Fix undefined / null / etc. 312 | if (!Boolean(buffer[idx].char) && buffer[idx].char !== 0) { 313 | buffer[idx].char = EMPTY_CELL 314 | } 315 | } 316 | } 317 | } 318 | 319 | // 4. -------------------------------------------------------------- 320 | // Call post(), if defined 321 | if (typeof program.post == 'function') { 322 | program.post(context, cursor, buffer, userData) 323 | } 324 | 325 | // 5. -------------------------------------------------------------- 326 | renderer.render(context, buffer, settings) 327 | 328 | // 6. -------------------------------------------------------------- 329 | // Queued events 330 | while (eventQueue.length > 0) { 331 | const type = eventQueue.shift() 332 | if (type && typeof program[type] == 'function') { 333 | program[type](context, cursor, buffer) 334 | } 335 | } 336 | 337 | // 7. -------------------------------------------------------------- 338 | // Loop (eventually) 339 | if (!settings.once) requestAnimationFrame(loop) 340 | 341 | // The end of the first frame is reached without errors 342 | // the promise can be resolved. 343 | resolve(context) 344 | } 345 | }) 346 | } 347 | 348 | // -- Helpers ------------------------------------------------------------------ 349 | 350 | // Build / update the 'context' object (immutable) 351 | // A bit of spaghetti... but the context object needs to be ready for 352 | // the boot function and also to be updated at each frame. 353 | function getContext(state, settings, metrics, fps) { 354 | const rect = settings.element.getBoundingClientRect() 355 | const cols = settings.cols || Math.floor(rect.width / metrics.cellWidth) 356 | const rows = settings.rows || Math.floor(rect.height / metrics.lineHeight) 357 | return Object.freeze({ 358 | frame : state.frame, 359 | time : state.time, 360 | cols, 361 | rows, 362 | metrics, 363 | width : rect.width, 364 | height : rect.height, 365 | settings, 366 | // Runtime & debug data 367 | runtime : Object.freeze({ 368 | cycle : state.cycle, 369 | fps : fps.fps 370 | // updatedRowNum 371 | }) 372 | }) 373 | } 374 | 375 | // Disables selection for an HTML element 376 | function disableSelect(el) { 377 | el.style.userSelect = 'none' 378 | el.style.webkitUserSelect = 'none' // for Safari on mac and iOS 379 | el.style.mozUserSelect = 'none' // for mobile FF 380 | el.dataset.selectionEnabled = 'false' 381 | } 382 | 383 | // Enables selection for an HTML element 384 | function enableSelect(el) { 385 | el.style.userSelect = 'auto' 386 | el.style.webkitUserSelect = 'auto' 387 | el.style.mozUserSelect = 'auto' 388 | el.dataset.selectionEnabled = 'true' 389 | } 390 | 391 | // Copies the content of an element to the clipboard 392 | export function copyContent(el) { 393 | // Store selection default 394 | const selectionEnabled = !el.dataset.selectionEnabled == 'false' 395 | 396 | // Enable selection if necessary 397 | if (!selectionEnabled) enableSelect(el) 398 | 399 | // Copy the text block 400 | const range = document.createRange() 401 | range.selectNode(el) 402 | const sel = window.getSelection() 403 | sel.removeAllRanges() 404 | sel.addRange(range) 405 | document.execCommand('copy') 406 | sel.removeAllRanges() 407 | 408 | // Restore default, if necessary 409 | if (!selectionEnabled) disableSelect(el) 410 | } 411 | 412 | // Calcs width (fract), height, aspect of a monospaced char 413 | // assuming that the CSS font-family is a monospaced font. 414 | // Returns a mutable object. 415 | export function calcMetrics(el) { 416 | 417 | const style = getComputedStyle(el) 418 | 419 | // Extract info from the style: in case of a canvas element 420 | // the style and font family should be set anyways. 421 | const fontFamily = style.getPropertyValue('font-family') 422 | const fontSize = parseFloat(style.getPropertyValue('font-size')) 423 | // Can’t rely on computed lineHeight since Safari 14.1 424 | // See: https://bugs.webkit.org/show_bug.cgi?id=225695 425 | const lineHeight = parseFloat(style.getPropertyValue('line-height')) 426 | let cellWidth 427 | 428 | // If the output element is a canvas 'measureText()' is used 429 | // else cellWidth is computed 'by hand' (should be the same, in any case) 430 | if (el.nodeName == 'CANVAS') { 431 | const ctx = el.getContext('2d') 432 | ctx.font = fontSize + 'px ' + fontFamily 433 | cellWidth = ctx.measureText(''.padEnd(50, 'X')).width / 50 434 | } else { 435 | const span = document.createElement('span') 436 | el.appendChild(span) 437 | span.innerHTML = ''.padEnd(50, 'X') 438 | cellWidth = span.getBoundingClientRect().width / 50 439 | el.removeChild(span) 440 | } 441 | 442 | const metrics = { 443 | aspect : cellWidth / lineHeight, 444 | cellWidth, 445 | lineHeight, 446 | fontFamily, 447 | fontSize, 448 | // Semi-hackish way to allow an update of the metrics object. 449 | // This may be useful in some situations, for example 450 | // responsive layouts with baseline or font change. 451 | // NOTE: It’s not an immutable object anymore 452 | _update : function() { 453 | const tmp = calcMetrics(el) 454 | for(var k in tmp) { 455 | // NOTE: Object.assign won’t work 456 | if (typeof tmp[k] == 'number' || typeof tmp[k] == 'string') { 457 | m[k] = tmp[k] 458 | } 459 | } 460 | } 461 | } 462 | 463 | return metrics 464 | } 465 | 466 | 467 | ```