Luau Examples

From Second Life Wiki
Revision as of 16:05, 4 March 2025 by Signal Linden (talk | contribs)
Jump to navigation Jump to search

Example Luau scripts

vehicle.lua

This script demonstrates how one may create their own vehicle scripts, which don’t rely on the underlying LL vehicle system.

-- Control and permission constants
local PERMISSION_TAKE_CONTROLS = 0x4
local CONTROL_FWD       = 0x1
local CONTROL_BACK      = 0x2
local CONTROL_UP        = 0x10
local CONTROL_DOWN      = 0x20
local CONTROL_LEFT      = 0x4
local CONTROL_RIGHT     = 0x8
local PRIM_ROT_LOCAL    = integer(29)

-- These two will hold the link numbers for left and right front wheels
local LINK_WHEEL_LEFT = 0
local LINK_WHEEL_RIGHT = 0

-- Integra Type R Engine properties
local Engine = {
    rpm = 800,              -- current engine RPM (starts at idle)
    gear = 0,               -- 0 = neutral; -1 = reverse; 1-5 for forward gears
    idleRPM = 800,          -- idle RPM
    maxRPM = 8300,          -- redline
    throttle = 0.0,         -- throttle value (0.0 to 1.0)
    gearRatios = {          -- Integra Type R gear ratios
        [-1] = 3.727,       -- reverse gear ratio
        [0]  = 0,          -- neutral
        [1]  = 3.727,
        [2]  = 2.256,
        [3]  = 1.729,
        [4]  = 1.416,
        [5]  = 1.194
    }
}

local FINAL_DRIVE = 4.1   -- Final drive ratio
local WHEEL_RADIUS = 0.3  -- in meters

-- Vehicle properties (using realistic Integra Type R mass)
local Vehicle = {
    speed = 0,      -- speed in m/s; negative indicates reverse motion
    mass = 1200,    -- mass in kg
    angle = 0,      -- heading angle in radians
    steering = 0,   -- steering input: -1 (left), 0 (none), 1 (right)
    brake = false   -- flag indicating if brakes are applied
}

-- Engine constants (using realistic Integra Type R values)
local MAX_TORQUE = 200   -- in Nm

-- Brake force constant (for active braking)
local BRAKE_FORCE = 4000  -- in Newtons

-- Define a torque curve function:
local function torqueCurve(rpm)
    local peakRPM = 6000
    if rpm < peakRPM then
        return 0.8 + 0.2 * ((rpm - Engine.idleRPM) / (peakRPM - Engine.idleRPM))
    else
        local factor = 1 - 0.5 * ((rpm - peakRPM) / (Engine.maxRPM - peakRPM))
        return math.max(0.5, factor)
    end
end

-- Engine braking force when throttle is zero in gear.
local ENGINE_BRAKE_FORCE = 2000  -- in Newtons

-- Friction and drag constants
local G = 9.81            -- gravity (m/s^2)
local C_rr_inGear = 0.015 -- rolling resistance coefficient in gear
local C_rr_neutral = 0.05 -- rolling resistance coefficient in neutral

local airDensity = 1.225      -- kg/m^3
local dragCoefficient = 0.3   -- aerodynamic drag coefficient
local frontalArea = 2.2       -- in m^2

local function getAerodynamicDrag(speed)
    return 0.5 * airDensity * dragCoefficient * frontalArea * speed * speed
end

-- Compute engine RPM from vehicle speed when in gear.
local function computeEngineRPMFromSpeed()
    if Engine.gear == 0 then
        return Engine.rpm  -- In neutral, RPM is managed independently.
    else
        local conversionFactor = Engine.gearRatios[Engine.gear] * FINAL_DRIVE * (60 / (2 * math.pi * WHEEL_RADIUS))
        local rpm = math.abs(Vehicle.speed) * conversionFactor
        if rpm < Engine.idleRPM then rpm = Engine.idleRPM end
        return rpm
    end
