1
Fixtures
minjaesong edited this page 2025-11-24 21:24:45 +09:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Fixtures

Audience: Engine maintainers and module developers working with placeable objects and electrical systems.

Fixtures are stationary, placeable objects in the world such as furniture, machines, and decorative items. They occupy tiles, interact with the player, and can have complex behaviours including electrical connectivity and music playback.

Overview

Fixtures provide:

  • Tile occupation — Reserve space in the world with BlockBox
  • Interaction — Player can click to use or open UI
  • Inventory — Store items internally
  • Electrical connectivity — Wire-based power and signals
  • Music playback — Play audio files through fixtures
  • Spawn validation — Check for floor/wall/ceiling requirements

FixtureBase

The base class for all fixtures.

Class Hierarchy

Actor
  └─ ActorWithBody
       └─ FixtureBase
            ├─ Electric (fixtures with wire connectivity)
            │    ├─ FixtureJukebox
            │    ├─ FixtureMusicalTurntable
            │    ├─ FixtureLogicSignalEmitter
            │    └─ ...
            ├─ FixtureTapestry
            ├─ FixtureWorkbench
            └─ ...

Core Properties

class FixtureBase : ActorWithBody, CuedByTerrainChange {
    // Spawn requirements
    @Transient open val spawnNeedsWall: Boolean = false
    @Transient open val spawnNeedsFloor: Boolean = true
    @Transient open val spawnNeedsStableFloor: Boolean = false
    @Transient open val spawnNeedsCeiling: Boolean = false

    // Physical presence
    lateinit var blockBox: BlockBox
    @Transient var blockBoxProps: BlockBoxProps
    var worldBlockPos: Point2i?  // Tile position in world, null if not placed

    // Interaction
    @Transient var nameFun: () -> String
    @Transient var mainUI: UICanvas?
    var inventory: FixtureInventory?

    // State
    @Transient var inOperation = false
    @Transient var spawnRequestedTime: Long
}

BlockBox

Defines the physical space a fixture occupies:

data class BlockBox(
    val collisionType: ItemID = NO_COLLISION,
    val width: Int = 0,        // Tile-wise width
    val height: Int = 0        // Tile-wise height
)

Collision Types:

companion object {
    const val NO_COLLISION = Block.ACTORBLOCK_NO_COLLISION
    const val FULL_COLLISION = Block.ACTORBLOCK_FULL_COLLISION
    const val ALLOW_MOVE_DOWN = Block.ACTORBLOCK_ALLOW_MOVE_DOWN  // Platform
    const val NO_PASS_RIGHT = Block.ACTORBLOCK_NO_PASS_RIGHT
    const val NO_PASS_LEFT = Block.ACTORBLOCK_NO_PASS_LEFT

    val NULL = BlockBox()  // No tile occupation
}

Example BlockBoxes:

// 2×3 fixture with no collision (decorative painting)
BlockBox(BlockBox.NO_COLLISION, 2, 3)

// 1×2 fixture with full collision (statue)
BlockBox(BlockBox.FULL_COLLISION, 1, 2)

// 2×1 fixture that acts as platform (table)
BlockBox(BlockBox.ALLOW_MOVE_DOWN, 2, 1)

Constructor

constructor(
    blockBox0: BlockBox,
    blockBoxProps: BlockBoxProps = BlockBoxProps(0),
    renderOrder: RenderOrder = RenderOrder.MIDDLE,
    nameFun: () -> String,
    mainUI: UICanvas? = null,
    inventory: FixtureInventory? = null,
    id: ActorID? = null
)

Example Fixture:

class FixtureWorkbench : FixtureBase(
    BlockBox(BlockBox.FULL_COLLISION, 3, 2),  // 3 tiles wide, 2 tiles tall
    nameFun = { Lang["ITEM_WORKBENCH"] },
    mainUI = UIWorkbench()
) {
    init {
        makeNewSprite(getSpritesheet("basegame", "sprites/fixtures/workbench.tga", 48, 32))
        density = 600.0
        actorValue[AVKey.BASEMASS] = 150.0
    }
}

Spawning

Spawn Requirements

Fixtures specify their spawning constraints:

// Requires solid floor below (default)
spawnNeedsFloor = true
spawnNeedsStableFloor = false

