component
Sailboat
A small procedural sailboat: a station-built low-poly hull, mast, boom, and mainsail, floating at the waterline with a gentle moored bob.
⌖ Explore the library in 3D → · View as Markdown
Add it
npx @runek/cli add sailboat
Pulls @react-three/fiber@^9.6.1, @react-three/rapier@^2.2.0, @runek/core@^0.15.0, three@^0.184.0.
Use it
import { Sailboat } from './runek/Sailboat'
<Sailboat position={[0, 0, 0]} seed={1} />
Or as a node in a world file (worlds as data):
{ "type": "Sailboat", "props": { "position": [0, 0, 0], "seed": 1 } }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
length |
number |
5.5 |
Hull length along local Z (bow at +Z), in units. |
beam |
number |
2 |
Hull maximum beam (width along X), in units. |
freeboard |
number |
0.5 |
Deck height above the waterline (local y=0), in units. |
draft |
number |
0.6 |
Keel depth below the waterline, in units. |
mastHeight |
number |
4.6 |
Mast height above the deck, in units. |
boomLength |
number |
3.2 |
Boom length aft of the mast, in units. |
sail |
boolean |
true |
Raise the mainsail. |
bob |
boolean |
true |
Gentle moored bobbing on the swell. |
collider |
boolean |
true |
Solid hull collider. |
physics |
boolean |
true |
Render the hull/rig as a bare visual, with no RigidBody or collider — for a parent controller (e.g. a steerable vehicle) that owns the physics body. When false, position and rotation place the visual directly and the collider prop is ignored. Default true. |
color |
color |
Hull color; defaults to the world palette’s wood. |
|
trimColor |
color |
Deck, mast, and boom color; defaults to the palette’s woodDark. |
|
sailColor |
color |
"#eee6d0" |
Sail color. |
position |
[x, y, z] |
[0, 0, 0] |
Position [x, y, z] in units (1 unit = 1 m, Y-up). |
rotation |
[x, y, z] |
[0, 0, 0] |
Euler rotation [x, y, z] in radians. |
seed |
number |
1 |
Seed for the deterministic variation: same seed, same result. |
Registry manifest
registry manifest /r/components/sailboat.json Self-contained JSON: inlined source plus resolved dependencies, exactly whatrunek add sailboat fetches.
Browse the whole catalog in the gallery →.
Source (Sailboat.tsx)
import { useFrame } from '@react-three/fiber'
import { CuboidCollider, RigidBody } from '@react-three/rapier'
import { rng, useWorld, type WorldComponentProps } from '@runek/core'
import { useMemo, useRef } from 'react'
import { BufferAttribute, BufferGeometry, DoubleSide, type Group } from 'three'
export interface SailboatProps extends WorldComponentProps {
/** Hull length along local Z (bow at +Z), in units. */
length?: number
/** Hull maximum beam (width along X), in units. */
beam?: number
/** Deck height above the waterline (local y=0), in units. */
freeboard?: number
/** Keel depth below the waterline, in units. */
draft?: number
/** Mast height above the deck, in units. */
mastHeight?: number
/** Boom length aft of the mast, in units. */
boomLength?: number
/** Raise the mainsail. */
sail?: boolean
/** Gentle moored bobbing on the swell. */
bob?: boolean
/** Solid hull collider. */
collider?: boolean
/** Render the hull/rig as a bare visual, with no `RigidBody` or collider — for a parent
* controller (e.g. a steerable vehicle) that owns the physics body. When false, `position`
* and `rotation` place the visual directly and the `collider` prop is ignored. Default true. */
physics?: boolean
/** Hull color; defaults to the world palette's `wood`. */
color?: string
/** Deck, mast, and boom color; defaults to the palette's `woodDark`. */
trimColor?: string
/** Sail color. */
sailColor?: string
}
type V3 = [number, number, number]
/** A low-poly hull from stations along the keel: pointed bow, transom stern, V-section. The
* local origin sits at the waterline, so the deck rides at +freeboard and the keel at -draft. */
function buildHull(L: number, B: number, fb: number, dr: number): BufferGeometry {
const N = 14
const t0 = 0.12
const hbMult = (t: number) => Math.sin(Math.PI * t) ** 0.6
const rocker = (t: number) => Math.sin(Math.PI * t) ** 0.5
const st = Array.from({ length: N }, (_, i) => {
const t = t0 + (i / (N - 1)) * (1 - t0)
return { z: -L / 2 + (i / (N - 1)) * L, hb: (B / 2) * hbMult(t), ky: -dr * rocker(t) }
})
const pos: number[] = []
const push = (...vs: V3[]) => {
for (const v of vs) pos.push(v[0], v[1], v[2])
}
for (let i = 0; i < N - 1; i++) {
const s = st[i]
const n = st[i + 1]
const Ls: V3 = [-s.hb, fb, s.z]
const Rs: V3 = [s.hb, fb, s.z]
const Ks: V3 = [0, s.ky, s.z]
const Ln: V3 = [-n.hb, fb, n.z]
const Rn: V3 = [n.hb, fb, n.z]
const Kn: V3 = [0, n.ky, n.z]
push(Ls, Ks, Kn, Ls, Kn, Ln) // port side
push(Rs, Rn, Kn, Rs, Kn, Ks) // starboard side
push(Ls, Ln, Rn, Ls, Rn, Rs) // deck
}
const s0 = st[0]
push([-s0.hb, fb, s0.z], [0, s0.ky, s0.z], [s0.hb, fb, s0.z]) // transom
const g = new BufferGeometry()
g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3))
g.computeVertexNormals()
return g
}
function triGeo(a: V3, b: V3, c: V3): BufferGeometry {
const g = new BufferGeometry()
g.setAttribute('position', new BufferAttribute(new Float32Array([...a, ...b, ...c]), 3))
g.computeVertexNormals()
return g
}
/** A small procedural sailboat: a station-built hull, a mast, a boom, and a swung-out mainsail.
* Floats at the local waterline and bobs gently on the swell. A static prop for now; the
* steerable variant is app-side player logic, not a new component. */
export function Sailboat({
position = [0, 0, 0],
rotation = [0, 0, 0],
seed = 1,
length = 5.5,
beam = 2,
freeboard = 0.5,
draft = 0.6,
mastHeight = 4.6,
boomLength = 3.2,
sail = true,
bob = true,
collider = true,
physics = true,
color,
trimColor,
sailColor = '#eee6d0',
}: SailboatProps) {
const { unit, palette } = useWorld()
const hullColor = color ?? palette.wood
const trim = trimColor ?? palette.woodDark
const boat = useRef<Group>(null)
const U = unit
const L = length * U
const B = beam * U
const fb = freeboard * U
const dr = draft * U
const mastH = mastHeight * U
const boomL = boomLength * U
const goose = 0.7 * U
const mastZ = 0.12 * L
const boomAngle = 0.2
const hull = useMemo(() => buildHull(L, B, fb, dr), [L, B, fb, dr])
const main = useMemo(
() => triGeo([0, mastH, 0], [0, goose, 0], [0, goose, -boomL]),
[mastH, goose, boomL],
)
const phase = useMemo(() => rng(seed)() * Math.PI * 2, [seed])
useFrame((state) => {
if (!bob || !boat.current) return
const t = state.clock.elapsedTime * 1.1 + phase
boat.current.position.y = Math.sin(t) * 0.05 * U
boat.current.rotation.z = Math.sin(t * 0.8) * 0.03
boat.current.rotation.x = Math.sin(t * 0.6 + 1) * 0.02
})
// The hull, mast, boom, and sail. `boat` is the group the bob animates, so any placement
// (the RigidBody, or the bare-visual wrapper) goes on a parent, never on this group.
const rig = (
<group ref={boat}>
{/* hull */}
<mesh geometry={hull} castShadow receiveShadow>
<meshStandardMaterial color={hullColor} side={DoubleSide} flatShading roughness={0.8} />
</mesh>
{/* mast */}
<mesh position={[0, fb + mastH / 2, mastZ]} castShadow>
<cylinderGeometry args={[0.05 * U, 0.06 * U, mastH, 8]} />
<meshStandardMaterial color={trim} roughness={0.7} />
</mesh>
{/* boom + mainsail, swung out a touch and pivoting at the mast */}
<group position={[0, fb, mastZ]} rotation={[0, boomAngle, 0]}>
<mesh position={[0, goose, -boomL / 2]} rotation={[Math.PI / 2, 0, 0]} castShadow>
<cylinderGeometry args={[0.04 * U, 0.04 * U, boomL, 6]} />
<meshStandardMaterial color={trim} roughness={0.7} />
</mesh>
{sail && (
<mesh geometry={main} castShadow>
<meshStandardMaterial color={sailColor} side={DoubleSide} roughness={0.9} />
</mesh>
)}
</group>
</group>
)
// Bare visual: a parent controller owns the physics body and places this.
if (!physics) {
return (
<group position={position} rotation={rotation}>
{rig}
</group>
)
}
return (
<RigidBody type="fixed" colliders={false} position={position} rotation={rotation}>
{collider && (
<CuboidCollider
args={[B * 0.45, (fb + dr) / 2, L * 0.45]}
position={[0, (fb - dr) / 2, 0]}
/>
)}
{rig}
</RigidBody>
)
}