end

-- For neutral, update RPM toward a target based on throttle.
local function updateEngineRPMNeutral()
    local targetRPM = Engine.idleRPM + (Engine.maxRPM - Engine.idleRPM) * Engine.throttle
    local changeRate = 200  -- RPM per tick
    if Engine.rpm < targetRPM then
        Engine.rpm = math.min(Engine.rpm + changeRate, targetRPM)
    elseif Engine.rpm > targetRPM then
        Engine.rpm = math.max(Engine.rpm - changeRate, targetRPM)
    end
end

-- Calculate the engine force delivered to the wheels.
local function getEngineForce()
    if Engine.gear == 0 then
        return 0  -- In neutral, no drive force is transmitted.
    else
        if Engine.throttle == 0 then
            -- Apply engine braking when throttle is released.
            return -ENGINE_BRAKE_FORCE
        else
            local torque = Engine.throttle * MAX_TORQUE * torqueCurve(Engine.rpm)
            local force = (torque * Engine.gearRatios[Engine.gear] * FINAL_DRIVE) / WHEEL_RADIUS
            return force
        end
    end
end

-- Starts the engine by setting it to idle RPM.
local function startEngine()
    Engine.rpm = Engine.idleRPM
    ll.Say(0, "Engine started. RPM: " .. Engine.rpm)
end

-- Shifts the gear if valid.
local function shiftGear(newGear)
    if Engine.gearRatios[newGear] then
        Engine.gear = newGear
        ll.Say(0, "Shifted to gear " .. newGear)
    else
        ll.Say(0, "Invalid gear: " .. newGear)
    end
end

local function findWheels()
    local total = ll.GetNumberOfPrims()
    for i = 1, total do
        local name = ll.GetLinkName(i)
        if name == "WHEEL_LF" then
            LINK_WHEEL_LEFT = i
        elseif name == "WHEEL_RF" then
            LINK_WHEEL_RIGHT = i
        end
    end 
end

local castRayCounter = 0
local lastGrounded = false

local function isVehicleOnGround()
    castRayCounter = castRayCounter + 1
    if castRayCounter >= 5 then
        castRayCounter = 0
        local pos = ll.GetPos()
        local rayDistance = 4.5  -- threshold distance (in meters) to consider as "grounded"
        local rayStart = pos
        local rayEnd = pos + vector(0, 0, -rayDistance)
        local hitResult = ll.CastRay(rayStart, rayEnd, {})
        if table.getn(hitResult) == 3 then
            lastGrounded = true
        else
            lastGrounded = false
        end
    end
    return lastGrounded
end 

