Difference between revisions of "SLua Alpha"
Line 1: | Line 1: | ||
[[File:Luau.png|720px|thumb|right|Luau logo]] | |||
= Second Life Luau Alpha = | = Second Life Luau Alpha = | ||
Line 25: | Line 27: | ||
When editing a script in the new Lua project viewer, you'll notice a new '''Compiler''' drop-down near the save button. This drop-down will allow you to select which compiler will be used, as well as which script runtime will be used (LSO2, Mono, Luau). | When editing a script in the new Lua project viewer, you'll notice a new '''Compiler''' drop-down near the save button. This drop-down will allow you to select which compiler will be used, as well as which script runtime will be used (LSO2, Mono, Luau). | ||
[[File: | [[File:Compiler_dropdown.png|Compiler selection dropdown]] | ||
* '''LSL: Legacy (LSO2)''' | * '''LSL: Legacy (LSO2)''' |
Revision as of 13:41, 4 March 2025
Second Life Luau Alpha
We're thrilled to announce the launch of the Luau Alpha for Second Life! This significant update introduces the Luau scripting language, offering creators enhanced performance, improved memory efficiency, and a more versatile scripting environment.
What is Luau?
Luau is a fast, small, safe, and gradually typed embeddable scripting language derived from Lua. It is designed to be backwards compatible with Lua 5.1, incorporating features from future Lua releases and expanding the feature set with type annotations and a state-of-the-art type inference system. Luau is largely implemented from scratch, with the language runtime being a heavily modified version of the Lua 5.1 runtime, featuring a completely rewritten interpreter and other performance innovations.
Why Luau?
The decision to integrate Luau into Second Life was driven by its ability to meet all the requirements for a scripting engine within the platform. Luau offers a high-quality scripting experience to creators, addressing many of the limitations present in the current LSL (Linden Scripting Language) environment. Its lightweight nature and performance optimizations make it an ideal choice for enhancing the scripting capabilities in Second Life. For more information on why Luau was chosen, please see the Lua FAQ.
How to Get Started with Luau in Second Life
In order to play with Luau, you'll need to download our Lua project viewer, and log onto our Aditi beta grid.
- Access the latest build of the Luau-enabled Second Life Viewer from here.
Once you've got the new viewer and have logged onto the beta grid, head over to these Luau-enabled regions:
- Luau Yardang
- Luau Tombolo
- Luau Mesa
- Luau Tideland
When editing a script in the new Lua project viewer, you'll notice a new Compiler drop-down near the save button. This drop-down will allow you to select which compiler will be used, as well as which script runtime will be used (LSO2, Mono, Luau).
- LSL: Legacy (LSO2)
- Scripts written in LSL, to be run on the old LSO2 VM
- LSL: Mono
- Scripts written in LSL, to be run on the Mono VM
- Lua
- Scripts written in Lua, to be run on the Luau VM
- LSL/Luau
- Scripts written in LSL, to be run on the Luau VM
Transitioning from LSL to Luau
- Function Namespacing:
- In Luau, Linden Lab functions have been moved under the ll namespace.
- For example:
- llSay becomes ll.Say
- llGetPos becomes ll.GetPos
- Constants:
- Constants like PERMISSION_TAKE_CONTROLS and PRIM_ROT_LOCAL are not predefined in Luau.
- You must specify these constants if you wish to reference them in your scripts.
Luau Libraries
- Coroutines:
- Luau supports coroutines, allowing for cooperative multitasking within scripts.
- Key functions include:
- coroutine.create
- coroutine.status
- coroutine.resume
- Refer to the coroutine library documentation for more details.
- Bitwise Operations:
- Luau includes a bit32 library for bitwise operations, enabling more efficient data manipulation.
- Refer to the bit32 library documentation for more details.
- Standard Library:
- Luau comes equipped with a standard library of functions designed to manipulate built-in data types.
- Explore the Luau Standard Library for a comprehensive list of available functions.
Feedback and Support
We encourage all creators to explore the new Luau scripting capabilities and provide feedback. Your insights are invaluable in refining and enhancing this feature. For more information and to share your experiences, please refer to our Lua FAQ.
Example Scripts
To help you get started, we've assembled some example scripts that demonstrate the capabilities of Luau in Second Life. These scripts cover various functionalities and can serve as a foundation for your own creations. Please feel free to propose changes to these scripts, or modify them to your heart's desire!
dialog.lua
This script demonstrates how one can interact with dialog menus.
-- Define the menu buttons and dialog message.
local buttons = {"-", "Red", "Green", "Yellow"}
local dialogInfo = "\nPlease make a choice."
local ToucherID = nil
local dialogChannel = nil
local listenHandle = nil
-- This function is called when the script first starts.
function state_entry()
-- Get the object's key and compute a dialog channel number.
local key = ll.GetKey()
-- Extract the last 7 characters of the key and convert it from hex.
dialogChannel = -1 - tonumber(string.sub(tostring(key), -7, -1), 16)
end
-- Called when the object is touched.
function touch_start(num_detected)
ToucherID = ll.DetectedKey(0)
-- If there is already a listen handle, then remove it
if listenHandle then
ll.ListenRemove(listenHandle)
end
listenHandle = ll.Listen(dialogChannel, "", ToucherID, "")
ll.Dialog(ToucherID, dialogInfo, buttons, dialogChannel)
-- Set a 60-second timer for response.
ll.SetTimerEvent(60.0)
end
-- Called when a dialog response is received.
function listen(channel, name, sender_id, message)
if message == "-" then
-- Redisplay the dialog if the "-" option is selected.
ll.Dialog(ToucherID, dialogInfo, buttons, dialogChannel)
return
end
-- Stop the timer, and stop the listening handler.
ll.ListenRemove(listenHandle)
ll.SetTimerEvent(0)
-- Let the user know what they selected
ll.Say(0, `You selected {message}`)
end
-- Called when the timer expires.
function timer()
-- Stop the timer and clean up the listener.
if listenHandle then
ll.SetTimerEvent(0)
ll.ListenRemove(listenHandle)
ll.Whisper(0, "Sorry. You snooze; you lose.")
end
end
-- Invoke state_entry on startup, since simulator doesn't invoke
-- it like it does in LSL
state_entry()
dialog_coroutine.lua
This script demonstrates how one could use coroutines to handle dialog responses, with multi-user support.
-------------------------
-- Minimal EventLoop
-------------------------
local EventLoop = {
-- Coroutine -> eventName it’s waiting for
_coros = {},
running = false
}
function EventLoop:create_task(func)
local coro = coroutine.create(func)
self._coros[coro] = false
self:_run_coro(coro)
return coro
end
function EventLoop:kill_task(coro)
self._coros[coro] = nil
if coroutine.status(coro) ~= "dead" then
coroutine.close(coro)
end
end
-- Internal helper: resumes a coroutine
function EventLoop:_run_coro(coro, ...)
if coroutine.status(coro) == "dead" then
return
end
local old_running = self.running
self.running = true
self._coros[coro] = false
local ok, eventAwaited = coroutine.resume(coro, ...)
self.running = old_running
if not ok then
ll.OwnerSay(`Coroutine error: {eventAwaited}`)
self._coros[coro] = nil
return
end
-- If still alive, 'eventAwaited' is the next event it wants
self._coros[coro] = eventAwaited
end
function EventLoop:handle_event(eventName, ...)
ll.OwnerSay(`Handling event {eventName}`)
local snapshot = table.clone(self._coros)
for coro, waitingFor in pairs(snapshot) do
if coroutine.status(coro) == "dead" then
self._coros[coro] = nil
elseif waitingFor == eventName then
ll.OwnerSay(`Dispatching event {eventName} to {coro}`)
self:_run_coro(coro, ...)
end
end
end
-- Coroutines use this to yield until an event
local function await_event(name)
assert(EventLoop.running, "await_event called outside a coroutine!")
return coroutine.yield(name)
end
-------------------------
-- Script Logic
-------------------------
local buttons = {"-", "Red", "Green", "Yellow"}
local dialogInfo = "\nPlease make a choice."
-- Use the chat listener to feed the event-loop
function listen(channel, name, sender_id, message)
-- We handle all 'listen' events via the event-loop
EventLoop:handle_event(`listen_{channel}`, channel, name, sender_id, message)
end
-- Called when the script starts
function state_entry()
-- Seed math.random so each new script run doesn’t repeat the same channels
math.randomseed(ll.GetUnixTime())
ll.OwnerSay("Script started with random channels for each user.")
end
-- A coroutine function for a single user's dialog flow
local function handle_dialog_for_user(userId)
-- Use a random channel
local channel = math.random(0x1, 0xFFFF)
-- Create a listener for that channel
local listenHandle = ll.Listen(channel, "", "", "")
while true do
-- Show the user a dialog
ll.Dialog(userId, dialogInfo, buttons, channel)
-- Wait for the next 'listen' event (channel, name, sender_id, message)
local c, n, sid, msg = await_event(`listen_{channel}`)
-- If this "listen" event isn't for our channel/user, ignore it
if c == channel and sid == userId then
-- If user pressed "-", re-display the menu, else they picked a final color
if msg ~= "-" then
ll.Say(0, `{n} selected {msg}`)
-- Now that they've chosen something else, remove the listener and finish
ll.ListenRemove(listenHandle)
return
end
end
end
end
-- Called when the object is touched
function touch_start(num_detected)
for i=0, num_detected-1 do
local toucherId = ll.DetectedKey(i)
-- Create a separate coroutine for each person who touches
EventLoop:create_task(function()
handle_dialog_for_user(toucherId)
end)
end
end
-- Run state_entry on load:
state_entry()
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]}¤t=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()