// Requires stable floor (no platforms)
spawnNeedsFloor = false
spawnNeedsStableFloor = true

// Requires solid wall behind
spawnNeedsWall = true

// Requires solid ceiling above (hanging fixtures)
spawnNeedsCeiling = true

// Requires wall OR floor (either satisfies requirement)
spawnNeedsWall = true
spawnNeedsFloor = true

Spawning Process

fun spawn(posX0: Int, posY0: Int, installersUUID: UUID?): Boolean {
    // posX0, posY0: tile-wise bottom-centre position

    // 1. Calculate top-left position
    val posXtl = (posX0 - blockBox.width.minus(1).div(2)) fmod world.width
    val posYtl = posY0 - blockBox.height + 1

    // 2. Check spawn validity
    if (!canSpawnHere(posX0, posY0)) {
        return false  // Blocked by tiles or lacks required floor/wall
    }

    // 3. Place filler blocks in terrain layer
    placeActorBlocks()

    // 4. Set hitbox and position
    worldBlockPos = Point2i(posXtl, posYtl)
    hitbox.setFromWidthHeight(
        posXtl * TILE_SIZED,
        posYtl * TILE_SIZED,
        blockBox.width * TILE_SIZED,
        blockBox.height * TILE_SIZED
    )

    // 5. Add to world
    INGAME.queueActorAddition(this)

    // 6. Make placement sound and dust particles
    makeNoiseAndDust(posXtl, posYtl)

    // 7. Call custom spawn logic
    onSpawn(posX0, posY0)

    return true
}

Interaction

Click Interaction

open fun onInteract(mx: Double, my: Double) {
    // Fired on mouse click
    // Do NOT override if fixture has mainUI (handled automatically)
}

Example:

override fun onInteract(mx: Double, my: Double) {
    // Toggle torch on/off
    isLit = !isLit
    playSound("torch_toggle")
}

UI Integration

If mainUI is set, clicking the fixture opens the UI automatically:

class FixtureWorkbench : FixtureBase(
    blockBox = BlockBox(BlockBox.FULL_COLLISION, 3, 2),
    nameFun = { Lang["ITEM_WORKBENCH"] },
    mainUI = UIWorkbench()  // Automatically opens on click
)

Quick Lookup Parameters

Add tooltip information visible on hover:

init {
    // With label
    addQuickLookupParam("STAT_TEMPERATURE") {
        "${temperature}°C"
    }

    // Without label (dynamic text only)
    addQuickLookupParam {
        if (isPowered) "§o§Powered§.§" else ""
    }
}

Despawning

override fun despawn() {
    if (canBeDespawned) {
        // Remove filler blocks
        forEachBlockbox { x, y, _, _ ->
            world.setTileTerrain(x, y, Block.AIR, true)
        }

        worldBlockPos = null
        mainUI?.dispose()

        super.despawn()
    }
}

override val canBeDespawned: Boolean
    get() = inventory?.isEmpty() ?: true

Fixtures with non-empty inventory cannot be despawned until emptied.

BlockBox Iteration

// Iterate over all tiles occupied by fixture
forEachBlockbox { x, y, offsetX, offsetY ->
    // x, y: world tile coordinates
    // offsetX, offsetY: offset from fixture's top-left corner

    world.setTileTerrain(x, y, Block.AIR, true)
}

// Get all positions as list
val positions: List<Pair<Int, Int>>? = everyBlockboxPos

Serialisation

Transient fields (not serialised):

  • @Transient var nameFun
  • @Transient var mainUI
  • @Transient var blockBoxProps
  • Sprite and rendering data

Serialised fields:

  • blockBox
  • inventory
  • worldBlockPos
  • Custom state variables

Reconstruction:

override fun reload() {
    super.reload()
    // Reconstruct transient state
    nameFun = { Lang["ITEM_WORKBENCH"] }
    mainUI = UIWorkbench()
    sprite = loadSprite()
}

Electric Fixtures

Electric fixtures support wire-based connectivity for power and signals.

Class Definition

open class Electric : FixtureBase {
    // Wire emitters (outputs)
    @Transient val wireEmitterTypes: HashMap<BlockBoxIndex, WireEmissionType>
    val wireEmission: HashMap<BlockBoxIndex, Vector2>