-- Timer event: updates engine, vehicle physics, steering, and rotates the wheels based on steering.
function timer()
    local dt = 0.1  -- time step in seconds

    -- For neutral, update RPM based on throttle.
    if Engine.gear == 0 then
        updateEngineRPMNeutral()
    end

    -- Calculate forces.
    local C_rr = (Engine.gear == 0) and C_rr_neutral or C_rr_inGear
    local F_roll = Vehicle.mass * G * C_rr
    local engineForce = getEngineForce()
    local dragForce = getAerodynamicDrag(math.abs(Vehicle.speed))
    local netForce = engineForce - (F_roll + dragForce)

    -- Apply braking force if brakes are engaged.
    if Vehicle.brake then
        netForce = netForce - BRAKE_FORCE
    end

    local acceleration = netForce / Vehicle.mass
    local vel = ll.GetVel()
    local horizontal_vec = vector(vel.x, vel.y, 0) 
    Vehicle.speed = (ll.VecMag(horizontal_vec) + acceleration * dt)

    -- Limit vehicle speed based on gear.
    if Engine.gear ~= 0 then
        if Vehicle.speed < 0 then 
            Vehicle.speed = 0 
        end
        local conversionFactor = Engine.gearRatios[Engine.gear] * FINAL_DRIVE * (60 / (2 * math.pi * WHEEL_RADIUS))
        local maxSpeed = Engine.maxRPM / conversionFactor
        if Vehicle.speed > maxSpeed then
            Vehicle.speed = maxSpeed
        end
    end

    -- When in gear, update RPM based on the new speed.
    if Engine.gear ~= 0 then
        Engine.rpm = computeEngineRPMFromSpeed()
    end

    -- Steering update using angular velocity and quaternions:
    local STEERING_RATE = math.rad(6)  -- desired steering rate per tick (radians)
    local yawDelta = Vehicle.steering * STEERING_RATE  -- small rotation for this tick

    if yawDelta ~= 0 then
        local deltaQuat = ll.Euler2Rot(vector(0, 0, yawDelta))
        local angularSpeed = yawDelta / dt
        local angularVelocity = vector(0, 0, angularSpeed)
        ll.SetAngularVelocity(angularVelocity, 1)
        Vehicle.angle = Vehicle.angle + yawDelta
    else
        ll.SetAngularVelocity(vector(0, 0, 0), 1)
    end

    -- Display engine RPM and speed (converted to mph).
    local speed_mph = math.abs(Vehicle.speed) * 2.23694
    ll.SetText("RPM: " .. math.floor(Engine.rpm) ..
               " | Speed: " .. string.format("%.1f", speed_mph) ..
               " mph", vector(0,0,0), 1)

    -- Only update velocity if the vehicle is near the ground, and we're in gear.
    if isVehicleOnGround() and Engine.gear ~= 0 then
        local velocityVector
        if Engine.gear > 0 then
            velocityVector = vector(Vehicle.speed, 0, 0)
        else
            -- Reverse gear
            velocityVector = vector(-Vehicle.speed, 0, 0)
        end
        ll.SetVelocity(velocityVector, 1)
    end

    -- Update the wheel rotations to visually match the steering.
    local maxWheelTurn = math.rad(30)  -- maximum wheel turn angle in radians
    local wheelAngle = -Vehicle.steering * maxWheelTurn

    if LINK_WHEEL_LEFT > 0 then
        ll.SetLinkPrimitiveParamsFast(
            LINK_WHEEL_LEFT,
            {
                PRIM_ROT_LOCAL,
                ll.Euler2Rot(vector(-4.71239, -wheelAngle, 0))
            }
        )
    end

    if LINK_WHEEL_RIGHT > 0 then
        ll.SetLinkPrimitiveParamsFast(
            LINK_WHEEL_RIGHT,
            {
                PRIM_ROT_LOCAL,
                ll.Euler2Rot(vector(4.71239, wheelAngle, 0))
            }
        )
    end
end

-- Called when the object is touched: starts engine and simulation.
function touch_start(num)
    ll.Say(0, "Touch detected, requesting controls and starting engine.")
    ll.RequestPermissions(ll.GetOwner(), PERMISSION_TAKE_CONTROLS)
    startEngine()
    findWheels()
    ll.SetTimerEvent(0.1)
end

-- Called when runtime permissions are granted.
function run_time_permissions(perm)
    if bit32.band(perm, PERMISSION_TAKE_CONTROLS) then
        ll.Say(0, "Permissions granted.")
        local controlMask = bit32.bor(CONTROL_FWD, CONTROL_BACK, CONTROL_UP, CONTROL_DOWN, CONTROL_LEFT, CONTROL_RIGHT)
        ll.TakeControls(controlMask, 1, 1)
    end
end

