Give the board an illustrated-map feel instead of a flowchart
Same data-driven layout as before (nothing about computeLayout changed) — only what gets drawn changed: - Procedural terrain backdrop: a winding river, a few mountain clusters, and denser two-tone trees, scattered deterministically like the existing tree-blobs were, so none of it needs hand-alignment to specific tiles and it keeps working at any board size. - The road between spaces is now a wide dashed dirt path with a subtle hand-drawn "wobble" (SVG feTurbulence/feDisplacementMap) instead of a clean vector line. - Tiles are beveled: a drawn shadow copy, a gloss gradient, and a few degrees of per-tile tilt derived from a hash of the space id (deterministic, not random-per-render) so they read as hand-placed rather than machine-stamped. Choice diamonds get a small signpost stick underneath. Prototyped and screenshotted headlessly before committing; user confirmed this direction over the previous flowchart-style rendering.
This commit is contained in:
+137
-19
@@ -4,6 +4,12 @@
|
||||
* lane = branch offset that fans out at a 'choice' space and re-centers
|
||||
* (averages) at whichever space its branches rejoin. This means the layout
|
||||
* keeps working unmodified as shared/board.js grows.
|
||||
*
|
||||
* Visual style is illustrated-map-ish rather than flowchart-ish: a
|
||||
* procedurally scattered terrain backdrop (river, mountains, trees) that
|
||||
* doesn't need to align to any specific tile, a wide dashed "road" between
|
||||
* spaces, and tiles drawn as beveled signs with a small deterministic tilt
|
||||
* per space so they read as hand-placed rather than machine-stamped.
|
||||
*/
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
@@ -109,6 +115,18 @@ function escapeHtml(str) {
|
||||
));
|
||||
}
|
||||
|
||||
/** Small stable hash so per-tile tilt/detail is deterministic, not random-per-render. */
|
||||
function hashString(str) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0;
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
function makeRand(seed) {
|
||||
let s = seed;
|
||||
return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
|
||||
}
|
||||
|
||||
export class BoardView {
|
||||
constructor(container, board) {
|
||||
this.board = board;
|
||||
@@ -126,6 +144,7 @@ export class BoardView {
|
||||
role: 'img',
|
||||
'aria-label': 'Life Journey board',
|
||||
});
|
||||
svg.appendChild(this._buildDefs());
|
||||
svg.appendChild(this._buildBackdrop(width, height));
|
||||
svg.appendChild(this._buildEdges());
|
||||
svg.appendChild(this._buildNodes());
|
||||
@@ -137,15 +156,102 @@ export class BoardView {
|
||||
this.container.appendChild(svg);
|
||||
}
|
||||
|
||||
_buildDefs() {
|
||||
const defs = svgEl('defs');
|
||||
|
||||
const field = svgEl('radialGradient', { id: 'fieldGradient', cx: '30%', cy: '0%', r: '85%' });
|
||||
field.appendChild(svgEl('stop', { offset: '0%', 'stop-color': '#2a5f4c' }));
|
||||
field.appendChild(svgEl('stop', { offset: '55%', 'stop-color': '#1d493c' }));
|
||||
field.appendChild(svgEl('stop', { offset: '100%', 'stop-color': '#163a30' }));
|
||||
defs.appendChild(field);
|
||||
|
||||
const gloss = svgEl('linearGradient', { id: 'tileGloss', x1: '0', y1: '0', x2: '0', y2: '1' });
|
||||
gloss.appendChild(svgEl('stop', { offset: '0%', 'stop-color': '#ffffff', 'stop-opacity': '0.38' }));
|
||||
gloss.appendChild(svgEl('stop', { offset: '60%', 'stop-color': '#ffffff', 'stop-opacity': '0' }));
|
||||
defs.appendChild(gloss);
|
||||
|
||||
// Subtle hand-drawn wobble applied to the road paths only.
|
||||
const wobble = svgEl('filter', { id: 'roadWobble', x: '-20%', y: '-20%', width: '140%', height: '140%' });
|
||||
wobble.appendChild(svgEl('feTurbulence', {
|
||||
type: 'fractalNoise', baseFrequency: '0.012 0.02', numOctaves: '2', seed: '3', result: 'noise',
|
||||
}));
|
||||
wobble.appendChild(svgEl('feDisplacementMap', {
|
||||
in: 'SourceGraphic', in2: 'noise', scale: '5', xChannelSelector: 'R', yChannelSelector: 'G',
|
||||
}));
|
||||
defs.appendChild(wobble);
|
||||
|
||||
return defs;
|
||||
}
|
||||
|
||||
_buildBackdrop(width, height) {
|
||||
const g = svgEl('g', { class: 'backdrop' });
|
||||
let seed = 7; // deterministic — stable across re-renders, not flickering
|
||||
const rand = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
|
||||
const count = Math.max(6, Math.floor((width * height) / 11000));
|
||||
for (let i = 0; i < count; i++) {
|
||||
g.appendChild(svgEl('circle', {
|
||||
cx: rand() * width, cy: rand() * height, r: 16 + rand() * 26, class: 'tree-blob',
|
||||
g.appendChild(svgEl('rect', { x: 0, y: 0, width, height, class: 'field-bg' }));
|
||||
|
||||
const rand = makeRand(7); // deterministic — stable across re-renders, not flickering
|
||||
|
||||
const mountainCount = Math.max(3, Math.floor(width / 460));
|
||||
for (let i = 0; i < mountainCount; i++) {
|
||||
g.appendChild(this._buildMountainCluster(rand() * width, rand() * height, 70 + rand() * 55, rand));
|
||||
}
|
||||
|
||||
g.appendChild(this._buildRiver(width, height, rand));
|
||||
|
||||
const treeClusterCount = Math.max(14, Math.floor((width * height) / 15000));
|
||||
for (let i = 0; i < treeClusterCount; i++) {
|
||||
g.appendChild(this._buildTreeCluster(rand() * width, rand() * height, rand));
|
||||
}
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
_buildMountainCluster(cx, cy, size, rand) {
|
||||
const g = svgEl('g', { class: 'mountain-cluster', transform: `translate(${cx.toFixed(1)},${cy.toFixed(1)})` });
|
||||
const peaks = 3;
|
||||
for (let i = 0; i < peaks; i++) {
|
||||
const w = size * (0.55 + rand() * 0.3);
|
||||
const h = size * (0.75 + rand() * 0.25);
|
||||
const x = (i - 1) * size * 0.5;
|
||||
g.appendChild(svgEl('polygon', {
|
||||
points: `${x},0 ${x - w / 2},${h} ${x + w / 2},${h}`, class: 'mountain-body',
|
||||
}));
|
||||
g.appendChild(svgEl('polygon', {
|
||||
points: `${x},0 ${x - w * 0.18},${h * 0.3} ${x + w * 0.18},${h * 0.3}`, class: 'mountain-snow',
|
||||
}));
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
_buildRiver(width, height, rand) {
|
||||
const steps = Math.max(4, Math.round(height / 260));
|
||||
const points = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
points.push([width * 0.15 + rand() * width * 0.7, (height / steps) * i]);
|
||||
}
|
||||
let d = `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)}`;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const [px, py] = points[i - 1];
|
||||
const [x, y] = points[i];
|
||||
const cy = (py + y) / 2;
|
||||
d += ` C ${px.toFixed(1)} ${cy.toFixed(1)}, ${x.toFixed(1)} ${cy.toFixed(1)}, ${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
}
|
||||
const g = svgEl('g', { class: 'river' });
|
||||
g.appendChild(svgEl('path', { d, class: 'river-body' }));
|
||||
g.appendChild(svgEl('path', { d, class: 'river-shine' }));
|
||||
return g;
|
||||
}
|
||||
|
||||
_buildTreeCluster(cx, cy, rand) {
|
||||
const g = svgEl('g', { class: 'tree-cluster', transform: `translate(${cx.toFixed(1)},${cy.toFixed(1)})` });
|
||||
const count = 1 + Math.floor(rand() * 2);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const ox = (rand() - 0.5) * 20;
|
||||
const oy = (rand() - 0.5) * 12;
|
||||
const r = 10 + rand() * 9;
|
||||
g.appendChild(svgEl('circle', {
|
||||
cx: ox.toFixed(1), cy: (oy - r * 0.6).toFixed(1), r: r.toFixed(1),
|
||||
class: rand() > 0.5 ? 'tree-canopy-a' : 'tree-canopy-b',
|
||||
}));
|
||||
g.appendChild(svgEl('rect', { x: (ox - 2).toFixed(1), y: oy.toFixed(1), width: 4, height: 8, class: 'tree-trunk' }));
|
||||
}
|
||||
return g;
|
||||
}
|
||||
@@ -158,7 +264,9 @@ export class BoardView {
|
||||
const to = this.layout.positions.get(childId);
|
||||
const cx = from.x + (to.x - from.x) * 0.5;
|
||||
const d = `M ${from.x} ${from.y} C ${cx} ${from.y}, ${cx} ${to.y}, ${to.x} ${to.y}`;
|
||||
g.appendChild(svgEl('path', { d, class: 'edge-path' }));
|
||||
g.appendChild(svgEl('path', { d, class: 'road-shadow' }));
|
||||
g.appendChild(svgEl('path', { d, class: 'road-base' }));
|
||||
g.appendChild(svgEl('path', { d, class: 'road-centerline' }));
|
||||
}
|
||||
}
|
||||
return g;
|
||||
@@ -172,6 +280,18 @@ export class BoardView {
|
||||
return g;
|
||||
}
|
||||
|
||||
/** One of {circle, diamond, rect}, offset by (dx,dy) — used for the shadow,
|
||||
* main, and gloss copies of a tile so all three share exact geometry. */
|
||||
_shapeEl(shapeType, dx, dy, cls) {
|
||||
const t = shapeType === 'diamond' ? `translate(${dx},${dy}) rotate(45)` : `translate(${dx},${dy})`;
|
||||
if (shapeType === 'circle') return svgEl('circle', { r: NODE_H / 2, class: cls, transform: t });
|
||||
if (shapeType === 'diamond') {
|
||||
const r = NODE_H / 2 + 6;
|
||||
return svgEl('rect', { x: -r, y: -r, width: r * 2, height: r * 2, rx: 8, class: cls, transform: t });
|
||||
}
|
||||
return svgEl('rect', { x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H, rx: 14, class: cls, transform: t });
|
||||
}
|
||||
|
||||
_buildNode(space, pos) {
|
||||
const style = TYPE_STYLE[space.type] ?? TYPE_STYLE.event;
|
||||
const node = svgEl('g', {
|
||||
@@ -180,19 +300,17 @@ export class BoardView {
|
||||
'data-space-id': space.id,
|
||||
});
|
||||
|
||||
if (style.shape === 'circle') {
|
||||
node.appendChild(svgEl('circle', { r: NODE_H / 2, class: 'space-shape' }));
|
||||
} else if (style.shape === 'diamond') {
|
||||
const r = NODE_H / 2 + 6;
|
||||
node.appendChild(svgEl('rect', {
|
||||
x: -r, y: -r, width: r * 2, height: r * 2, rx: 8,
|
||||
transform: 'rotate(45)', class: 'space-shape',
|
||||
}));
|
||||
} else {
|
||||
node.appendChild(svgEl('rect', {
|
||||
x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H, rx: 14, class: 'space-shape',
|
||||
}));
|
||||
// A few degrees of deterministic tilt per tile — hand-placed, not
|
||||
// machine-stamped — applied only to the tile's shape, never its label.
|
||||
const tilt = ((hashString(space.id) % 700) / 100 - 3.5).toFixed(2);
|
||||
const shapeGroup = svgEl('g', { class: 'tile-shape-group', transform: `rotate(${tilt})` });
|
||||
shapeGroup.appendChild(this._shapeEl(style.shape, 3, 4, 'space-shadow'));
|
||||
shapeGroup.appendChild(this._shapeEl(style.shape, 0, 0, 'space-shape'));
|
||||
shapeGroup.appendChild(this._shapeEl(style.shape, 0, 0, 'space-gloss'));
|
||||
if (space.type === 'choice') {
|
||||
shapeGroup.appendChild(svgEl('rect', { x: -4, y: NODE_H / 2 + 2, width: 8, height: 22, class: 'choice-post' }));
|
||||
}
|
||||
node.appendChild(shapeGroup);
|
||||
|
||||
const fo = svgEl('foreignObject', { x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H });
|
||||
const div = document.createElement('div');
|
||||
|
||||
+25
-5
@@ -71,15 +71,35 @@
|
||||
/* --- Board --- */
|
||||
.board-container {
|
||||
overflow: auto; max-height: 620px; border-radius: 14px; margin-bottom: 14px;
|
||||
background: radial-gradient(900px 500px at 30% 0%, #2a5f4c 0%, #1d493c 55%, #163a30 100%);
|
||||
border: 1px solid var(--line);
|
||||
background: #163a30; border: 1px solid var(--line);
|
||||
}
|
||||
.board-svg { display: block; width: 100%; height: auto; min-width: 680px; }
|
||||
.tree-blob { fill: #2f6b52; opacity: .55; }
|
||||
.edge-path { fill: none; stroke: #caa15a; stroke-width: 7; stroke-linecap: round; opacity: .9; }
|
||||
|
||||
.field-bg { fill: url(#fieldGradient); }
|
||||
.mountain-body { fill: #8f9aa8; opacity: .5; }
|
||||
.mountain-snow { fill: #e8edf2; opacity: .45; }
|
||||
.river-body { fill: none; stroke: #4a90b8; stroke-width: 34; stroke-linecap: round; opacity: .5; }
|
||||
.river-shine { fill: none; stroke: #a8d8ea; stroke-width: 8; stroke-linecap: round; opacity: .4; }
|
||||
.tree-canopy-a { fill: #2f6b52; opacity: .6; }
|
||||
.tree-canopy-b { fill: #3a7a5e; opacity: .55; }
|
||||
.tree-trunk { fill: #4a3a2a; opacity: .5; }
|
||||
|
||||
.road-shadow {
|
||||
fill: none; stroke: #0f2a23; stroke-width: 15; stroke-linecap: round;
|
||||
opacity: .3; transform: translate(0, 3px);
|
||||
}
|
||||
.road-base { fill: none; stroke: #caa15a; stroke-width: 13; stroke-linecap: round; filter: url(#roadWobble); }
|
||||
.road-centerline {
|
||||
fill: none; stroke: #f0dcae; stroke-width: 2.5; stroke-linecap: round;
|
||||
stroke-dasharray: 8 10; opacity: .85; filter: url(#roadWobble);
|
||||
}
|
||||
|
||||
.space-shadow { fill: rgba(0,0,0,.32); }
|
||||
.space-gloss { fill: url(#tileGloss); pointer-events: none; }
|
||||
.choice-post { fill: #8a6a3c; stroke: #5f462a; stroke-width: 1; }
|
||||
.space-shape {
|
||||
fill: #fffaf0; stroke: var(--line); stroke-width: 2.5;
|
||||
filter: drop-shadow(0 3px 3px rgba(0,0,0,.35));
|
||||
filter: drop-shadow(0 1px 1px rgba(0,0,0,.25));
|
||||
}
|
||||
.space-start .space-shape { fill: var(--good); stroke: #2c6b44; }
|
||||
.space-finish .space-shape { fill: var(--marigold); stroke: #b97f18; }
|
||||
|
||||
Reference in New Issue
Block a user