    // Wire sinks (inputs)
    @Transient val wireSinkTypes: HashMap<BlockBoxIndex, WireEmissionType>
    val wireConsumption: HashMap<BlockBoxIndex, Vector2>

    // Energy storage
    val chargeStored: HashMap<String, Double>
}

Wire Types

Common wire emission types:

  • "appliance_power" — AC power (230V/120V)
  • "digital_bit" — Digital signal (0.0 = LOW, 1.0 = HIGH)
  • "heating_power" — Thermal energy
  • "analog_audio" — Audio signal

Configuring Wires

class FixtureJukebox : Electric {
    init {
        // Set sink at (0, 2) in BlockBox for appliance power
        setWireSinkAt(0, 2, "appliance_power")

        // Power consumption: 350W real, 0VAR reactive
        setWireConsumptionAt(0, 2, Vector2(350.0, 0.0))
    }
}
class FixtureSolarPanel : Electric {
    init {
        // Set emitter at (1, 0) for appliance power
        setWireEmitterAt(1, 0, "appliance_power")

        // Power generation updated per frame
        setWireEmissionAt(1, 0, Vector2(powerGenerated, 0.0))
    }
}

Digital Signals

Electric fixtures can respond to digital signals:

class FixtureLogicSignalEmitter : Electric {
    init {
        setWireSinkAt(0, 0, "digital_bit")
        setWireEmitterAt(1, 0, "digital_bit")
    }

    // Called when signal rises from LOW to HIGH
    override fun onRisingEdge(readFrom: BlockBoxIndex) {
        println("Signal went HIGH at index $readFrom")
        // Set output HIGH
        setWireEmissionAt(1, 0, Vector2(1.0, 0.0))
    }

    // Called when signal falls from HIGH to LOW
    override fun onFallingEdge(readFrom: BlockBoxIndex) {
        println("Signal went LOW at index $readFrom")
        // Set output LOW
        setWireEmissionAt(1, 0, Vector2(0.0, 0.0))
    }

    // Called after edge detection
    override fun updateSignal() {
        // Update internal logic
    }
}

Signal Thresholds

companion object {
    const val ELECTRIC_THRESHOLD_HIGH = 0.6666666666666666    // 66.7%
    const val ELECTRIC_THRESHOLD_LOW = 0.3333333333333333     // 33.3%
    const val ELECTRIC_THRESHOLD_EDGE_DELTA = 0.33333333333333337
}

Signal Detection:

  • Rising edge — Signal crosses from below THRESHOLD_LOW to above THRESHOLD_HIGH
  • Falling edge — Signal crosses from above THRESHOLD_HIGH to below THRESHOLD_LOW

Reading Wire State

// Get wire state at offset (x, y) for given type
val state: Vector2 = getWireStateAt(offsetX, offsetY, "digital_bit")

// Check if digital signal is HIGH
if (isSignalHigh(0, 0)) {
    // Input is HIGH
}

// Check if digital signal is LOW
if (isSignalLow(0, 0)) {
    // Input is LOW
}

Wire Emission

override fun updateImpl(delta: Float) {
    super.updateImpl(delta)

    // Update power generation based on sunlight
    val sunlight = world.globalLight
    val powerGenerated = sunlight * 500.0  // Up to 500W

    setWireEmissionAt(0, 0, Vector2(powerGenerated, 0.0))
}

Complex Example: Logic Gate

class FixtureLogicAND : Electric {
    init {
        // Two inputs
        setWireSinkAt(0, 0, "digital_bit")
        setWireSinkAt(1, 0, "digital_bit")

        // One output
        setWireEmitterAt(2, 0, "digital_bit")
    }

    override fun updateSignal() {
        // AND gate logic
        val inputA = isSignalHigh(0, 0)
        val inputB = isSignalHigh(1, 0)
        val output = if (inputA && inputB) 1.0 else 0.0

        setWireEmissionAt(2, 0, Vector2(output, 0.0))
    }
}

PlaysMusic Interface

A marker interface for fixtures that can play music.

interface PlaysMusic {
    // Marker interface (no methods)
}

Purpose: Identifies fixtures that play music for game systems:

  • Music service coordination (prevent multiple music sources)
  • Audio priority management
  • Jukebox/radio functionality

Usage:

class FixtureJukebox : Electric, PlaysMusic {
    @Transient var musicNowPlaying: MusicContainer? = null