-- Handle control events:
-- • CONTROL_FWD applies throttle.
-- • CONTROL_BACK applies the brakes.
-- • CONTROL_UP and CONTROL_DOWN shift gears.
-- • CONTROL_LEFT and CONTROL_RIGHT steer the vehicle.
function control(avatar_id, level, edge)
    local start = bit32.band(level, edge)            
    local finish = bit32.band(bit32.bnot(level), edge) 
    local held = bit32.band(level, bit32.bnot(edge))   
    local untouched = bit32.bnot(bit32.bor(level, edge)) 

    if bit32.band(held, CONTROL_BACK) ~= 0 then
        Vehicle.brake = true
        Engine.throttle = 0.0
    elseif bit32.band(held, CONTROL_FWD) ~= 0 then
        Vehicle.brake = false
        Engine.throttle = 1.0
        if Engine.gear == 0 then
            updateEngineRPMNeutral()
        end
    else
        Vehicle.brake = false
        Engine.throttle = 0.0
    end

    if bit32.band(start, CONTROL_UP) ~= 0 then
        shiftGear(Engine.gear + 1)
    end

    if bit32.band(start, CONTROL_DOWN) ~= 0 then
        shiftGear(Engine.gear - 1)
    end

    local steeringInput = 0
    if bit32.band(held, CONTROL_LEFT) ~= 0 then
        steeringInput = steeringInput + 1
    end
    if bit32.band(held, CONTROL_RIGHT) ~= 0 then
        steeringInput = steeringInput - 1
    end
    Vehicle.steering = steeringInput
end

weather_box.lua

This script demonstrates coroutines, as well as working with a JSON-based web API:

local TEXTBOX_CHANNEL = 323242
-- kludge because we don't have LSL constants in here yet
local HTTP_BODY_MAXLENGTH = integer(2)
local PRIM_TEXT = integer(26)
local PRIM_SIZE = integer(7)


gCityName = ""
gLatLong = nil  -- A table of 2 elems when it's populated
gCityRequestTask = nil
gWeatherRequestTask = nil



-----
-- JSON stuff
-----

-- From https://github.com/rxi/json.lua/blob/master/json.lua

local json = {}

local escape_char_map = {
  [ "\\" ] = "\\",
  [ "\"" ] = "\"",
  [ "\b" ] = "b",
  [ "\f" ] = "f",
  [ "\n" ] = "n",
  [ "\r" ] = "r",
  [ "\t" ] = "t",
}

local escape_char_map_inv = { [ "/" ] = "/" }
for k, v in pairs(escape_char_map) do
  escape_char_map_inv[v] = k
end


local parse

local function create_set(...)
  local res = {}
  for i = 1, select("#", ...) do
    res[ select(i, ...) ] = true
  end
  return res
end

local space_chars   = create_set(" ", "\t", "\r", "\n")
local delim_chars   = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars  = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals      = create_set("true", "false", "null")

local literal_map = {
  [ "true"  ] = true,
  [ "false" ] = false,
  [ "null"  ] = nil,
}


local function next_char(str, idx, set, negate)
  for i = idx, #str do
    if set[str:sub(i, i)] ~= negate then
      return i
    end
  end
  return #str + 1
end


local function decode_error(str, idx, msg)
  local line_count = 1
  local col_count = 1
  for i = 1, idx - 1 do
    col_count = col_count + 1
    if str:sub(i, i) == "\n" then
      line_count = line_count + 1
      col_count = 1
    end
  end
  error( string.format("%s at line %d col %d", msg, line_count, col_count) )
end


local function codepoint_to_utf8(n)
  -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
  local f = math.floor
  if n <= 0x7f then
    return string.char(n)
  elseif n <= 0x7ff then
    return string.char(f(n / 64) + 192, n % 64 + 128)
  elseif n <= 0xffff then
    return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
  elseif n <= 0x10ffff then
    return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
                       f(n % 4096 / 64) + 128, n % 64 + 128)
  end
  error( string.format("invalid unicode codepoint '%x'", n) )
end


local function parse_unicode_escape(s)
  local n1 = tonumber( s:sub(1, 4),  16 )
  local n2 = tonumber( s:sub(7, 10), 16 )
   -- Surrogate pair?
  if n2 then
    return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
  else
    return codepoint_to_utf8(n1)
  end
