assault.lua

Shipped with TRX as data/trx/ship/modules/assault.lua.

-- The assault course readouts: the run timer, the penalties it has taken, and
-- the lap times the quad bike circuit shows when a lap ends.
--
-- The engine draws these at fixed places under the top of the screen. Here they
-- are widgets in the top middle region, so they stack with the rest of the
-- interface instead of sitting over it.
--
-- Every one of them is made once and given signals. The timings are polled,
-- because the engine publishes nothing for them, and only while something is
-- on screen to read them.

local ui = trx.ui
local signal = trx.signal
local assault = trx.assault

local DIGITS = trx.catalog.objects.assault_digits
local COURSE = assault.Track.COURSE
local QUAD = assault.Track.QUAD

-- The readouts belong to the run, so they go while the inventory ring, the
-- pause screen or photo mode holds the level still.
local playing = trx.game.signals.is_playing

-- The palettes the engine draws these in. Each is a color at the top of a
-- character fading to a darker one at the bottom.
local PALETTE = {
  white = { "ffffff", "ffffff" },
  neutral = { "ffffff", "404040" },
  grey = { "808080", "1a1a1a" },
  green = { "59bf33", "1a4000" },
  red = { "e63300", "4d0000" },
  pink = { "ff00ff", "400040" },
}

for _, palette in pairs(PALETTE) do
  palette.top = trx.math.color(palette[1])
  palette.bottom = trx.math.color(palette[2])
end

-- TR2 draws the digits flat white; TR3 gives them the shaded palette.
local plain = trx.game.tr_version < 3 and PALETTE.white or PALETTE.neutral

-- Reads a timing once a tick, and only while it is on screen. A readout that
-- is off costs one signal rather than a poll.
local function timing(shown, read)
  local held = signal.new(0)
  local ticks = nil
  local function follow(on)
    if on then
      held:set(read())
      if ticks == nil then
        ticks = signal.tick:on(function()
          held:set(read())
        end)
      end
    else
      held:set(0)
      if ticks ~= nil then
        ticks:detach()
        ticks = nil
      end
    end
  end
  follow(shown:get())
  local watch = shown:on(follow)
  -- The signals the API derives let go of the level themselves. These two are
  -- attached by hand, so they are let go of by hand.
  trx.events.on_level_unload(function()
    watch:detach()
    follow(false)
  end)
  return held
end