    val musicIsPlaying: Boolean
        get() = musicNowPlaying != null

    fun playDisc(index: Int) {
        val disc = discInventory[index]
        val musicFile = (ItemCodex[disc] as? ItemFileRef)?.getAsGdxFile()

        musicNowPlaying = MusicContainer(title, musicFile.file()) {
            // Callback when music ends
            stopPlayback()
        }

        MusicService.playMusicalFixture(
            action = { startAudio(musicNowPlaying!!) },
            musicFinished = { !musicIsPlaying },
            onSuccess = { /* ... */ },
            onFailure = { /* ... */ }
        )
    }
}

Built-in PlaysMusic Fixtures:

  • FixtureJukebox — Plays music discs
  • FixtureMusicalTurntable — Vinyl record player
  • FixtureRadio — Streams music

Common Patterns

Creating a Simple Fixture

class FixtureTikiTorch : FixtureBase(
    BlockBox(BlockBox.NO_COLLISION, 1, 2),
    nameFun = { Lang["ITEM_TIKI_TORCH"] }
) {
    @Transient var isLit = true

    init {
        makeNewSprite(getSpritesheet("basegame", "sprites/fixtures/tiki_torch.tga", 16, 32))
        density = 500.0
        actorValue[AVKey.BASEMASS] = 5.0
    }

    override fun onInteract(mx: Double, my: Double) {
        isLit = !isLit
    }

    override fun updateImpl(delta: Float) {
        super.updateImpl(delta)
        sprite?.currentRow = if (isLit) 0 else 1
    }

    override fun reload() {
        super.reload()
        // Reconstruct sprite
        makeNewSprite(getSpritesheet("basegame", "sprites/fixtures/tiki_torch.tga", 16, 32))
    }
}

Creating an Electric Fixture

class FixtureLamp : Electric {
    constructor() : super(
        BlockBox(BlockBox.NO_COLLISION, 1, 1),
        nameFun = { Lang["ITEM_LAMP"] }
    )

    @Transient var isPowered = false

    init {
        makeNewSprite(getSpritesheet("basegame", "sprites/fixtures/lamp.tga", 16, 16))

        // Require power at bottom tile
        setWireSinkAt(0, 0, "appliance_power")
        setWireConsumptionAt(0, 0, Vector2(60.0, 0.0))  // 60W lamp
    }

    override fun updateImpl(delta: Float) {
        super.updateImpl(delta)

        // Check if receiving power
        val powerReceived = getWireStateAt(0, 0, "appliance_power")
        isPowered = powerReceived.x > 10.0  // At least 10W

        // Update sprite
        sprite?.currentRow = if (isPowered) 0 else 1
    }
}

Fixture with Inventory

class FixtureChest : FixtureBase(
    BlockBox(BlockBox.FULL_COLLISION, 2, 2),
    nameFun = { Lang["ITEM_CHEST"] },
    mainUI = UIChest(),
    inventory = FixtureInventory(20)  // 20 slots
) {
    init {
        makeNewSprite(getSpritesheet("basegame", "sprites/fixtures/chest.tga", 32, 32))
        (mainUI as UIChest).setInventory(inventory!!)
    }

    override val canBeDespawned: Boolean
        get() = inventory?.isEmpty() ?: true
}

Best Practises

  1. Always call super.updateImpl(delta) — Required for fixture systems to work
  2. Mark UI/sprites as @Transient — They cannot be serialised
  3. Implement reload() — Reconstruct all transient fields
  4. Use forEachBlockbox for tile operations — Handles BlockBox correctly
  5. Check canBeDespawned before despawn — Prevent loss of inventory contents
  6. Set inOperation when active — Enables chunk anchoring for machines
  7. Use makeNoiseAndDust() on spawn — Provides visual/audio feedback
  8. Don't override onInteract() if using mainUI — Handled automatically

Common Pitfalls

  • Forgetting to call placeActorBlocks() — Fixture doesn't occupy tiles
  • Not implementing reload() — Crashes when loading save
  • Setting wire emissions in constructor — Use updateImpl() instead
  • Mixing up BlockBox coordinates — Use forEachBlockbox for clarity
  • Despawning fixtures with inventory — Check canBeDespawned first
  • Directly modifying worldBlockPos — Use spawn() instead

See Also