end


local function parse_string(str, i)
  local res = ""
  local j = i + 1
  local k = j

  while j <= #str do
    local x = str:byte(j)

    if x < 32 then
      decode_error(str, j, "control character in string")

    elseif x == 92 then -- `\`: Escape
      res = res .. str:sub(k, j - 1)
      j = j + 1
      local c = str:sub(j, j)
      if c == "u" then
        local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1)
                 or str:match("^%x%x%x%x", j + 1)
                 or decode_error(str, j - 1, "invalid unicode escape in string")
        res = res .. parse_unicode_escape(hex)
        j = j + #hex
      else
        if not escape_chars[c] then
          decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string")
        end
        res = res .. escape_char_map_inv[c]
      end
      k = j + 1

    elseif x == 34 then -- `"`: End of string
      res = res .. str:sub(k, j - 1)
      return res, j + 1
    end

    j = j + 1
  end

  decode_error(str, i, "expected closing quote for string")
end


local function parse_number(str, i)
  local x = next_char(str, i, delim_chars)
  local s = str:sub(i, x - 1)
  local n = tonumber(s)
  if not n then
    decode_error(str, i, "invalid number '" .. s .. "'")
  end
  return n, x
end


local function parse_literal(str, i)
  local x = next_char(str, i, delim_chars)
  local word = str:sub(i, x - 1)
  if not literals[word] then
    decode_error(str, i, "invalid literal '" .. word .. "'")
  end
  return literal_map[word], x
end


local function parse_array(str, i)
  local res = {}
  local n = 1
  i = i + 1
  while 1 do
    local x
    i = next_char(str, i, space_chars, true)
    -- Empty / end of array?
    if str:sub(i, i) == "]" then
      i = i + 1
      break
    end
    -- Read token
    x, i = parse(str, i)
    res[n] = x
    n = n + 1
    -- Next token
    i = next_char(str, i, space_chars, true)
    local chr = str:sub(i, i)
    i = i + 1
    if chr == "]" then break end
    if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
  end
  return res, i
end


local function parse_object(str, i)
  local res = {}
  i = i + 1
  while 1 do
    local key, val
    i = next_char(str, i, space_chars, true)
    -- Empty / end of object?
    if str:sub(i, i) == "}" then
      i = i + 1
      break
    end
    -- Read key
    if str:sub(i, i) ~= '"' then
      decode_error(str, i, "expected string for key")
    end
    key, i = parse(str, i)
    -- Read ':' delimiter
    i = next_char(str, i, space_chars, true)
    if str:sub(i, i) ~= ":" then
      decode_error(str, i, "expected ':' after key")
    end
    i = next_char(str, i + 1, space_chars, true)
    -- Read value
    val, i = parse(str, i)
    -- Set
    res[key] = val
    -- Next token
    i = next_char(str, i, space_chars, true)
    local chr = str:sub(i, i)
    i = i + 1
    if chr == "}" then break end
    if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
  end
  return res, i
end


local char_func_map = {
  [ '"' ] = parse_string,
  [ "0" ] = parse_number,
  [ "1" ] = parse_number,
  [ "2" ] = parse_number,
  [ "3" ] = parse_number,
  [ "4" ] = parse_number,
  [ "5" ] = parse_number,
  [ "6" ] = parse_number,
  [ "7" ] = parse_number,
  [ "8" ] = parse_number,
  [ "9" ] = parse_number,
  [ "-" ] = parse_number,
  [ "t" ] = parse_literal,
  [ "f" ] = parse_literal,
  [ "n" ] = parse_literal,
  [ "[" ] = parse_array,
  [ "{" ] = parse_object,
}


parse = function(str, idx)
  local chr = str:sub(idx, idx)
  local f = char_func_map[chr]
  if f then
    return f(str, idx)
  end
  decode_error(str, idx, "unexpected character '" .. chr .. "'")
