component

Tv

A flat-screen TV on a stand, a low media console, or a wall. Switched on, it shows a seeded picture (sky, sun, hills) drawn from flat shapes.

⌖ Explore the library in 3D → · View as Markdown

Tune every prop in the workshop →

Add it

npx @runek/cli add tv

Pulls @react-three/rapier@^2.2.0, @runek/core@^0.13.0, three@^0.184.0.

Use it

import { Tv } from './runek/Tv'

<Tv position={[0, 0, 0]} seed={1} />

Or as a node in a world file (worlds as data):

{ "type": "Tv", "props": { "position": [0, 0, 0], "seed": 1 } }

Props

PropTypeDefaultDescription
widthnumber1.2Screen width, in units.
mount"stand" | "console" | "wall"stand and console are seeded when unset; wall must be asked for. On a wall the origin is the middle of the screen with its back against the wall (local -Z).
onbooleantrueShowing a seeded picture (a sky, a sun, hills) in this tint, or dark glass when off.
screencolorPicture tint. Seeded when unset.
colorcolorBezel and stand; defaults to the palette’s metal.
consoleColorcolorThe console’s wood; defaults to the palette’s woodDark.
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.
seednumber1Seed for the deterministic variation: same seed, same result.

Registry manifest

registry manifest /r/components/tv.json Self-contained JSON: inlined source plus resolved dependencies, exactly what runek add tv fetches.

Browse the whole catalog in the gallery →.

Source (Tv.tsx)
import { CuboidCollider, RigidBody } from '@react-three/rapier'
import { pick, range, rng, sub, useWorld, type Vec3, type WorldComponentProps } from '@runek/core'
import { useMemo } from 'react'
import { Color } from 'three'

/** On a center `stand`, on a low media `console`, or flat on a `wall`. */
export type TvMount = 'stand' | 'console' | 'wall'

export interface TvProps extends WorldComponentProps {
  /** Screen width, in units. */
  width?: number
  /** `stand` and `console` are seeded when unset; `wall` must be asked for. On a wall the origin
   *  is the middle of the screen with its back against the wall (local -Z). */
  mount?: TvMount
  /** Showing a seeded picture (a sky, a sun, hills) in this tint, or dark glass when off. */
  on?: boolean
  /** Picture tint. Seeded when unset. */
  screen?: string
  /** Bezel and stand; defaults to the palette's `metal`. */
  color?: string
  /** The console's wood; defaults to the palette's `woodDark`. */
  consoleColor?: string
}

interface Blob {
  pos: Vec3
  r: number
  color: string
}

const TINTS = ['#6fa3d8', '#e3a46a', '#8fc6a8', '#b49be0', '#e07f7f']
const OFF = '#101214'

/**
 * A flat-screen television on a stand, on a low media console, or on a wall. Switched on, its
 * screen glows with a seeded picture drawn from a few flat shapes (no textures), so a lounge has
 * something to look at. One cuboid collider; standing mounts sit on the ground.
 */
