The masonry becomes files: first-person textures join the paintable art
The six materials move out of the renderer into public/textures/*.png — repaint any of them (any size; the sampler reads each file's own scale) and refresh, exactly as the token SVGs work. fpv/textures.ts keeps the procedural bake as the shipped default and the stand-in while files load, and the /?tokens gallery grows a masonry section showing the set beside the tokens they will someday match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
cf6349aca8
commit
fb89d752ae
Binary file not shown.
|
After Width: | Height: | Size: 650 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 536 B |
Binary file not shown.
|
After Width: | Height: | Size: 412 B |
Binary file not shown.
|
After Width: | Height: | Size: 517 B |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
@@ -86,6 +86,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<h2>The masonry — first-person wall textures</h2>
|
||||||
|
<p class="sub">
|
||||||
|
The materials the raycaster (<code>/?fpv</code>) wraps its walls in.
|
||||||
|
Each lives at <code>public/textures/<name>.png</code> — repaint the
|
||||||
|
file (any size) and refresh; the built-in procedural bake stands in
|
||||||
|
wherever a file is missing.
|
||||||
|
</p>
|
||||||
|
<div class="grid">
|
||||||
|
{#each ["wall", "rim", "stone", "door", "firewall", "warp"] as name (name)}
|
||||||
|
<figure>
|
||||||
|
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
|
||||||
|
<figcaption>{name}</figcaption>
|
||||||
|
</figure>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>Walls & doors — as the maze draws them</h2>
|
<h2>Walls & doors — as the maze draws them</h2>
|
||||||
<p class="sub">
|
<p class="sub">
|
||||||
Wall-segment effects have never been tokens: locks picked, jammed, and
|
Wall-segment effects have never been tokens: locks picked, jammed, and
|
||||||
@@ -172,4 +188,11 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding-top: 0.35rem;
|
padding-top: 0.35rem;
|
||||||
}
|
}
|
||||||
|
.masonry {
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
border: 1px solid #3a3428;
|
||||||
|
background: #101318;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// billboarded for whatever stands in the corridors, occluded per column
|
// billboarded for whatever stands in the corridors, occluded per column
|
||||||
// by the same depth buffer the walls wrote.
|
// by the same depth buffer the walls wrote.
|
||||||
import { castRay, billboards, type Billboard } from "./raycast";
|
import { castRay, billboards, type Billboard } from "./raycast";
|
||||||
|
import { materialTextures } from "./textures";
|
||||||
import { tokenArt } from "../art";
|
import { tokenArt } from "../art";
|
||||||
import type { GameView } from "@wizwar/engine";
|
import type { GameView } from "@wizwar/engine";
|
||||||
|
|
||||||
@@ -52,84 +53,10 @@
|
|||||||
warp: [96, 60, 160],
|
warp: [96, 60, 160],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Textures are baked once at 64x64 and sampled one column at a time —
|
// Materials come from /textures/*.png when those files exist — the
|
||||||
// perspective-correct verticals for the price of a single drawImage.
|
// independently paintable set — with the baked procedurals underneath
|
||||||
const TEX = 64;
|
// so a missing or still-loading file never leaves a wall naked.
|
||||||
function bake(paint: (c: CanvasRenderingContext2D) => void): HTMLCanvasElement {
|
const textures = materialTextures();
|
||||||
const t = document.createElement("canvas");
|
|
||||||
t.width = TEX; t.height = TEX;
|
|
||||||
const c = t.getContext("2d")!;
|
|
||||||
paint(c);
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
function h2(a: number, b: number): number {
|
|
||||||
// Cheap deterministic jitter for brick shading.
|
|
||||||
const n = Math.sin(a * 127.1 + b * 311.7) * 43758.5453;
|
|
||||||
return n - Math.floor(n);
|
|
||||||
}
|
|
||||||
function brickTexture(base: [number, number, number], rows: number, mortar: string): HTMLCanvasElement {
|
|
||||||
return bake((c) => {
|
|
||||||
const rh = TEX / rows;
|
|
||||||
for (let r = 0; r < rows; r++) {
|
|
||||||
const offset = (r % 2) * (TEX / 4);
|
|
||||||
for (let b = -1; b < 3; b++) {
|
|
||||||
const jit = 0.82 + 0.30 * h2(r, b);
|
|
||||||
c.fillStyle = `rgb(${base[0] * jit | 0},${base[1] * jit | 0},${base[2] * jit | 0})`;
|
|
||||||
c.fillRect(b * (TEX / 2) + offset, r * rh, TEX / 2 - 2, rh - 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.fillStyle = mortar;
|
|
||||||
for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const textures: Record<string, HTMLCanvasElement> = {
|
|
||||||
wall: brickTexture(FACE.wall!, 4, "rgba(28,24,18,0.9)"),
|
|
||||||
rim: brickTexture(FACE.rim!, 3, "rgba(16,14,12,0.95)"),
|
|
||||||
stone: brickTexture(FACE.stone!, 2, "rgba(20,20,26,0.9)"),
|
|
||||||
door: bake((c) => {
|
|
||||||
const [r, g, b] = FACE.door!;
|
|
||||||
for (let p = 0; p < 4; p++) {
|
|
||||||
const jit = 0.86 + 0.22 * h2(p, 7);
|
|
||||||
c.fillStyle = `rgb(${r * jit | 0},${g * jit | 0},${b * jit | 0})`;
|
|
||||||
c.fillRect(p * (TEX / 4), 0, TEX / 4 - 2, TEX);
|
|
||||||
}
|
|
||||||
c.fillStyle = "rgba(30,20,10,0.9)";
|
|
||||||
for (let p = 0; p <= 4; p++) c.fillRect(p * (TEX / 4) - 1, 0, 2, TEX);
|
|
||||||
c.fillRect(0, 0, TEX, 4); // lintel
|
|
||||||
c.fillStyle = "rgba(60,60,66,0.9)"; // iron band and handle
|
|
||||||
c.fillRect(0, TEX * 0.42, TEX, 3);
|
|
||||||
c.beginPath();
|
|
||||||
c.arc(TEX * 0.78, TEX * 0.52, 2.5, 0, Math.PI * 2);
|
|
||||||
c.fill();
|
|
||||||
}),
|
|
||||||
firewall: bake((c) => {
|
|
||||||
const grad = c.createLinearGradient(0, 0, 0, TEX);
|
|
||||||
grad.addColorStop(0, "#7c1a14");
|
|
||||||
grad.addColorStop(0.55, "#d65c1c");
|
|
||||||
grad.addColorStop(1, "#f0a13a");
|
|
||||||
c.fillStyle = grad;
|
|
||||||
c.fillRect(0, 0, TEX, TEX);
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
c.fillStyle = `rgba(255,${180 + (h2(i, 3) * 60) | 0},80,${0.25 + h2(i, 5) * 0.3})`;
|
|
||||||
const w = 2 + h2(i, 9) * 4;
|
|
||||||
c.fillRect(h2(i, 1) * TEX, TEX - h2(i, 7) * TEX, w, h2(i, 7) * TEX);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
warp: bake((c) => {
|
|
||||||
const grad = c.createLinearGradient(0, 0, TEX, TEX);
|
|
||||||
grad.addColorStop(0, "#2c1a4e");
|
|
||||||
grad.addColorStop(0.5, "#7a4ccc");
|
|
||||||
grad.addColorStop(1, "#3a2266");
|
|
||||||
c.fillStyle = grad;
|
|
||||||
c.fillRect(0, 0, TEX, TEX);
|
|
||||||
c.strokeStyle = "rgba(200,170,255,0.5)";
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
c.beginPath();
|
|
||||||
c.arc(TEX / 2, TEX / 2, 6 + i * 5, i, i + 4.2);
|
|
||||||
c.stroke();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
function draw(time: number) {
|
function draw(time: number) {
|
||||||
const ctx = canvas?.getContext("2d");
|
const ctx = canvas?.getContext("2d");
|
||||||
@@ -193,7 +120,9 @@
|
|||||||
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
|
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
|
||||||
const top = half - wallH / 2;
|
const top = half - wallH / 2;
|
||||||
const tex = textures[hit.kind] ?? textures.wall!;
|
const tex = textures[hit.kind] ?? textures.wall!;
|
||||||
ctx.drawImage(tex, hit.u * TEX, 0, 1, TEX, col, top, 1, wallH);
|
// Sample by the texture's own size: painted files may be any scale.
|
||||||
|
ctx.drawImage(tex, Math.min(tex.width - 1, hit.u * tex.width), 0,
|
||||||
|
Math.max(1, tex.width / 96), tex.height, col, top, 1, wallH);
|
||||||
// Distance and orientation carve the light; overlays animate it.
|
// Distance and orientation carve the light; overlays animate it.
|
||||||
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
|
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
|
||||||
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
|
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// The first-person materials. Each has two lives: a hand-paintable file
|
||||||
|
// at /textures/<name>.png (the one to improve — any size, sampled by
|
||||||
|
// column like the token art is swapped by file), and the procedural bake
|
||||||
|
// below that ships as the default and stands in while a file loads.
|
||||||
|
|
||||||
|
const FACE: Record<string, [number, number, number]> = {
|
||||||
|
wall: [126, 118, 100],
|
||||||
|
stone: [96, 96, 104],
|
||||||
|
door: [130, 92, 48],
|
||||||
|
rim: [82, 76, 66],
|
||||||
|
firewall: [214, 92, 28],
|
||||||
|
warp: [96, 60, 160],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Textures are baked once at 64x64 and sampled one column at a time —
|
||||||
|
// perspective-correct verticals for the price of a single drawImage.
|
||||||
|
export const TEX = 64;
|
||||||
|
function bake(paint: (c: CanvasRenderingContext2D) => void): HTMLCanvasElement {
|
||||||
|
const t = document.createElement("canvas");
|
||||||
|
t.width = TEX; t.height = TEX;
|
||||||
|
const c = t.getContext("2d")!;
|
||||||
|
paint(c);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
function h2(a: number, b: number): number {
|
||||||
|
// Cheap deterministic jitter for brick shading.
|
||||||
|
const n = Math.sin(a * 127.1 + b * 311.7) * 43758.5453;
|
||||||
|
return n - Math.floor(n);
|
||||||
|
}
|
||||||
|
function brickTexture(base: [number, number, number], rows: number, mortar: string): HTMLCanvasElement {
|
||||||
|
return bake((c) => {
|
||||||
|
const rh = TEX / rows;
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
const offset = (r % 2) * (TEX / 4);
|
||||||
|
for (let b = -1; b < 3; b++) {
|
||||||
|
const jit = 0.82 + 0.30 * h2(r, b);
|
||||||
|
c.fillStyle = `rgb(${base[0] * jit | 0},${base[1] * jit | 0},${base[2] * jit | 0})`;
|
||||||
|
c.fillRect(b * (TEX / 2) + offset, r * rh, TEX / 2 - 2, rh - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.fillStyle = mortar;
|
||||||
|
for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp"] as const;
|
||||||
|
|
||||||
|
/** The built-in bake: what ships when no painted file overrides it. */
|
||||||
|
export function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
||||||
|
return {
|
||||||
|
wall: brickTexture(FACE.wall!, 4, "rgba(28,24,18,0.9)"),
|
||||||
|
rim: brickTexture(FACE.rim!, 3, "rgba(16,14,12,0.95)"),
|
||||||
|
stone: brickTexture(FACE.stone!, 2, "rgba(20,20,26,0.9)"),
|
||||||
|
door: bake((c) => {
|
||||||
|
const [r, g, b] = FACE.door!;
|
||||||
|
for (let p = 0; p < 4; p++) {
|
||||||
|
const jit = 0.86 + 0.22 * h2(p, 7);
|
||||||
|
c.fillStyle = `rgb(${r * jit | 0},${g * jit | 0},${b * jit | 0})`;
|
||||||
|
c.fillRect(p * (TEX / 4), 0, TEX / 4 - 2, TEX);
|
||||||
|
}
|
||||||
|
c.fillStyle = "rgba(30,20,10,0.9)";
|
||||||
|
for (let p = 0; p <= 4; p++) c.fillRect(p * (TEX / 4) - 1, 0, 2, TEX);
|
||||||
|
c.fillRect(0, 0, TEX, 4); // lintel
|
||||||
|
c.fillStyle = "rgba(60,60,66,0.9)"; // iron band and handle
|
||||||
|
c.fillRect(0, TEX * 0.42, TEX, 3);
|
||||||
|
c.beginPath();
|
||||||
|
c.arc(TEX * 0.78, TEX * 0.52, 2.5, 0, Math.PI * 2);
|
||||||
|
c.fill();
|
||||||
|
}),
|
||||||
|
firewall: bake((c) => {
|
||||||
|
const grad = c.createLinearGradient(0, 0, 0, TEX);
|
||||||
|
grad.addColorStop(0, "#7c1a14");
|
||||||
|
grad.addColorStop(0.55, "#d65c1c");
|
||||||
|
grad.addColorStop(1, "#f0a13a");
|
||||||
|
c.fillStyle = grad;
|
||||||
|
c.fillRect(0, 0, TEX, TEX);
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
c.fillStyle = `rgba(255,${180 + (h2(i, 3) * 60) | 0},80,${0.25 + h2(i, 5) * 0.3})`;
|
||||||
|
const w = 2 + h2(i, 9) * 4;
|
||||||
|
c.fillRect(h2(i, 1) * TEX, TEX - h2(i, 7) * TEX, w, h2(i, 7) * TEX);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
warp: bake((c) => {
|
||||||
|
const grad = c.createLinearGradient(0, 0, TEX, TEX);
|
||||||
|
grad.addColorStop(0, "#2c1a4e");
|
||||||
|
grad.addColorStop(0.5, "#7a4ccc");
|
||||||
|
grad.addColorStop(1, "#3a2266");
|
||||||
|
c.fillStyle = grad;
|
||||||
|
c.fillRect(0, 0, TEX, TEX);
|
||||||
|
c.strokeStyle = "rgba(200,170,255,0.5)";
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
c.beginPath();
|
||||||
|
c.arc(TEX / 2, TEX / 2, 6 + i * 5, i, i + 4.2);
|
||||||
|
c.stroke();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The material set the renderer draws from: procedural now, each entry
|
||||||
|
* replaced in place the moment its painted file arrives. */
|
||||||
|
export function materialTextures(): Record<string, CanvasImageSource & { width: number; height: number }> {
|
||||||
|
const set: Record<string, CanvasImageSource & { width: number; height: number }> = proceduralTextures();
|
||||||
|
for (const name of MATERIALS) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => { set[name] = img; };
|
||||||
|
img.src = `/textures/${name}.png`;
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user