end


function json.decode(str)
  if type(str) ~= "string" then
    error("expected argument of type string, got " .. type(str))
  end
  local res, idx = parse(str, next_char(str, 1, space_chars, true))
  idx = next_char(str, idx, space_chars, true)
  if idx <= #str then
    decode_error(str, idx, "trailing garbage")
  end
  return res
end


-----
-- Event Loop
-----

-- A naive little event loop that lets you track running coroutines and dispatches
-- the events they're waiting for to them.

local EventLoop = {
    -- Coroutine -> awaited event
    _coros = {},
    running = false
}

function EventLoop:create_task(func)
    local coro = coroutine.create(func)
    -- false has no semantic meaning here, we just want to reserve the space for
    -- the coroutine in the table without saying we're awaiting anything yet.
    self._coros[coro] = false
    self:_run_coro(coro)
    return coro
end

function EventLoop:kill_task(coro)
    self._coros[coro] = nil
    if coroutine.status ~= "dead" then
        coroutine.close(coro)
    end
end

-- (internal) run the event
function EventLoop:_run_coro(coro, ...)
    assert(coroutine.status(coro) ~= "dead")
    -- assert(not self.running)
    local old_running = self.running
    self.running = true

    -- Since we're running, this isn't currently awaiting anything
    self._coros[coro] = false
    local success, ret = coroutine.resume(coro, ...)
    self.running = old_running

    -- If we're not done then ret should be the kind of event we're awaiting.
    -- Otherwise it's an error message or something. Who cares.
    if not success then
        -- Get rid of the coro
        if ret then
            ll.OwnerSay(`Might have an error: {ret}`)
        end
        ret = nil
    end
    self._coros[coro] = ret
end

-- Wake up any tasks that are waiting for this kind of event
function EventLoop:handle_event(name, ...)
    -- We may mutate self._coros, so do a clone
    local coros = table.clone(self._coros)
    ll.OwnerSay(`Handling {name}`)

    for coro, awaited_event in coros do
        if coroutine.status(coro) == "dead" then
            -- Hmm, this coroutine is dead, prune it.
            self._coros[coro] = nil
        end

        if awaited_event == name then
            ll.OwnerSay(`Dispatching {name} to {coro}`)
            self:_run_coro(coro, ...)
        end
    end
end

function EventLoop:create_event_catcher(name)
    local function catcher(...)
        ll.OwnerSay("yay")
        EventLoop:handle_event(name, ...)
    end
    return catcher
end



-- Helper function for waiting and telling the EventLoop what event you want
local function await_event(kind)
    -- You had better do this inside a running event loop
    assert(EventLoop.running)
    -- This will yield, the EventLoop will resume us once it has
    -- an event we might be interested in.
    return coroutine.yield(kind)
end



-----
-- Script-specific functions
-----

local function hide_children()
    for i=2,ll.GetNumberOfPrims() do
        -- Hide all the child prims
        ll.SetLinkAlpha(i, 0, -1)
        ll.SetLinkPrimitiveParamsFast(i, {
            PRIM_SIZE, vector(0.5, 0.5, 0.5),
            PRIM_TEXT, "", vector(1,1,1), 1
        })
    end
end

local function send_textbox(avatar_id, message, channel)
    ll.TextBox(avatar_id, message, channel)

    while true do
        local resp_channel, name, id, resp = await_event("listen")
        if id ~= avatar_id or channel ~= resp_channel then
            -- If this isn't a message we're interested in then wait for the next event
            ll.OwnerSay(`{id}, {avatar_id}, {resp_channel}, {channel}`)
            continue
        end
        return resp
    end
end

local function make_json_request(url)
    local req_id = ll.HTTPRequest(url, {HTTP_BODY_MAXLENGTH, integer(16000)}, "")
    while true do
        local ev_id, status, metadata, body = await_event("http_response")
        if ev_id ~= req_id then
            -- Not a response for the HTTP request we were waiting on, wait for the next event.
            continue
        end
        return status, metadata, body
    end
