-- 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()