267 lines
11 KiB
TypeScript
267 lines
11 KiB
TypeScript
/**
|
|
* @medieval-kit/round-shield
|
|
*
|
|
* A planked round shield with a rawhide rim and an iron boss.
|
|
*
|
|
* The kit had no arms or armour at all, which for a medieval catalogue is a
|
|
* whole category missing rather than a gap. This is the cheapest way in: a
|
|
* shield is the one piece of war gear that stands on its own in a scene —
|
|
* leaning on a wall, stacked by a door — without needing a person to hold it.
|
|
*
|
|
* Three things make it a shield rather than a disc, and all three are
|
|
* structural rather than decorative:
|
|
*
|
|
* - It is BOARDS, not a plate. A round shield is planks butted edge to edge
|
|
* and held by the rim and the boss; the plank lines are the first thing
|
|
* the eye finds and a smooth face reads as a lid.
|
|
* - The rim binds the ENDS of those planks. Without it the boards would
|
|
* split off one by one at the first blow, and without it in the model the
|
|
* silhouette is a wafer.
|
|
* - The boss is a dome over a HOLE. The hand goes behind it, gripping a bar
|
|
* across the back, so the boss is not ornament — it is the knuckle guard,
|
|
* and it is why the centre of the face is iron.
|
|
*
|
|
* The face is quartered because that is what a painted shield looks like and
|
|
* because it costs nothing: each plank is built in two halves anyway, so the
|
|
* upper and lower halves simply take different colours. `hue` turns both
|
|
* quarters together and keeps them opposite, so the slider always produces a
|
|
* pair that belongs together.
|
|
*/
|
|
import { Color, type BufferGeometry } from 'three'
|
|
|
|
import {
|
|
bandGeometry,
|
|
boxGeometry,
|
|
createKitModel,
|
|
createTinter,
|
|
jitter,
|
|
latheGeometry,
|
|
mergeColoured,
|
|
prismGeometry,
|
|
taperedBoxGeometry,
|
|
type Level,
|
|
} from '../core/index.ts'
|
|
|
|
export interface RoundShieldConfig {
|
|
/** Radius of the shield (metres). */
|
|
readonly radius: number
|
|
/** Planks across the face. */
|
|
readonly planks: number
|
|
/** Thickness of the boards (metres). */
|
|
readonly thickness: number
|
|
/** How far it leans back from upright (radians). */
|
|
readonly lean: number
|
|
/** Paint colour, 0–1 around the wheel. The second quarter sits opposite. */
|
|
readonly hue: number
|
|
/** Rivets around the boss. */
|
|
readonly rivets: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const roundShieldDefaults: RoundShieldConfig = {
|
|
radius: 0.36,
|
|
planks: 9,
|
|
thickness: 0.016,
|
|
// Barely off vertical. A shield propped against something stands almost
|
|
// upright; at 0.22 it read as lying back against nothing.
|
|
lean: 0.12,
|
|
hue: 0.02,
|
|
rivets: 10,
|
|
seed: 83,
|
|
}
|
|
|
|
export type RoundShieldParts = 'boards' | 'rim' | 'boss'
|
|
|
|
export function createModel(overrides: Partial<RoundShieldConfig> = {}) {
|
|
return createKitModel<RoundShieldConfig, 'oak' | 'leather' | 'iron', RoundShieldParts>({
|
|
id: 'round-shield',
|
|
defaults: roundShieldDefaults,
|
|
slots: ['oak', 'leather', 'iron'],
|
|
|
|
build: ({ config, random }) => {
|
|
const tint = createTinter(random)
|
|
const R = config.radius
|
|
const T = config.thickness
|
|
const planks = Math.max(3, Math.round(config.planks))
|
|
const hue = ((config.hue % 1) + 1) % 1
|
|
|
|
// Saturated and dark. At 0.42 saturation and 0.34 lightness the face
|
|
// came out pastel against a reference whose red and blue are deep enough
|
|
// to read as pigment ground in oil -- which is what they were. Shield
|
|
// paint is not a wash.
|
|
const paint = (h: number, dark: number): Color =>
|
|
new Color().setHSL(
|
|
(h + jitter(random, 0.01) + 1) % 1,
|
|
0.66 + jitter(random, 0.05),
|
|
dark + jitter(random, 0.03),
|
|
)
|
|
|
|
// --- Boards ---------------------------------------------------------
|
|
// Each plank is built in two halves so the face can be quartered without
|
|
// any extra geometry: the split the paint needs is a split the boards
|
|
// already have to make anyway.
|
|
const boards: BufferGeometry[] = []
|
|
const step = (R * 2) / planks
|
|
const upper = paint(hue, 0.26)
|
|
const lower = paint((hue + 0.55) % 1, 0.23)
|
|
|
|
for (let i = 0; i < planks; i += 1) {
|
|
const x = -R + step * (i + 0.5)
|
|
// The plank is a chord of the circle, so its length is set by how far
|
|
// out it sits. Squaring off every plank at full width would give a
|
|
// square with a rim drawn on it.
|
|
//
|
|
// The chord is measured an eighth of the way out, not at the plank's
|
|
// outer edge, and the plank NARROWS to that quarter width at the rim so
|
|
// its far corners sit just inside the rim rather than outside it.
|
|
//
|
|
// Measured at the outer edge, which is what this did, the outermost
|
|
// plank of nine came out exactly zero long and was dropped by the guard
|
|
// below. Both edge planks vanished, the board field ended a full plank
|
|
// short of the rim on each side, and the shield rendered with the
|
|
// background showing through it. The ones that survived stepped in and
|
|
// out against the rim in a sawtooth, because every one of them stopped
|
|
// where its own outer corner met the circle.
|
|
const halfSpan = Math.sqrt(Math.max(0, R * R - Math.pow(Math.abs(x) + step * 0.12, 2)))
|
|
if (halfSpan <= step * 0.2) continue
|
|
for (const half of [-1, 1]) {
|
|
// The two halves of one plank are quartered on the DIAGONAL: the
|
|
// left-hand planks take one colour above and the other below, and
|
|
// the right-hand ones the reverse. That is what makes a quartered
|
|
// shield read as quartered rather than as banded.
|
|
const left = x < 0
|
|
const colour = (half < 0) === left ? upper : lower
|
|
boards.push(taperedBoxGeometry(
|
|
[step * 0.97, T],
|
|
[step * 0.86, T * 0.92],
|
|
halfSpan,
|
|
[x, half * halfSpan * 0.5, 0],
|
|
new Color(colour),
|
|
))
|
|
}
|
|
}
|
|
|
|
// --- Rim ------------------------------------------------------------
|
|
// A rawhide binding folded over the edge. It is a band whose thickness
|
|
// reaches INSIDE the boards' own radius, so it grips them rather than
|
|
// sitting against their ends.
|
|
// Thick enough to SWALLOW the plank ends.
|
|
//
|
|
// The boards are rectangles cut to the chord of the circle, so their ends
|
|
// step in and out around the edge -- unavoidable, since a plank is
|
|
// straight and the outline is not. A real rim hides exactly that: it is a
|
|
// wide band of hide folded over the ends and stitched, and it is what
|
|
// turns a stack of boards into a disc. At 0.055 of the radius it was too
|
|
// narrow to cover the steps and the silhouette came out serrated.
|
|
const rim = bandGeometry(
|
|
R * 1.02, 0, T * 3.2, R * 0.105, planks * 3,
|
|
tint('leather', -0.04, 0.9), { inner: true },
|
|
)
|
|
rim.rotateX(Math.PI / 2)
|
|
|
|
// The cross between the quarters, laid ON the face.
|
|
//
|
|
// Two bands of pale paint dividing the four quarters. It is the detail
|
|
// that makes a quartered shield read at a distance -- without it the
|
|
// quarters meet edge to edge and the eye sees two colours rather than a
|
|
// device. Proud of the boards rather than inlaid, because paint is.
|
|
const trim: BufferGeometry[] = []
|
|
const band = R * 0.1
|
|
const cream = new Color().setHSL(0.11, 0.24, 0.74)
|
|
for (const vertical of [false, true]) {
|
|
const arm = taperedBoxGeometry(
|
|
vertical ? [band, T * 0.5] : [R * 1.94, T * 0.5],
|
|
vertical ? [band * 0.97, T * 0.4] : [R * 1.9, T * 0.4],
|
|
vertical ? R * 1.94 : band,
|
|
[0, 0, T * 0.62],
|
|
new Color(cream),
|
|
)
|
|
trim.push(arm)
|
|
}
|
|
|
|
// --- Boss -----------------------------------------------------------
|
|
const bossR = R * 0.26
|
|
const iron: BufferGeometry[] = []
|
|
// The dome. Its back is open, because behind it is the hole the hand
|
|
// goes into, and a cap there would be a face nobody can see.
|
|
iron.push(latheGeometry(
|
|
[
|
|
{ y: 0, radius: bossR * 0.98 },
|
|
{ y: bossR * 0.42, radius: bossR * 0.86 },
|
|
{ y: bossR * 0.78, radius: bossR * 0.54 },
|
|
{ y: bossR * 0.94, radius: bossR * 0.16 },
|
|
] as Level[],
|
|
11, [0, 0, 0], tint('iron', 0.05, 0.7), { capBottom: false },
|
|
// +90, not -90. The dome is turned about Y, and -90 sends its point
|
|
// to -Z -- into the back of the shield, where the only thing visible
|
|
// from the front was the open rim of it. That is why the boss read as a
|
|
// flat black ring.
|
|
).rotateX(Math.PI / 2).translate(0, 0, T * 0.5))
|
|
// The flange it is riveted through.
|
|
iron.push(bandGeometry(
|
|
bossR * 1.34, 0, T * 1.1, bossR * 0.36, 11,
|
|
tint('iron', -0.02, 0.7), { inner: true },
|
|
).rotateX(Math.PI / 2).translate(0, 0, T * 0.55))
|
|
|
|
const rivets = Math.max(0, Math.round(config.rivets))
|
|
for (let i = 0; i < rivets; i += 1) {
|
|
const a = (i / rivets) * Math.PI * 2 + 0.2
|
|
// Flared outwards, like the barrel's: a stud that tapers away from the
|
|
// surface tilts its side normals back towards it, which the radial
|
|
// check reads as reversed winding, and a hammered rivet flares anyway.
|
|
const stud = prismGeometry(
|
|
R * 0.016, R * 0.022, T * 1.4, 4, [0, 0, 0],
|
|
tint('iron', 0.09, 0.6), { capBottom: false },
|
|
)
|
|
stud.rotateX(-Math.PI / 2)
|
|
iron.push(stud.translate(
|
|
Math.sin(a) * bossR * 1.16,
|
|
Math.cos(a) * bossR * 1.16,
|
|
T * 0.8,
|
|
))
|
|
}
|
|
|
|
// --- Back ------------------------------------------------------------
|
|
// The grip bar across the hole, and two battens holding the planks. Cheap
|
|
// and invisible from the front, but a shield seen from behind with
|
|
// nothing to hold is worse than one with no back at all.
|
|
const back: BufferGeometry[] = []
|
|
back.push(boxGeometry(
|
|
[R * 1.2, R * 0.09, T * 1.6],
|
|
[0, 0, -T * 1.1],
|
|
new Color(tint('oak', -0.1)),
|
|
))
|
|
for (const at of [-0.46, 0.46]) {
|
|
back.push(boxGeometry(
|
|
[R * 1.5, R * 0.07, T * 1.2],
|
|
[0, at * R, -T * 0.9],
|
|
new Color(tint('oak', -0.14)),
|
|
))
|
|
}
|
|
|
|
const face = mergeColoured([...boards, ...trim, ...back])
|
|
const boss = mergeColoured(iron)
|
|
|
|
// Leaned back from UPRIGHT, not tipped up from flat.
|
|
//
|
|
// Everything is authored in the XY plane facing +Z, which is already a
|
|
// shield standing on its edge. Rotating by -90 degrees and then adding
|
|
// the lean laid it face-up on the floor with a slight tilt, which is a
|
|
// shield somebody dropped. The lean is the whole rotation.
|
|
for (const g of [face, rim, boss]) g.rotateX(config.lean)
|
|
// Set down by measuring, not by trigonometry: the boss stands proud of
|
|
// the face and the rim wraps past it, so which piece is lowest depends on
|
|
// the lean and is not worth deriving.
|
|
const all = mergeColoured([face, rim, boss])
|
|
all.computeBoundingBox()
|
|
const drop = all.boundingBox?.min.y ?? 0
|
|
for (const g of [face, rim, boss]) g.translate(0, -drop, 0)
|
|
|
|
return {
|
|
boards: { slot: 'oak' as const, geometry: face },
|
|
rim: { slot: 'leather' as const, geometry: rim },
|
|
boss: { slot: 'iron' as const, geometry: boss },
|
|
}
|
|
},
|
|
}, overrides)
|
|
}
|