516 lines
17 KiB
Text
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()
|