| ⋯ 34 unchanged lines |
| 35 | 35 | | */ |
| 36 | 36 | | edgeLine: string; |
| 37 | 37 | | edgeWidth: number; |
| 38 | + | /** |
| 39 | + | * How thick a tile stands, as a fraction of its width, and where the light |
| 40 | + | * is — clockwise from straight up the screen, in degrees. |
| 41 | + | * |
| 42 | + | * The sides are *worked out*, not painted. The art has a bevel baked into it, |
| 43 | + | * which was fine while every tile stood the same way up and wrong the moment |
| 44 | + | * they were scattered: a painted highlight turns with the tile, so a pool of |
| 45 | + | * discards ended up with a different light on each one. These are the four |
| 46 | + | * real edges of the rotated rectangle, extruded down the screen, and only the |
| 47 | + | * ones actually facing you are drawn — so a tile at any angle is lit by the |
| 48 | + | * one light over the table. |
| 49 | + | */ |
| 50 | + | depth: number; |
| 51 | + | /** |
| 52 | + | * Which way the tile's base sits from its face, clockwise from up. Down and |
| 53 | + | * to the left is what the art itself uses, and it is what lets you see two |
| 54 | + | * sides of a tile at once rather than one. |
| 55 | + | */ |
| 56 | + | depthAngle: number; |
| 57 | + | lightAngle: number; |
| 58 | + | /** |
| 59 | + | * How much of each cell is the *painted* side, and how much is its outline, |
| 60 | + | * both as a fraction of the cell's width. |
| 61 | + | * |
| 62 | + | * The sheet is not a picture of a tile's face — it is a picture of a whole |
| 63 | + | * tile, drawn in the same oblique projection as this, with its own sides |
| 64 | + | * painted down the left and along the bottom and its own light coming from |
| 65 | + | * the top right. That was fine while every tile stood the same way up. Turned, |
| 66 | + | * it is a pre-rendered solid being spun, which is why a scattered pool looked |
| 67 | + | * like every tile had its own private sun. Cropped back to the flat face, the |
| 68 | + | * art is a face and the solid is worked out here. |
| 69 | + | */ |
| 70 | + | artBevel: number; |
| 71 | + | artEdge: number; |
| 72 | + | /** The tile's own ivory, full on the light and full away from it. */ |
| 73 | + | sideLight: string; |
| 74 | + | sideDark: string; |
| 38 | 75 | | /** The shadow: how dark at rest, how much height washes it out, how far it is |
| 39 | 76 | | * offset across and down, how wide it spreads, and how soft it is. */ |
| 40 | 77 | | shadowAlpha: number; |
| ⋯ 8 unchanged lines |
| 49 | 86 | | export const LOOK: TileLook = { |
| 50 | 87 | | edgeLine: 'rgba(52, 43, 29, 0.55)', |
| 51 | 88 | | edgeWidth: 1, |
| 89 | + | depth: 0.125, |
| 90 | + | depthAngle: 225, |
| 91 | + | lightAngle: 35, |
| 92 | + | // The two greys the art shades its own sides with, so a tile is the tile it |
| 93 | + | // always was — only lit by one sun instead of one each. |
| 94 | + | sideLight: '#d7d7d7', |
| 95 | + | sideDark: '#9f9f9f', |
| 96 | + | artBevel: 0.125, |
| 97 | + | artEdge: 0.016, |
| 52 | 98 | | shadowAlpha: 0.36, |
| 53 | 99 | | shadowFade: 0.0016, |
| 54 | 100 | | shadowAcross: 0.12, |
| ⋯ 33 unchanged lines |
| 88 | 134 | | lit: boolean; |
| 89 | 135 | | } |
| 90 | 136 | | |
| 137 | + | /** Somewhere between two colours, `t` from the first to the second. */ |
| 138 | + | function mix(a: string, b: string, t: number): string { |
| 139 | + | const hex = (c: string) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)); |
| 140 | + | const [ar, ag, ab] = hex(a); |
| 141 | + | const [br, bg, bb] = hex(b); |
| 142 | + | const k = Math.max(0, Math.min(1, t)); |
| 143 | + | return `rgb(${Math.round(ar + (br - ar) * k)},${Math.round(ag + (bg - ag) * k)},${Math.round( |
| 144 | + | ab + (bb - ab) * k, |
| 145 | + | )})`; |
| 146 | + | } |
| 147 | + | |
| 91 | 148 | | /** |
| 149 | + | * The sides of the tile you can actually see, lit by the one light. |
| 150 | + | * |
| 151 | + | * A tile is a box lying on cloth, seen from almost overhead and a little in |
| 152 | + | * front. What that shows of its sides is the two or three edges pointing |
| 153 | + | * towards the viewer — down the screen — each a quad between the top face's |
| 154 | + | * edge and the same edge dropped by the tile's depth. Which of them are lit is |
| 155 | + | * decided per edge, from the direction that edge faces on *screen*: turn the |
| 156 | + | * tile and the same physical side comes round to face a different way, and its |
| 157 | + | * shading changes with it, which is the whole point. |
| 158 | + | */ |
| 159 | + | function sides(ctx: CanvasRenderingContext2D, d: Drawn, w: number, h: number, look: TileLook) { |
| 160 | + | const depth = w * look.depth; |
| 161 | + | if (depth <= 0) return; |
| 162 | + | |
| 163 | + | const c = Math.cos(d.angle); |
| 164 | + | const s = Math.sin(d.angle); |
| 165 | + | const corner = (x: number, y: number) => ({ x: d.x + x * c - y * s, y: d.y + x * s + y * c }); |
| 166 | + | const box = [ |
| 167 | + | corner(-w / 2, -h / 2), |
| 168 | + | corner(w / 2, -h / 2), |
| 169 | + | corner(w / 2, h / 2), |
| 170 | + | corner(-w / 2, h / 2), |
| 171 | + | ]; |
| 172 | + | |
| 173 | + | // Straight up the screen is 0, turning clockwise, so both of these read like |
| 174 | + | // a compass. `out` is where the tile's base sits from its face — the whole of |
| 175 | + | // the projection, and the only reason any side is in sight at all. |
| 176 | + | const lit = (look.lightAngle * Math.PI) / 180; |
| 177 | + | const light = { x: Math.sin(lit), y: -Math.cos(lit) }; |
| 178 | + | const off = (look.depthAngle * Math.PI) / 180; |
| 179 | + | const out = { x: Math.sin(off) * depth, y: -Math.cos(off) * depth }; |
| 180 | + | |
| 181 | + | for (let i = 0; i < 4; i++) { |
| 182 | + | const a = box[i]; |
| 183 | + | const b = box[(i + 1) % 4]; |
| 184 | + | // Outward, in screen terms: the edge's normal, turned to point away from |
| 185 | + | // the middle of the tile. |
| 186 | + | const ex = b.x - a.x; |
| 187 | + | const ey = b.y - a.y; |
| 188 | + | const len = Math.hypot(ex, ey) || 1; |
| 189 | + | let nx = ey / len; |
| 190 | + | let ny = -ex / len; |
| 191 | + | if (nx * (a.x + b.x - 2 * d.x) + ny * (a.y + b.y - 2 * d.y) < 0) { |
| 192 | + | nx = -nx; |
| 193 | + | ny = -ny; |
| 194 | + | } |
| 195 | + | // Only the edges the base falls away from are in sight at all. |
| 196 | + | if (nx * out.x + ny * out.y <= 0.001) continue; |
| 197 | + | |
| 198 | + | ctx.beginPath(); |
| 199 | + | ctx.moveTo(a.x, a.y); |
| 200 | + | ctx.lineTo(b.x, b.y); |
| 201 | + | ctx.lineTo(b.x + out.x, b.y + out.y); |
| 202 | + | ctx.lineTo(a.x + out.x, a.y + out.y); |
| 203 | + | ctx.closePath(); |
| 204 | + | // Facing the light is 1, facing away is 0. |
| 205 | + | ctx.fillStyle = mix(look.sideDark, look.sideLight, (nx * light.x + ny * light.y + 1) / 2); |
| 206 | + | ctx.fill(); |
| 207 | + | ctx.strokeStyle = look.edgeLine; |
| 208 | + | ctx.lineWidth = look.edgeWidth; |
| 209 | + | ctx.stroke(); |
| 210 | + | } |
| 211 | + | } |
| 212 | + | |
| 213 | + | /** |
| 92 | 214 | | * One tile: its face, its outline, and nothing else. |
| 93 | 215 | | * |
| 94 | 216 | | * There used to be a slab of ivory drawn under it and offset down the screen, |
| ⋯ 16 unchanged lines |
| 111 | 233 | | const w = d.w * s; |
| 112 | 234 | | const h = d.h * s; |
| 113 | 235 | | |
| 236 | + | // The sides first: the face sits on top of them, which is what makes the |
| 237 | + | // whole thing read as a block rather than a card with a border. |
| 238 | + | sides(ctx, d, w, h, look); |
| 239 | + | |
| 114 | 240 | | ctx.save(); |
| 115 | 241 | | ctx.translate(d.x, d.y); |
| 116 | 242 | | // Edge on there is no face left to see, so it pinches to a line rather than |
| ⋯ 25 unchanged lines |
| 142 | 268 | | const cells = new Map<string, HTMLCanvasElement>(); |
| 143 | 269 | | let cellsFrom: HTMLImageElement | null = null; |
| 144 | 270 | | |
| 145 | | - | function cellOf(sheet: HTMLImageElement, tile: number, lit: boolean): HTMLCanvasElement { |
| 271 | + | function cellOf( |
| 272 | + | sheet: HTMLImageElement, |
| 273 | + | tile: number, |
| 274 | + | lit: boolean, |
| 275 | + | look: TileLook, |
| 276 | + | ): HTMLCanvasElement { |
| 146 | 277 | | // A different sheet — a hot reload, in practice — is a different set of cells. |
| 147 | 278 | | if (cellsFrom !== sheet) { |
| 148 | 279 | | cells.clear(); |
| 149 | 280 | | cellsFrom = sheet; |
| 150 | 281 | | } |
| 151 | | - | const key = `${tile}:${lit}`; |
| 282 | + | const key = `${tile}:${lit}:${look.artBevel}:${look.artEdge}`; |
| 152 | 283 | | const had = cells.get(key); |
| 153 | 284 | | if (had) return had; |
| 154 | 285 | | |
| 155 | 286 | | const cw = sheet.naturalWidth / SPRITE_COLS; |
| 156 | 287 | | const ch = sheet.naturalHeight / 2; |
| 288 | + | // A thickness is a thickness whichever way it is measured, so both insets are |
| 289 | + | // taken off the cell's *width*. The painted side is down the left and along |
| 290 | + | // the bottom; the other two carry only the outline. |
| 291 | + | const bevel = cw * look.artBevel; |
| 292 | + | const edge = cw * look.artEdge; |
| 293 | + | const sx = SPRITE_COL[tile] * cw + bevel; |
| 294 | + | const sy = (lit ? ch : 0) + edge; |
| 295 | + | const sw = cw - bevel - edge; |
| 296 | + | const sh = ch - edge - bevel; |
| 297 | + | |
| 157 | 298 | | const c = document.createElement('canvas'); |
| 158 | | - | c.width = Math.round(cw); |
| 159 | | - | c.height = Math.round(ch); |
| 160 | | - | c.getContext('2d')?.drawImage( |
| 161 | | - | sheet, |
| 162 | | - | SPRITE_COL[tile] * cw, |
| 163 | | - | lit ? ch : 0, |
| 164 | | - | cw, |
| 165 | | - | ch, |
| 166 | | - | 0, |
| 167 | | - | 0, |
| 168 | | - | c.width, |
| 169 | | - | c.height, |
| 170 | | - | ); |
| 299 | + | c.width = Math.max(1, Math.round(sw)); |
| 300 | + | c.height = Math.max(1, Math.round(sh)); |
| 301 | + | c.getContext('2d')?.drawImage(sheet, sx, sy, sw, sh, 0, 0, c.width, c.height); |
| 171 | 302 | | cells.set(key, c); |
| 172 | 303 | | return c; |
| 173 | 304 | | } |
| ⋯ 114 unchanged lines |
| 288 | 419 | | } |
| 289 | 420 | | |
| 290 | 421 | | if (art.faces) { |
| 291 | | - | ctx.drawImage(cellOf(art.faces, tile, lit), -w / 2, -h / 2, w, h); |
| 422 | + | ctx.drawImage(cellOf(art.faces, tile, lit, look), -w / 2, -h / 2, w, h); |
| 292 | 423 | | } else { |
| 293 | 424 | | tilePath(ctx, w, h); |
| 294 | 425 | | ctx.fillStyle = '#f6f0e2'; |
| ⋯ 20 unchanged lines |