end

local function request_weather()
    -- And you can use a wrapper like this to make an async function callable within both normal and coroutine contexts.
    -- Normally this sort of thing would not be necessary, but here it is since the EventLoop lives in our own code,
    -- and isn't a property of the actual script engine.
    if gWeatherRequestTask then
        EventLoop:kill_task(gWeatherRequestTask)
    end

    gWeatherRequestTask = EventLoop:create_task(function()
    ll.OwnerSay("Requesting weather")
        local status, metadata, body = make_json_request(`https://api.open-meteo.com/v1/forecast?latitude={gLatLong[1]}&longitude={gLatLong[2]}&current=temperature_2m&daily=temperature_2m_max`)
        if status ~= 200 then
            ll.OwnerSay("Error requesting weather")
            return
        end
        local parsed = json.decode(body)

        local current = parsed["current"]
        ll.OwnerSay(`Temperature is {current["temperature_2m"]}C`)
        ll.SetText(`Temperature in {gCityName} is currently {current["temperature_2m"]}C`, vector(1,1,1), 1)

        -- Show the highs for each day
        local days = parsed["daily"]["temperature_2m_max"]
        for i=1,#days do
            local link_num = i + 1
            -- Higher temps = bigger on Z, negatives are unrepresentable :)
            local z_size = (days[i] / 30) * 2
            ll.SetLinkAlpha(link_num, 1, -1)
            ll.SetLinkPrimitiveParamsFast(link_num, {
                PRIM_SIZE, vector(0.5, 0.5, z_size),
                PRIM_TEXT, `{days[i]}C`, vector(1,1,1), 1
            })
        end
    end)
end

local function ask_for_city()
    local resp = send_textbox(ll.GetOwner(), "What city do you want the weather for?", TEXTBOX_CHANNEL)
    if not resp then
        ll.OwnerSay("Got no response for city request.")
        return
    end

    ll.OwnerSay(`You want the weather for {resp}.`)
    gLatLong = nil
    gCityName = resp

    hide_children()

    -- request the lat and long for this city
    local status, metadata, body = make_json_request(`https://nominatim.openstreetmap.org/search.php?city={ll.EscapeURL(resp)}&format=jsonv2`)
    local parsed = json.decode(body)
    if not #parsed then
        ll.OwnerSay(`Got no results for {resp}!`)
        return
    end

    local first_result = parsed[1]
    gLatLong = {first_result["lat"], first_result["lon"]}
    ll.OwnerSay(`City Lat: {gLatLong[1]}, Long: {gLatLong[2]}`)

    request_weather()

    -- request weather again in 3 mins
    ll.SetTimerEvent(180)
end


-----
-- Event Handlers
-----


-- If we don't care about conventionally dispatched events, we can just set the event handler to directly send
-- event data to the event loop.
http_response = EventLoop:create_event_catcher("http_response")
listen = EventLoop:create_event_catcher("listen")


-- More conventional event handlers work too.
function touch_start(num)
    for i=0,num-1 do
        if ll.GetOwner() ~= ll.DetectedKey(i) then
            -- Go to next loop if it wasn't the owner touching us.
            continue
        end

        if gCityRequestTask then
            EventLoop:kill_task(gCityRequestTask)
        end

        -- Spin up a task to ask the user for their city
        gCityRequestTask = EventLoop:create_task(ask_for_city)
        break
    end
end

function timer()
    request_weather()
end


-- Not strictly necessary, but I like having the main function have its own scope.
-- Rather than just have top-level logic.
local function main()
    ll.Listen(TEXTBOX_CHANNEL, "", "", "")
    ll.SetText("", vector(1,1,1), 1)
    hide_children()
    ll.OwnerSay("Weatherbox operational!")
end

main()