singe/testScripts/openRoad/openRoad.singe

516 lines
17 KiB
Text

-- Open Road: a road-racing game of the arcade kind, drawn a screen row at a time. There is no 3D
-- here at all. The track is a list of short segments, each with how sharply it bends and how high
-- it is. Every frame projects the segments ahead with one division each, and fills the screen rows
-- between one segment and the next with a cut-out row of road stretched to the road's width there
-- (spriteDrawGrid's stretched form). Bends move each row sideways a little more than the last;
-- hills move rows up or down, and a crest hides what is beyond it, scenery included.
--
-- Cars and scenery are flat pictures scaled to their distance. The player's car is one picture,
-- leaned into a turn and spun in a crash by turning it.
--
-- Left and right steer, button 1 (or up) accelerates, button 2 (or down) brakes. Reach the arch
-- before the time runs out for more time.
dofile("Singe/Framework.singe")
local W = 320
local H = 240
local HORIZON_Y = 110 -- Where the horizon sits on a level road; the sky art assumes it
local FOCAL = 160 -- Screen offset = world offset * FOCAL / distance ahead
local CAM_HEIGHT = 60 -- The camera's height over the road under the car
local SEG = 20 -- World units of track in a segment
local DRAW = 150 -- Segments drawn ahead
local NEAR = 1 -- Closer than this, a point is behind the camera
local ROAD_HALF = 100 -- Half a road row in world units, rumble to rumble
local STRIP_W = 256 -- The strip art's width; the art script sets these four
local HAZE = 8
local ROW_ROAD = 0 -- Row kinds in the strip, each HAZE rows deep
local ROW_GRASS = 2
local RUMBLE_SEGS = 3 -- Segments from one light band to the next dark one
local LANE = 58 -- World units from one lane's centre to the next
local PLAYER_AHEAD = 80 -- The car is this far ahead of the camera; it is drawn there at its own size
local PLAYER_W = 36 -- And this wide in the world, for hitting things
local PLAYER_BOTTOM = 230 -- Its bottom row on screen: HORIZON_Y + CAM_HEIGHT * FOCAL / PLAYER_AHEAD
local MAX_SPEED = 420 -- World units a second
local ACCEL = 120
local BRAKE = 320
local COAST = 50
local OFFROAD_SPEED = MAX_SPEED / 3 -- Off the road the car slows to this
local OFFROAD_DRAG = 260
local STEER = 170 -- World units a second across, at full speed
local CENTRIFUGAL = 150 -- How hard a sharp bend at full speed pushes the car out
local CURVE_EASY = 0.2 -- A bend's sharpness: how much each segment turns from the last
local CURVE_MEDIUM = 0.4
local CURVE_HARD = 0.6
local HILL_LOW = 120
local HILL_MEDIUM = 260
local HILL_HIGH = 420
local HILLS_SCROLL = 4 -- Pixels the far hills move for each segment of a bend at full sharpness
local LEAN = 6 -- Degrees the car leans into a full turn
local SPIN_SECONDS = 1.2
local SPIN_TURNS = 2
local TRAFFIC = 24
local CAR_LENGTH = 30 -- How close behind a car the player can be before hitting it
local TIME_START = 85
local TIME_BONUS = 75
local ARCH_SEGMENT = 8 -- Where the start arch stands, just ahead of the car
-- What each kind of thing is: its picture, its width in the world (its height follows from the
-- picture), and whether the car hits it.
local KINDS = {}
local track = {}
local trackLen = 0
local traffic = {}
local drawn = {}
local held = {}
local strip = nil
local sky = nil
local hills = nil
local hillsW = 0
local position = 0 -- The camera's distance along the track
local playerX = 0 -- The car's distance across from the road's centre
local speed = 0
local spin = nil -- Seconds into a crash's spin, or nil
local hillsAt = 0
local timeLeft = TIME_START
local distance = 0
local nextLap = 0
local lap = 1
local score = 0
local message = nil
local messageEnd = 0
local clock = 0
local lastTicks = nil
local function addSegment(curve, y)
local last = track[#track]
track[#track + 1] = { index = #track, curve = curve, y1 = last and last.y2 or 0, y2 = y, scenery = {}, cars = {} }
end
local function easeIn(a, b, t)
return a + (b - a) * t * t
end
local function easeInOut(a, b, t)
return a + (b - a) * (0.5 - math.cos(t * math.pi) / 2)
end
-- A piece of road: it eases into its bend, holds it, and eases out, climbing or falling by rise
-- over its whole length.
local function addRoad(enter, hold, leave, curve, rise)
local startY = track[#track] and track[#track].y2 or 0
local endY = startY + rise
local total = enter + hold + leave
for n = 0, enter - 1 do
addSegment(easeIn(0, curve, n / enter), easeInOut(startY, endY, n / total))
end
for n = 0, hold - 1 do
addSegment(curve, easeInOut(startY, endY, (enter + n) / total))
end
for n = 0, leave - 1 do
addSegment(easeInOut(curve, 0, n / leave), easeInOut(startY, endY, (enter + hold + n) / total))
end
end
local function addScenery(index, kind, x)
local segment = track[(index % #track) + 1]
segment.scenery[#segment.scenery + 1] = { kind = kind, x = x }
end
local function buildTrack()
track = {}
addRoad(0, 40, 0, 0, 0)
addRoad(25, 50, 25, CURVE_MEDIUM, 0)
addRoad(30, 40, 30, 0, HILL_MEDIUM)
addRoad(25, 40, 25, -CURVE_EASY, 0)
addRoad(20, 30, 20, -CURVE_MEDIUM, -HILL_LOW)
addRoad(20, 30, 20, CURVE_MEDIUM, HILL_LOW)
addRoad(30, 60, 30, 0, -HILL_HIGH)
addRoad(30, 60, 30, CURVE_HARD, 0)
for _ = 1, 4 do
addRoad(15, 10, 15, 0, HILL_LOW)
addRoad(15, 10, 15, 0, -HILL_LOW)
end
addRoad(25, 50, 25, -CURVE_MEDIUM, HILL_MEDIUM)
addRoad(20, 20, 20, CURVE_EASY, 0)
addRoad(20, 20, 20, -CURVE_EASY, 0)
addRoad(30, 40, 30, -CURVE_HARD, 0)
addRoad(40, 60, 40, CURVE_EASY, -track[#track].y2)
addRoad(0, 30, 0, 0, 0)
trackLen = #track * SEG
-- Palms along both sides, bushes farther out, a warning board on the outside before each bend
-- (where the car goes if it is taken too fast), and the arch, with nothing hiding it.
for i = 0, #track - 1 do
local segment = track[i + 1]
local nearArch = math.abs(i - ARCH_SEGMENT) < 6
if i % 6 == 0 and not nearArch then
addScenery(i, KINDS.palm, -(ROAD_HALF + 35 + math.random(0, 60)))
addScenery(i, KINDS.palm, ROAD_HALF + 35 + math.random(0, 60))
end
if math.random() < 0.25 then
local side = (math.random() < 0.5) and -1 or 1
addScenery(i, KINDS.bush, side * (ROAD_HALF + 90 + math.random(0, 260)))
end
local previous = track[((i - 1) % #track) + 1]
if previous.curve == 0 and segment.curve ~= 0 then
local outside = (segment.curve > 0) and -1 or 1
for back = 25, 5, -5 do
addScenery(i - back, KINDS.sign, outside * (ROAD_HALF + 22))
end
end
end
addScenery(ARCH_SEGMENT, KINDS.arch, 0)
end
local function loadKind(name, width, solid)
local sprite = spriteLoad(DIR .. "art/" .. name .. ".png")
local w = spriteGetWidth(sprite)
local h = spriteGetHeight(sprite)
return { sprite = sprite, width = width, height = width * h / w, naturalW = w, naturalH = h, solid = solid }
end
local function segmentAt(z)
return track[(math.floor(z / SEG) % #track) + 1]
end
local function showMessage(text, seconds)
message = text
messageEnd = clock + seconds
end
local function reset()
position = 0
playerX = 0
speed = 0
spin = nil
timeLeft = TIME_START
distance = 0
nextLap = ARCH_SEGMENT * SEG - PLAYER_AHEAD + trackLen
lap = 1
score = 0
traffic = {}
for _ = 1, TRAFFIC do
local kind = (math.random() < 0.75) and KINDS.traffic or KINDS.truck
traffic[#traffic + 1] = { kind = kind, z = math.random(ARCH_SEGMENT + 20, #track - 10) * SEG, x = math.random(-1, 1) * LANE, speed = math.random(120, 260) }
end
showMessage("GO!", 2)
end
local function crash()
spin = 0
speed = 0
end
local function step(dt)
local steer = (held[SWITCH_RIGHT] and 1 or 0) - (held[SWITCH_LEFT] and 1 or 0)
local gas = held[SWITCH_BUTTON1] or held[SWITCH_UP]
local brake = held[SWITCH_BUTTON2] or held[SWITCH_DOWN]
local playerZ = position + PLAYER_AHEAD
local segment = segmentAt(playerZ)
local pace = speed / MAX_SPEED
local offRoad = math.abs(playerX) > ROAD_HALF
clock = clock + dt
if timeLeft > 0 then
timeLeft = math.max(0, timeLeft - dt)
if timeLeft == 0 then
showMessage("TIME UP", 1000)
end
end
-- A spin takes the controls away until it is over, and leaves the car on the road's edge.
if spin then
spin = spin + dt
if spin >= SPIN_SECONDS then
spin = nil
playerX = (playerX < 0 and -1 or 1) * (ROAD_HALF - PLAYER_W)
end
steer = 0
gas = false
end
if timeLeft == 0 then
gas = false
end
if gas then
speed = speed + ACCEL * dt
elseif brake then
speed = speed - BRAKE * dt
else
speed = speed - COAST * dt
end
if offRoad and speed > OFFROAD_SPEED then
speed = speed - OFFROAD_DRAG * dt
end
speed = math.max(0, math.min(MAX_SPEED, speed))
-- Steering moves the car across in proportion to its speed; a bend pushes it outward by the
-- square of it.
playerX = playerX + steer * STEER * pace * dt
playerX = playerX - segment.curve / CURVE_HARD * pace * pace * CENTRIFUGAL * dt
playerX = math.max(-ROAD_HALF * 3, math.min(ROAD_HALF * 3, playerX))
position = (position + speed * dt) % trackLen
distance = distance + speed * dt
score = score + math.floor(speed * dt)
hillsAt = (hillsAt + segment.curve / CURVE_HARD * HILLS_SCROLL * speed * dt / SEG) % hillsW
if distance >= nextLap then
nextLap = nextLap + trackLen
lap = lap + 1
timeLeft = timeLeft + TIME_BONUS
showMessage("EXTENDED TIME", 2)
end
-- Off the road, anything solid in the car's segment stops it dead.
playerZ = position + PLAYER_AHEAD
segment = segmentAt(playerZ)
if not spin and math.abs(playerX) > ROAD_HALF then
for _, thing in ipairs(segment.scenery) do
if thing.kind.solid and math.abs(thing.x - playerX) < (PLAYER_W + thing.kind.width) * 0.35 then
crash()
break
end
end
end
-- Traffic keeps its lane at its own speed. Running into the back of a car drops the player to
-- that car's speed, behind it.
for _, car in ipairs(traffic) do
car.z = (car.z + car.speed * dt) % trackLen
local gap = (car.z - playerZ) % trackLen
if gap < CAR_LENGTH and speed > car.speed and math.abs(car.x - playerX) < (PLAYER_W + car.kind.width) * 0.4 then
speed = car.speed * 0.7
position = (car.z - CAR_LENGTH - PLAYER_AHEAD) % trackLen
end
end
end
-- The far hills, twice the screen's width, drawn round from wherever they have scrolled to.
local function drawHills()
local from = math.floor(hillsAt)
local first = math.min(W, hillsW - from)
local top = HORIZON_Y - spriteGetHeight(hills) + 1
spriteDrawGrid(hills, 0, top, from, 0, first, spriteGetHeight(hills))
if first < W then
spriteDrawGrid(hills, first, top, 0, 0, W - first, spriteGetHeight(hills))
end
end
-- A picture standing on the road at its distance, centred across on x, its foot at bottom. Rows at
-- clip and below are hidden by nearer road, so a thing beyond a crest shows only what is above it:
-- the picture's top part, cut out and stretched to what is left.
local function drawStanding(kind, x, bottom, perUnit, clip)
local w = kind.width * perUnit
local h = kind.height * perUnit
if w < 1 or x + w / 2 < 0 or x - w / 2 >= W or bottom - h >= clip then
return
end
local left = math.floor(x - w / 2)
local right = math.floor(x + w / 2)
local top = math.floor(bottom - h)
if bottom <= clip then
spriteDraw(kind.sprite, left, top, right, math.floor(bottom) - 1)
else
local shown = math.min(kind.naturalH, math.max(1, math.floor(kind.naturalH * (clip - (bottom - h)) / h)))
spriteDrawGrid(kind.sprite, left, top, 0, 0, kind.naturalW, shown, right, clip - 1)
end
end
-- The road, nearest segment first. Each segment fills the rows between where its near edge and its
-- far edge land, as far up as the nearest road drawn so far reaches (clip). Each row is one cut-out
-- row of the strip stretched to the road's width there, over one stretched row of grass.
local function drawRoad(camX, camY)
local base = math.floor(position / SEG)
local within = position - base * SEG
local first = track[(base % #track) + 1]
local x = 0
local dx = -first.curve * within / SEG
local clip = H
local hazeFar = DRAW * SEG
drawn = {}
for n = 0, DRAW - 1 do
local index = base + n
local segment = track[(index % #track) + 1]
local z1 = math.max(NEAR, n * SEG - within)
local z2 = n * SEG - within + SEG
local scale1 = FOCAL / z1
local scale2 = FOCAL / z2
segment.sx1 = W / 2 + (x - camX) * scale1
segment.sy1 = HORIZON_Y - (segment.y1 - camY) * scale1
segment.scale1 = scale1
segment.sx2 = W / 2 + (x + dx - camX) * scale2
segment.sy2 = HORIZON_Y - (segment.y2 - camY) * scale2
segment.scale2 = scale2
segment.clip = clip
segment.z1 = z1
x = x + dx
dx = dx + segment.curve
drawn[#drawn + 1] = segment
if segment.sy2 < clip and segment.sy1 > segment.sy2 then
local top = math.max(0, math.ceil(segment.sy2))
local bottom = math.min(clip - 1, math.floor(segment.sy1))
local light = (index // RUMBLE_SEGS) % 2
local haze = math.min(HAZE - 1, math.floor(z1 / hazeFar * HAZE))
local road = (ROW_ROAD + light) * HAZE + haze
local grass = (ROW_GRASS + light) * HAZE + haze
local height = segment.sy1 - segment.sy2
if top <= bottom then
spriteDrawGrid(strip, 0, top, 0, grass, STRIP_W, 1, W - 1, bottom)
for row = bottom, top, -1 do
local t = (segment.sy1 - row) / height
local cx = segment.sx1 + (segment.sx2 - segment.sx1) * t
local half = ROAD_HALF * (scale1 + (scale2 - scale1) * t)
spriteDrawGrid(strip, math.floor(cx - half), row, 0, road, STRIP_W, 1, math.floor(cx + half), row)
end
clip = top
end
end
end
end
-- Scenery and traffic, farthest first, each clipped by the road nearer than it.
local function drawThings()
for _, car in ipairs(traffic) do
local segment = segmentAt(car.z)
segment.cars[#segment.cars + 1] = car
end
for i = #drawn, 2, -1 do
local segment = drawn[i]
for _, thing in ipairs(segment.scenery) do
drawStanding(thing.kind, segment.sx1 + thing.x * segment.scale1, segment.sy1, segment.scale1, segment.clip)
end
for _, car in ipairs(segment.cars) do
local t = (car.z % SEG) / SEG
local perUnit = segment.scale1 + (segment.scale2 - segment.scale1) * t
local sx = segment.sx1 + (segment.sx2 - segment.sx1) * t + car.x * perUnit
local sy = segment.sy1 + (segment.sy2 - segment.sy1) * t
drawStanding(car.kind, sx, sy, perUnit, segment.clip)
end
end
for _, segment in ipairs(drawn) do
segment.cars = {}
end
end
-- The player's car: one picture at its own size, leaned into the turn, shaken on the grass, and
-- spun right round in a crash.
local function drawCar(steer)
local pace = speed / MAX_SPEED
local angle = math.floor(steer * LEAN * pace + 0.5)
local shake = 0
if spin then
local t = spin / SPIN_SECONDS
angle = math.floor(360 * SPIN_TURNS * (1 - (1 - t) * (1 - t)))
end
if math.abs(playerX) > ROAD_HALF and speed > 0 then
shake = math.random(0, 2)
end
-- Turned, the picture grows, so it is drawn by its centre, which stays where the upright one's was.
spriteRotate(KINDS.car.sprite, angle % 360)
spriteDraw(KINDS.car.sprite, W // 2, PLAYER_BOTTOM - KINDS.car.naturalH // 2 - shake, true)
end
function onInputPressed(what)
held[what] = true
if what == SWITCH_BUTTON1 and timeLeft == 0 and speed == 0 then
reset()
end
end
function onInputReleased(what)
held[what] = false
end
function onOverlayUpdate()
local now = singeGetTicks()
local dt = lastTicks and math.min(0.05, (now - lastTicks) / 1000) or 0
lastTicks = now
step(dt)
local playerZ = position + PLAYER_AHEAD
local segment = segmentAt(playerZ)
local t = (playerZ % SEG) / SEG
local camY = segment.y1 + (segment.y2 - segment.y1) * t + CAM_HEIGHT
local steer = (held[SWITCH_RIGHT] and 1 or 0) - (held[SWITCH_LEFT] and 1 or 0)
spriteDraw(sky, 0, 0)
drawHills()
drawRoad(playerX, camY)
drawThings()
drawCar(steer)
overlayPrint(1, 0, string.format("TIME %2d", math.ceil(timeLeft)))
overlayPrint(15, 0, string.format("LAP %d", lap))
overlayPrint(26, 0, string.format("%06d", score // 10))
overlayPrint(1, 14, string.format("%3d km/h", math.floor(speed * 0.7)))
if message and clock < messageEnd then
overlayPrint(20 - #message // 2, 5, message)
end
if timeLeft == 0 and speed == 0 then
overlayPrint(9, 7, "Button 1 drives again")
end
return OVERLAY_UPDATED
end
overlaySetResolution(W, H)
strip = spriteLoad(DIR .. "art/strip.png")
sky = spriteLoad(DIR .. "art/sky.png")
hills = spriteLoad(DIR .. "art/hills.png")
hillsW = spriteGetWidth(hills)
KINDS.car = loadKind("car", 40, false)
KINDS.traffic = loadKind("traffic", 42, true)
KINDS.truck = loadKind("truck", 46, true)
KINDS.palm = loadKind("palm", 50, true)
KINDS.bush = loadKind("bush", 50, false)
KINDS.sign = loadKind("sign", 40, true)
KINDS.arch = loadKind("arch", ROAD_HALF * 2.3, false)
buildTrack()
reset()