Selftest 275/0/0. balance.test: **$80 · shade cloth · p1,p2,p3,p4 ->
hp 63, 0/4 lost — CLEAN WIN.** First time the wild night has been
winnable without sacrificing the rig. C found the line and the $10 gap;
this spends both levers together, as they only work together.
Fabric is priced at $0 BOTH WAYS, and that is a finding, not laziness.
SPRINT6 asked me to price the difference; I measured it and there is no
price at which membrane is a sane buy — it is dominated:
buys: +26% hail, but only on the PEA-hail nights (hailBlockFor(0.7,
0.3)=0.74), which are the easy ones — on storm_02 (1.3) and the
ice night (1.4) a knit already blocks 100%, because a 2 cm stone
can't pass a 2 mm weave (C's ruling). Plus rain, at 0.25 of the
drain vs hail's 5.0 — ~5% of the damage.
costs: double the wind load, and it PONDS.
Charge for that and you've shipped a trap. Free, it's DESIGN.md's actual
words — "fabric choice is a forecast bet" — and the bet is real: cloth on
the windy nights, membrane when the hail is small and the wind is mild.
If A ever raises rain's weight, membrane earns a price.
Wired C's hailBlockFor into skyfx.gardenHailExposure, which nothing
consumed — without it the fabric choice would have been cosmetic, which
is the bug class this repo keeps finding.
I did NOT reproduce C's numbers, and the comment says so rather than
repeating them. C reported p1 dropping to 1.08 kN (under a carabiner's
1.2) with cloth + 0.40. I measure 1.27, and p1 never crosses under 1.2 at
any combo. The line still wins on a $5 carabiner — but because 1.27 vs
1.2 is a 6% overshoot and breaking needs 0.4 s SUSTAINED, not because the
corner got under its rating. The margin is TIME, not headroom, and it's
thinner than the table implies: anything that lengthens storm_02's gust
HOLDS could take it. Flagged for C in THREADS.
The old t2 quad stays as the sacrifice-play control (hp 56, 2/4 lost) —
now a worse bet the shop will happily sell you, rather than the wild
night's only outcome.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
473 lines
19 KiB
JavaScript
473 lines
19 KiB
JavaScript
/**
|
|
* rigging.js — prep-phase rig selection and hardware economy. [Lane B]
|
|
*
|
|
* The money half of the sail. Ports the prototype's economy verbatim ($80
|
|
* budget, $5/$15/$30 hardware, $15 spare) and adds the state machine around it:
|
|
* which anchors are rigged, what hangs at each corner, how tight, how many
|
|
* spares in the bag.
|
|
*
|
|
* Kept three-free and DOM-free like sail.js so it is testable headless. The
|
|
* picking/DOM layer is deliberately NOT here yet — it needs Lane A's camera and
|
|
* anchor markers, which do not exist at time of writing; see createRiggingUI at
|
|
* the bottom for the seam it will plug into.
|
|
*/
|
|
|
|
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
|
|
import { orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
|
|
|
|
export { START_BUDGET, SPARE_COST };
|
|
export const MAX_CORNERS = 4;
|
|
export const DEFAULT_TENSION = 1.0;
|
|
|
|
/**
|
|
* Fabric — DESIGN.md's "fabric choice is a forecast bet", and it is a BET, not a
|
|
* purchase. Both cost $0.
|
|
*
|
|
* That pricing is a finding, not laziness. SPRINT6 asked me to "price the
|
|
* difference"; I measured it instead and there is no price at which membrane is
|
|
* a sane buy, because it is strictly dominated as an upgrade:
|
|
*
|
|
* what membrane buys +26% hail blocked — but ONLY on the pea-hail nights
|
|
* (hailBlockFor(0.7, 0.3) = 0.74), which are the EASY
|
|
* ones; on storm_02 (1.3) and the ice night (1.4) a
|
|
* knitted cloth already blocks 100%, because a 2 cm
|
|
* stone cannot pass a 2 mm weave — C's ruling.
|
|
* Plus rain, which is 0.25 of the drain against hail's
|
|
* 5.0, i.e. ~5% of the damage.
|
|
* what membrane costs DOUBLE the wind load (porosity halves the pressure
|
|
* term), and it PONDS — the flat-sail killer, needing
|
|
* the broom.
|
|
*
|
|
* Charge for that and you have shipped a trap. Free, it is the real forecast
|
|
* bet the design describes: take the cloth when the night is windy (which is
|
|
* every night with big hail), take the membrane when the hail is small and the
|
|
* wind is mild and you want the rain off too. If Lane A ever raises rain's drain
|
|
* weight, membrane earns a price and this comment is the place to start.
|
|
*/
|
|
export const FABRIC = [
|
|
{ id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through — half the load, sheds water, leaks the finest hail' },
|
|
{ id: 'membrane', name: 'waterproof membrane', porosity: 0, cost: 0, blurb: 'stops rain and every stone — full wind load, and it ponds' },
|
|
];
|
|
export const DEFAULT_FABRIC = FABRIC[0];
|
|
|
|
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
|
|
|
const OK = { ok: true };
|
|
const fail = (reason) => ({ ok: false, reason });
|
|
|
|
export class RiggingSession {
|
|
/**
|
|
* @param {object} opts
|
|
* @param {Array} opts.anchors world.anchors — [{id, pos, type, sway?}]
|
|
* @param {number} opts.budget starting cash
|
|
*/
|
|
constructor({ anchors = [], budget = START_BUDGET } = {}) {
|
|
this.anchors = anchors;
|
|
this._startBudget = budget;
|
|
this.reset();
|
|
}
|
|
|
|
/**
|
|
* Back to an empty prep phase, same anchors and starting budget. Lane A's
|
|
* "play again" reaches into the state machine to fake a fresh round rather
|
|
* than rebuilding the session, so this owns the field list — add a field
|
|
* above, reset it here.
|
|
*/
|
|
reset() {
|
|
this.budget = this._startBudget;
|
|
this.tension = DEFAULT_TENSION;
|
|
this.spares = 0;
|
|
this.fabric = DEFAULT_FABRIC;
|
|
/** @type {{anchorId: string, hw: object}[]} — ring-ordered once 4 are rigged */
|
|
this.picks = [];
|
|
return this;
|
|
}
|
|
|
|
get spent() { return START_BUDGET - this.budget; }
|
|
get canStart() { return this.picks.length === MAX_CORNERS; }
|
|
isRigged(anchorId) { return this.picks.some((p) => p.anchorId === anchorId); }
|
|
pickOf(anchorId) { return this.picks.find((p) => p.anchorId === anchorId) || null; }
|
|
|
|
/** Charge (or refund, when amount is negative) against the budget. */
|
|
_spend(amount) {
|
|
if (this.budget - amount < 0) return false;
|
|
this.budget -= amount;
|
|
return true;
|
|
}
|
|
|
|
/** Rig a corner at an anchor, starting on the cheapest hardware (prototype). */
|
|
rig(anchorId) {
|
|
const a = this.anchors.find((x) => x.id === anchorId);
|
|
if (!a) return fail('no such anchor');
|
|
if (this.isRigged(anchorId)) return fail('already rigged');
|
|
if (this.picks.length >= MAX_CORNERS) return fail('a sail has four corners');
|
|
if (!this._spend(HARDWARE[0].cost)) return fail('not enough budget');
|
|
this.picks.push({ anchorId, hw: HARDWARE[0] });
|
|
this._reorder();
|
|
return OK;
|
|
}
|
|
|
|
/**
|
|
* Unrig a corner and refund its hardware. Not in the prototype (which had no
|
|
* way back from a misclick) but it is a pure refund, so it costs the economy
|
|
* nothing and saves the player a restart.
|
|
*/
|
|
unrig(anchorId) {
|
|
const i = this.picks.findIndex((p) => p.anchorId === anchorId);
|
|
if (i < 0) return fail('not rigged');
|
|
this.budget += this.picks[i].hw.cost;
|
|
this.picks.splice(i, 1);
|
|
return OK;
|
|
}
|
|
|
|
/** Cycle a corner's hardware to the next tier, paying (or refunding) the difference. */
|
|
cycleHardware(anchorId) {
|
|
const p = this.pickOf(anchorId);
|
|
if (!p) return fail('not rigged');
|
|
const next = HARDWARE[(HARDWARE.indexOf(p.hw) + 1) % HARDWARE.length];
|
|
if (!this._spend(next.cost - p.hw.cost)) return fail('not enough budget');
|
|
p.hw = next;
|
|
return OK;
|
|
}
|
|
|
|
setHardware(anchorId, hw) {
|
|
const p = this.pickOf(anchorId);
|
|
if (!p) return fail('not rigged');
|
|
if (!HARDWARE.includes(hw)) return fail('unknown hardware');
|
|
if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
|
|
p.hw = hw;
|
|
return OK;
|
|
}
|
|
|
|
/**
|
|
* Pick the cloth. Free either way (see FABRIC) — it's a forecast bet, so the
|
|
* cost is which night you're wrong on, not dollars.
|
|
* @param {object|string} f a FABRIC entry or its id
|
|
*/
|
|
setFabric(f) {
|
|
const pick = typeof f === 'string' ? FABRIC.find((x) => x.id === f) : f;
|
|
if (!pick || !FABRIC.includes(pick)) return fail('unknown fabric');
|
|
if (!this._spend(pick.cost - this.fabric.cost)) return fail('not enough budget');
|
|
this.fabric = pick;
|
|
return OK;
|
|
}
|
|
|
|
/** 0.6 loose (soaks gusts, flogs) .. 1.4 drum tight (no flap, shock-loads). */
|
|
setTension(v) {
|
|
this.tension = clamp(v, TENSION_MIN, TENSION_MAX);
|
|
return this.tension;
|
|
}
|
|
|
|
/** Spares are what Lane D's hold-E re-rig consumes mid-storm. */
|
|
setSpares(n) {
|
|
n = Math.max(0, Math.floor(n));
|
|
const delta = (n - this.spares) * SPARE_COST;
|
|
if (!this._spend(delta)) return fail('not enough budget');
|
|
this.spares = n;
|
|
return OK;
|
|
}
|
|
|
|
/**
|
|
* Ring-order the picks by angle around their ground-plane centroid, so corner
|
|
* i of the cloth grid always maps to a neighbouring anchor. Without it,
|
|
* picking anchors in a silly order knots the sail through itself.
|
|
*/
|
|
_reorder() {
|
|
if (this.picks.length < MAX_CORNERS) return;
|
|
const byId = new Map(this.picks.map((p) => [p.anchorId, p]));
|
|
const ring = orderRing(this.picks.map((p) => this.anchors.find((a) => a.id === p.anchorId)));
|
|
this.picks = ring.map((a) => byId.get(a.id));
|
|
}
|
|
|
|
/** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */
|
|
commit(rig) {
|
|
if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`);
|
|
// Fabric before attach: porosity scales the wind pressure term and decides
|
|
// whether the cloth ponds, so the sail has to know what it's made of before
|
|
// it is built. (It's also half of the wild night's only winnable line —
|
|
// shade cloth + C's 0.40 downdraft drop p1 to 1.08 kN, under a $5
|
|
// carabiner's rating, which is the $10 that closes the $90 -> $80 gap.)
|
|
if (rig.setFabric) rig.setFabric(this.fabric);
|
|
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension);
|
|
}
|
|
|
|
/** Everything the HUD needs to draw the prep panel, in one read. */
|
|
get summary() {
|
|
return {
|
|
budget: this.budget,
|
|
spent: this.spent,
|
|
tension: this.tension,
|
|
spares: this.spares,
|
|
fabric: { id: this.fabric.id, name: this.fabric.name, porosity: this.fabric.porosity, cost: this.fabric.cost, blurb: this.fabric.blurb },
|
|
canStart: this.canStart,
|
|
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })),
|
|
weakest: this.picks.length
|
|
? this.picks.reduce((w, p) => (p.hw.rating < w.hw.rating ? p : w)).anchorId
|
|
: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prep-phase picking UI.
|
|
*
|
|
* Everything above is the rules; this is only the hands. It renders its own
|
|
* anchor markers because the yard has none to raycast against — world.js builds
|
|
* posts and trunks, not pick targets — and marker styling is prep-phase UI, so
|
|
* it belongs to this lane rather than to the terrain.
|
|
*
|
|
* Controls: LMB an anchor to rig it, LMB again to cycle its hardware,
|
|
* shift-LMB to pull it off for a full refund, [ and ] for tension, S for the
|
|
* spare. RMB is left alone — that's the camera's orbit. Enter belongs to Lane
|
|
* A's phase machine, which calls commit() on the way out of prep.
|
|
*
|
|
* @param {object} o
|
|
* @param {object} o.scene THREE.Scene to hang markers in
|
|
* @param {object} o.camera cameraRig.object — what we raycast from
|
|
* @param {Element} o.domElement renderer.domElement — where clicks land
|
|
* @param {object} o.world needs world.anchors
|
|
* @param {function} o.onCommit (anchorIds, hwChoices, tension) => void — Lane A's rigSail
|
|
* @param {function} [o.onMessage] (text) => void — refusals, for the event ticker
|
|
* @param {boolean} [o.panel=true] draw the built-in prep panel; false if hud.js takes it over
|
|
*/
|
|
export async function createRiggingUI({
|
|
scene, camera, domElement, world,
|
|
onCommit, onMessage = () => {}, panel = true,
|
|
} = {}) {
|
|
const THREE = await import('../vendor/three.module.js');
|
|
const session = new RiggingSession({ anchors: world.anchors });
|
|
|
|
// --- markers -----------------------------------------------------------
|
|
const group = new THREE.Group();
|
|
group.visible = false;
|
|
scene.add(group);
|
|
|
|
const DIM = 0x33424c;
|
|
const ringGeo = new THREE.TorusGeometry(0.28, 0.05, 8, 20);
|
|
const dotGeo = new THREE.SphereGeometry(0.1, 10, 8);
|
|
// What you click is NOT what you see: the ring's tube is 5 cm, which at yard
|
|
// distance is a couple of pixels and unhittable. Pick against an invisible
|
|
// sphere big enough to mean "that anchor" and let the ring just be the read.
|
|
const pickGeo = new THREE.SphereGeometry(0.45, 8, 6);
|
|
const pickMat = new THREE.MeshBasicMaterial({ visible: false });
|
|
|
|
const markers = world.anchors.map((a) => {
|
|
const mat = new THREE.MeshBasicMaterial({ color: DIM, transparent: true, opacity: 0.9 });
|
|
const ring = new THREE.Mesh(ringGeo, mat);
|
|
const dot = new THREE.Mesh(dotGeo, mat);
|
|
const hit = new THREE.Mesh(pickGeo, pickMat);
|
|
hit.userData.anchorId = a.id;
|
|
const holder = new THREE.Group();
|
|
holder.add(ring, dot, hit, makeLabel(THREE, a.id.toUpperCase()));
|
|
group.add(holder);
|
|
return { anchor: a, holder, ring, dot, hit, mat, label: holder.children[3] };
|
|
});
|
|
const pickTargets = markers.map((m) => m.hit);
|
|
|
|
// --- quad preview ------------------------------------------------------
|
|
// A closed loop through the ring-ordered picks: this is the shape you are
|
|
// about to build, drawn before you commit to it.
|
|
const previewGeo = new THREE.BufferGeometry();
|
|
previewGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(5 * 3), 3));
|
|
const preview = new THREE.Line(
|
|
previewGeo,
|
|
new THREE.LineDashedMaterial({ color: 0xffd27a, dashSize: 0.35, gapSize: 0.25 }),
|
|
);
|
|
preview.frustumCulled = false;
|
|
group.add(preview);
|
|
|
|
// --- panel -------------------------------------------------------------
|
|
const el = panel ? document.createElement('div') : null;
|
|
if (el) {
|
|
el.id = 'rigging-panel';
|
|
el.style.cssText = `position:fixed;top:12px;left:12px;z-index:20;display:none;
|
|
background:#0d1418e0;border:1px solid #2c3a44;border-radius:6px;padding:10px 12px;
|
|
font:12px/1.65 ui-monospace,Menlo,monospace;color:#dde5ea;min-width:280px;
|
|
white-space:pre;pointer-events:none`;
|
|
document.body.appendChild(el);
|
|
}
|
|
|
|
let active = false;
|
|
let hovered = null;
|
|
|
|
const ndc = new THREE.Vector2();
|
|
const ray = new THREE.Raycaster();
|
|
const scratch = new THREE.Vector3();
|
|
|
|
function pickAt(ev) {
|
|
const r = domElement.getBoundingClientRect();
|
|
ndc.x = ((ev.clientX - r.left) / r.width) * 2 - 1;
|
|
ndc.y = -((ev.clientY - r.top) / r.height) * 2 + 1;
|
|
ray.setFromCamera(ndc, camera);
|
|
return ray.intersectObjects(pickTargets, false)[0]?.object.userData.anchorId ?? null;
|
|
}
|
|
|
|
function say(result) {
|
|
if (result && result.ok === false) onMessage(result.reason);
|
|
return result;
|
|
}
|
|
|
|
function onPointerDown(ev) {
|
|
if (!active || ev.button !== 0) return; // RMB is the camera's
|
|
const id = pickAt(ev);
|
|
if (!id) return;
|
|
ev.preventDefault();
|
|
if (!session.isRigged(id)) say(session.rig(id));
|
|
else if (ev.shiftKey) say(session.unrig(id));
|
|
else say(session.cycleHardware(id));
|
|
refresh();
|
|
}
|
|
|
|
function onPointerMove(ev) {
|
|
if (!active) return;
|
|
hovered = pickAt(ev);
|
|
domElement.style.cursor = hovered ? 'pointer' : '';
|
|
}
|
|
|
|
function onKeyDown(ev) {
|
|
if (!active) return;
|
|
if (ev.key === '[') session.setTension(session.tension - 0.05);
|
|
else if (ev.key === ']') session.setTension(session.tension + 0.05);
|
|
else if (ev.key.toLowerCase() === 's') say(session.setSpares(session.spares ? 0 : 1));
|
|
else return;
|
|
ev.preventDefault();
|
|
refresh();
|
|
}
|
|
|
|
domElement.addEventListener('pointerdown', onPointerDown);
|
|
domElement.addEventListener('pointermove', onPointerMove);
|
|
addEventListener('keydown', onKeyDown);
|
|
|
|
/** Ground-plane area of the quad as picked, m² — the 70-192 m² problem, visible. */
|
|
function quadArea() {
|
|
if (session.picks.length !== MAX_CORNERS) return 0;
|
|
const p = session.picks.map((k) => world.anchors.find((a) => a.id === k.anchorId).pos);
|
|
const tri = (a, b, c) =>
|
|
new THREE.Vector3().subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a)).length() * 0.5;
|
|
return tri(p[0], p[1], p[2]) + tri(p[0], p[2], p[3]);
|
|
}
|
|
|
|
function refresh() {
|
|
if (!el) return;
|
|
const s = session.summary;
|
|
const rows = world.anchors.map((a) => {
|
|
const pick = session.pickOf(a.id);
|
|
if (!pick) return ` ${a.id.padEnd(3)} ${a.type.padEnd(6)} —`;
|
|
const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : '';
|
|
return ` ${a.id.padEnd(3)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`;
|
|
});
|
|
const area = quadArea();
|
|
el.textContent = [
|
|
`PREP — rig four corners $${s.budget} left`,
|
|
`tension ${s.tension.toFixed(2)} spare x${s.spares}${area ? ` sail ${area.toFixed(0)} m2` : ''}`,
|
|
'',
|
|
...rows,
|
|
'',
|
|
s.canStart ? 'ENTER to start the storm' : `pick ${MAX_CORNERS - session.picks.length} more corner(s)`,
|
|
'click anchor: rig / cycle hw shift-click: remove',
|
|
'[ ] tension S spare RMB orbit',
|
|
].join('\n');
|
|
}
|
|
|
|
const ui = {
|
|
session,
|
|
get summary() { return { ...session.summary, area: quadArea() }; },
|
|
get canStart() { return session.canStart; },
|
|
get active() { return active; },
|
|
|
|
/** Lane A: call on phaseChange — markers and clicks are prep-only. */
|
|
setActive(on) {
|
|
active = !!on;
|
|
group.visible = active;
|
|
if (el) el.style.display = active ? 'block' : 'none';
|
|
if (!active) domElement.style.cursor = '';
|
|
if (active) refresh();
|
|
return ui;
|
|
},
|
|
|
|
/** Markers ride the anchors, so a tree corner wanders before you even rig it. */
|
|
update(dt, t) {
|
|
if (!active) return;
|
|
for (const m of markers) {
|
|
const p = m.anchor.sway ? m.anchor.sway(t) : m.anchor.pos;
|
|
m.holder.position.set(p.x, p.y, p.z);
|
|
m.holder.quaternion.copy(camera.quaternion); // rings face the player
|
|
const pick = session.pickOf(m.anchor.id);
|
|
m.mat.color.setHex(pick ? pick.hw.color : DIM);
|
|
const s = (hovered === m.anchor.id ? 1.35 : 1) * (pick ? 1.15 : 1);
|
|
m.ring.scale.setScalar(s);
|
|
m.label.visible = !!pick || hovered === m.anchor.id;
|
|
}
|
|
|
|
const pos = previewGeo.attributes.position;
|
|
if (session.picks.length >= 2) {
|
|
preview.visible = true;
|
|
const n = session.picks.length;
|
|
for (let i = 0; i <= n; i++) {
|
|
const k = session.picks[i % n];
|
|
const a = world.anchors.find((x) => x.id === k.anchorId);
|
|
const p = a.sway ? a.sway(t) : a.pos;
|
|
scratch.set(p.x, p.y, p.z);
|
|
pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
|
|
}
|
|
// degenerate tail so a partial pick doesn't draw a stale segment
|
|
for (let i = session.picks.length + 1; i < 5; i++) pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
|
|
pos.needsUpdate = true;
|
|
previewGeo.setDrawRange(0, session.picks.length + 1);
|
|
preview.computeLineDistances();
|
|
} else {
|
|
preview.visible = false;
|
|
}
|
|
},
|
|
|
|
/** Hand the finished rig to Lane A's rigSail. Returns false if it isn't four corners. */
|
|
commit() {
|
|
if (!session.canStart) {
|
|
onMessage(`rig ${MAX_CORNERS - session.picks.length} more corner(s) first`);
|
|
return false;
|
|
}
|
|
onCommit(
|
|
session.picks.map((p) => p.anchorId),
|
|
session.picks.map((p) => p.hw),
|
|
session.tension,
|
|
);
|
|
return true;
|
|
},
|
|
|
|
dispose() {
|
|
domElement.removeEventListener('pointerdown', onPointerDown);
|
|
domElement.removeEventListener('pointermove', onPointerMove);
|
|
removeEventListener('keydown', onKeyDown);
|
|
scene.remove(group);
|
|
ringGeo.dispose(); dotGeo.dispose(); previewGeo.dispose();
|
|
preview.material.dispose();
|
|
for (const m of markers) { m.mat.dispose(); m.label.material.map?.dispose(); m.label.material.dispose(); }
|
|
el?.remove();
|
|
},
|
|
};
|
|
|
|
refresh();
|
|
return ui;
|
|
}
|
|
|
|
/** A cheap canvas-texture nameplate, so anchors read as h1/t2/p1 rather than dots. */
|
|
function makeLabel(THREE, text) {
|
|
const c = document.createElement('canvas');
|
|
c.width = 128; c.height = 64;
|
|
const g = c.getContext('2d');
|
|
g.font = 'bold 40px ui-monospace, Menlo, monospace';
|
|
g.textAlign = 'center';
|
|
g.textBaseline = 'middle';
|
|
g.lineWidth = 6;
|
|
g.strokeStyle = '#0d1418';
|
|
g.strokeText(text, 64, 32);
|
|
g.fillStyle = '#dde5ea';
|
|
g.fillText(text, 64, 32);
|
|
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
|
|
map: new THREE.CanvasTexture(c), depthTest: false, transparent: true,
|
|
}));
|
|
sprite.position.set(0, 0.55, 0);
|
|
sprite.scale.set(0.8, 0.4, 1);
|
|
return sprite;
|
|
}
|