export function Tv({
  position = [0, 0, 0],
  rotation = [0, 0, 0],
  seed = 1,
  width = 1.2,
  mount,
  on = true,
  screen,
  color,
  consoleColor,
}: TvProps) {
  const { unit, palette } = useWorld()
  const frame = color ?? palette.metal
  const W = width * unit
  const Hs = (W * 9) / 16
  const bezel = 0.015 * unit
  const thick = 0.04 * unit

  const look = useMemo(() => {
    const r = rng(sub(seed, 0))
    const rolled = { mount: pick<TvMount>(r, ['stand', 'console']), tint: pick(r, TINTS) }
    const base = new Color(screen ?? rolled.tint)
    const shade = (k: number, hue = 0) =>
      base.clone().offsetHSL(hue, 0, 0).multiplyScalar(k).getStyle()
    const horizon = range(r, -0.15, 0.1) * Hs
    const blobs: Blob[] = [
      // the sun, then hills (circles mostly below the horizon) in two depths
      {
        pos: [range(r, -0.3, 0.3) * W, horizon + range(r, 0.12, 0.28) * Hs, 0.0006],
        r: 0.07 * W,
        color: '#fff3c4',
      },
    ]
    for (let i = 0; i < 4; i++) {
      const rad = range(r, 0.12, 0.22) * W
      blobs.push({
        pos: [range(r, -0.5, 0.5) * W, horizon - rad * range(r, 0.8, 0.95), 0.001 + i * 0.0002],
        r: rad,
        color: shade(i < 2 ? 0.55 : 0.4, 0.25),
      })
    }
    return {
      mount: mount ?? rolled.mount,
      sky: shade(1.15),
      ground: shade(0.35, 0.25),
      horizon,
      blobs,
    }
  }, [seed, mount, screen, W, Hs])

  const wall = look.mount === 'wall'
  const consoleH = 0.45 * unit
  const standLift = 0.08 * unit
  // Screen center: on a wall it is the origin; standing, it rises above its base.
  const cy = wall
    ? 0
    : (look.mount === 'console' ? consoleH + standLift : 0.75 * unit) + Hs / 2 + bezel
  const cz = wall ? thick / 2 + 0.01 * unit : 0
  const top = cy + Hs / 2 + bezel

  return (
    <RigidBody type="fixed" colliders={false} position={position} rotation={rotation}>
      {wall ? (
        <CuboidCollider args={[W / 2, Hs / 2, thick / 2]} position={[0, 0, cz]} />
      ) : look.mount === 'console' ? (
        <CuboidCollider
          args={[W * 0.62, consoleH / 2, 0.22 * unit]}
          position={[0, consoleH / 2, 0]}
        />
      ) : (
        <CuboidCollider args={[0.25 * unit, top / 2, 0.15 * unit]} position={[0, top / 2, 0]} />
      )}

      <mesh position={[0, cy, cz]} castShadow>
        <boxGeometry args={[W + bezel * 2, Hs + bezel * 2, thick]} />
        <meshStandardMaterial color={frame} roughness={0.4} metalness={0.3} />
      </mesh>
      <group position={[0, cy, cz + thick / 2 + 0.0005 * unit]}>
        {on ? (
          <>
            <mesh position={[0, (Hs / 2 + look.horizon) / 2, 0]}>
              <planeGeometry args={[W, Hs / 2 - look.horizon]} />
              <meshBasicMaterial color={look.sky} toneMapped={false} />
            </mesh>
            <mesh position={[0, (look.horizon - Hs / 2) / 2, 0]}>
              <planeGeometry args={[W, Hs / 2 + look.horizon]} />
              <meshBasicMaterial color={look.ground} toneMapped={false} />
            </mesh>
            {/* shapes are clipped to the screen by keeping them inside it: circles are scaled
                down where they would spill over the edge */}
            {look.blobs.map((b) => (
              <mesh
                key={`${b.pos[0].toFixed(4)}:${b.pos[1].toFixed(4)}`}
                position={[
                  Math.max(-W / 2 + b.r, Math.min(W / 2 - b.r, b.pos[0])),
                  Math.max(-Hs / 2 + Math.min(b.r, Hs / 2), b.pos[1]),
                  b.pos[2] * unit,
                ]}
              >
                <circleGeometry args={[Math.min(b.r, Hs / 2), 24]} />
                <meshBasicMaterial color={b.color} toneMapped={false} />
              </mesh>
            ))}
          </>
        ) : (
          <mesh>
            <planeGeometry args={[W, Hs]} />
            <meshStandardMaterial color={OFF} roughness={0.12} metalness={0.4} />
          </mesh>
        )}
      </group>

      {look.mount === 'stand' && (
        <>
          <mesh position={[0, (cy - Hs / 2) / 2, -thick * 0.2]} castShadow>
            <boxGeometry args={[0.07 * unit, cy - Hs / 2, 0.04 * unit]} />
            <meshStandardMaterial color={frame} roughness={0.5} metalness={0.3} />
          </mesh>
          <mesh position={[0, 0.01 * unit, 0]} castShadow receiveShadow>
            <boxGeometry args={[0.45 * unit, 0.02 * unit, 0.26 * unit]} />
            <meshStandardMaterial color={frame} roughness={0.5} metalness={0.3} />
          </mesh>
        </>
      )}
      {look.mount === 'console' && (
        <>
          <mesh position={[0, consoleH + standLift / 2, 0]} castShadow>
            <boxGeometry args={[0.3 * unit, standLift, 0.18 * unit]} />
            <meshStandardMaterial color={frame} roughness={0.5} metalness={0.3} />
          </mesh>
          <mesh position={[0, consoleH / 2 + 0.04 * unit, 0]} castShadow receiveShadow>
            <boxGeometry args={[W * 1.24, consoleH - 0.08 * unit, 0.44 * unit]} />
            <meshStandardMaterial color={consoleColor ?? palette.woodDark} roughness={0.7} />
          </mesh>
          {[-1, 1].map((s) => (
            <mesh key={s} position={[s * W * 0.31, consoleH / 2 + 0.04 * unit, 0.222 * unit]}>
              <planeGeometry args={[W * 0.58, consoleH - 0.14 * unit]} />
              <meshStandardMaterial color={consoleColor ?? palette.wood} roughness={0.6} />
            </mesh>
          ))}
          {[-1, 1].flatMap((sx) =>
            [-1, 1].map((sz) => (
              <mesh key={`${sx}${sz}`} position={[sx * W * 0.58, 0.02 * unit, sz * 0.18 * unit]}>
                <boxGeometry args={[0.04 * unit, 0.04 * unit, 0.04 * unit]} />
                <meshStandardMaterial color={frame} roughness={0.5} />
              </mesh>
            )),
          )}
        </>
      )}
    </RigidBody>
  )
}