-- The clock as the game writes it: minutes, seconds, and a tenth. A run that
-- has not started yet shows dashes where a lap time is asked for.
local function format_time(frames, placeholder)
  if placeholder and frames <= 0 then
    return "--:--.-"
  end
  local seconds = frames // trx.game.LOGIC_FPS
  local tenths = (frames % trx.game.LOGIC_FPS) * 10 // trx.game.LOGIC_FPS
  return ("%d:%02d.%d"):format(seconds // 60, seconds % 60, tenths)
end

-- A penalty is written in whole seconds, and carries the unit after it.
local function format_penalty(frames, is_target)
  local seconds = frames // trx.game.LOGIC_FPS
  local fmt = is_target and "T %d:%02d s" or "%d:%02d s"
  return fmt:format(seconds // 60, seconds % 60)
end

-- The lap times take the top middle to themselves while they are up, which is
-- what the engine does with them. The run timer stands down for the whole of
-- that, even where the lap itself has no time to show.
local lap_up = signal.polled(function()
  return assault.get_lap_timer(QUAD) > 0
end) & playing

local lap_shown = lap_up
  & signal.polled(function()
    return assault.get_lap_time(QUAD) > 0
  end)

-------------------------------------------------------------------------------
-- the run timer, with the penalties standing to the left of it
-------------------------------------------------------------------------------

-- What the engine leaves between the penalties and the clock.
local PENALTY_GAP = 40

do
  local on_screen = signal.polled(function()
    local track = assault.active_track
    return track ~= nil and assault.is_visible(track)
  end) & playing & ~lap_up

  local time = timing(on_screen, function()
    return assault.get_time()
  end)

  local clock = ui.widgets.Digits({
    object = DIGITS,
    text = time:map(function(frames)
      return format_time(frames, false)
    end),
    color = plain.top,
    color_bottom = plain.bottom,
  })

  -- The penalties, which stand for a moment after a pad or a target is
  -- missed. Each is read once, and drawn twice: once beside the clock, and
  -- once more on the far side to hold the same room open.
  local penalties = {}
  for _, is_target in ipairs({ false, true }) do
    local read = is_target and assault.get_target_penalty
      or assault.get_penalty

    local shown = on_screen
      & signal.polled(function()
        local track = assault.active_track
        return track ~= nil
          and assault.get_penalty_timer(track) > 0
          and read(track) > 0
      end)

    local frames = timing(shown, function()
      return read(assault.active_track or COURSE)
    end)

    penalties[#penalties + 1] = {
      shown = shown,
      text = frames:map(function(value)
        return format_penalty(value, is_target)
      end),
    }
  end

  -- Both penalties end in the unit, so aligning them to the right lines the
  -- digits up and leaves the target mark standing out to the left, which is
  -- how the engine writes them.
  local function penalty_column(hidden)
    local children = {}
    for _, penalty in ipairs(penalties) do
      children[#children + 1] = ui.widgets.Digits({
        object = DIGITS,
        text = penalty.text,
        color = PALETTE.pink.top,
        color_bottom = PALETTE.pink.bottom,
        mark_color = plain.top,
        mark_color_bottom = plain.bottom,
        shown = penalty.shown,
      })
    end
    local column = ui.widgets.Stack({
      spacing = 3,
      align = ui.HAlign.RIGHT,
      children = children,
    })
    column.hidden = hidden
    return column
  end

  -- The penalties take the left, and as much room again is held open on the
  -- right, so that the clock stands in the middle whether a run has taken a
  -- penalty or not.
  ui.regions.place(
    ui.Region.TOP_CENTER,
    ui.widgets.Stack({
      orientation = ui.Orientation.HORIZONTAL,
      spacing = PENALTY_GAP,
      shown = on_screen,
      children = { penalty_column(false), clock, penalty_column(true) },
    })
  )
end

-------------------------------------------------------------------------------
-- the lap times, the last beside the best the circuit has on record
-------------------------------------------------------------------------------

do
  local last = timing(lap_shown, function()
    return assault.get_lap_time(QUAD)
  end)
  local best = timing(lap_shown, function()
    return assault.get_best_time(QUAD)
  end)

  -- A lap that matches the record is drawn green on both sides. Otherwise the
  -- lap is red against a record and neutral without one.
  local is_best = signal.combine(last, best, function(last_time, best_time)
    return best_time > 0 and last_time == best_time
  end)

  -- A circuit with no record on file shows the lap alone, as the engine does.
  local has_best = best:map(function(best_time)
    return best_time > 0
  end)

  local function lap_digits(time, shown, color_of)
    local color = signal.combine(is_best, best, color_of)
    return ui.widgets.Digits({
      object = DIGITS,
      text = time:map(function(frames)
        return format_time(frames, true)
      end),
      color = color:map(function(palette)
        return palette.top
      end),
      color_bottom = color:map(function(palette)
        return palette.bottom
      end),
      shown = shown,
    })
  end

  ui.regions.place(
    ui.Region.TOP_CENTER,
    ui.widgets.Stack({
      orientation = ui.Orientation.HORIZONTAL,
      spacing = 20,
      shown = lap_shown,
      children = {
        lap_digits(last, true, function(best_lap, best_time)
          if best_lap then
            return PALETTE.green
          end
          return best_time > 0 and PALETTE.red or PALETTE.neutral
        end),
        lap_digits(best, has_best, function(best_lap)
          return best_lap and PALETTE.green or PALETTE.grey
        end),
      },
    })
  )
end

Back to the shipped scripts