1901 lines
63 KiB
Text
1901 lines
63 KiB
Text
-- Insert Coin: a demo for Singe, to "Game Is On" by poorartistt.
|
|
--
|
|
-- Forty years of video games in four minutes, an era to a section of the song: a phosphor bat and
|
|
-- ball, aliens in formation that step on the beat, a sixteen-bit runner over parallax hills and a
|
|
-- kart round a track seen in perspective, a tape loading, flat-shaded polygons with their wobble, modern glass
|
|
-- and chrome and physics, a countdown to continue, a boss in a storm of bullets, a bonus stage, the
|
|
-- high score table, and every era again, faster, at the end.
|
|
--
|
|
-- Everything moves on the music: the aliens step on the beat, the ball meets the bats on the beat,
|
|
-- the runner jumps on the snare, the boss fires on the kick, the hats, and the snare.
|
|
dofile("Singe/Framework.singe")
|
|
local K = dofile(DIR .. "../kit.lua")
|
|
|
|
local W = 640
|
|
local H = 480
|
|
local FOV = 60
|
|
local PHOSPHOR = { 70, 255, 140 }
|
|
local NEON = { { 255, 60, 90 }, { 255, 170, 40 }, { 255, 240, 70 }, { 70, 230, 120 }, { 60, 180, 255 }, { 190, 90, 255 } }
|
|
-- The high score table near the end: edit it freely. Rows show top to bottom in the order written
|
|
-- here, ranked 1ST, 2ND, and so on; any number of rows fits, and a name too long for the column is
|
|
-- drawn smaller. Scores show as six digits.
|
|
local HIGH_SCORES = {
|
|
{ "SINGE", 999990 },
|
|
{ "MAME", 874350 },
|
|
{ "STELLA", 762100 },
|
|
{ "VICE", 658400 },
|
|
{ "DAPHNE", 541750 },
|
|
{ "DOSBOX", 433200 },
|
|
{ "FCEUX", 356900 },
|
|
{ "SNES9X", 284150 },
|
|
{ "MEDNAFEN", 197600 },
|
|
{ "HYPSEUS", 121050 },
|
|
}
|
|
|
|
-- The big scroller that runs over the whole demo until the recap: what this is, and what Singe does.
|
|
local FEATURE_TEXT = "YOU ARE WATCHING INSERT COIN, A DEMO OF THE SINGE GAME ENGINE ... EVERYTHING HERE RUNS LIVE, IN REAL TIME, TIMED TO THE MUSIC BEAT BY BEAT ... SINGE PLAYS LASERDISC GAMES AND MAKES NEW ONES ... GAMES ARE WRITTEN IN LUA ... SPRITES SCALE, TURN, TINT, AND GLOW ... VIDEO IN ALMOST ANY FORMAT, HARDWARE DECODED ... A 3D ENGINE WITH GLTF MODELS, LIGHTS, SHADOWS, BLOOM, AND FOG ... PHYSICS, CLOTH, ROPES, RAGDOLLS, AND VEHICLES ... PARTICLES IN 2D AND 3D ... GUIS, MIDI, AND THE MICROPHONE ... LIGHT GUNS AND EVERY KIND OF CONTROLLER ... ONLINE HIGH SCORES ... IT RECORDS ITS OWN VIDEO ... ONE FILE PER GAME ... WINDOWS, MACOS, LINUX, RASPBERRY PI, AND HANDHELDS ... SINGE ... "
|
|
local FEATURE_ALPHA = 190 -- How see-through its letters are: 0 to 255
|
|
local FEATURE_CELL = 12 -- A block of its letters, in pixels, as a letter rests
|
|
|
|
local FX_W = 320 -- The live plasma's and tunnel's size, stretched to wherever they are shown
|
|
local FX_H = 240
|
|
local TUNNEL_LAP = 4 -- The tunnel's travel counts a lap of its texture as this many passes
|
|
local PLASMA_STOPS = { { 0.00, { 0, 0, 20 } }, { 0.25, { 10, 40, 160 } }, { 0.50, { 20, 200, 120 } }, { 0.70, { 240, 240, 60 } }, { 0.85, { 255, 255, 255 } }, { 1.00, { 0, 0, 20 } } }
|
|
local TUNNEL_STOPS = { { 0.00, { 0, 0, 20 } }, { 0.30, { 10, 40, 160 } }, { 0.55, { 20, 200, 120 } }, { 0.80, { 240, 240, 60 } }, { 1.00, { 255, 255, 255 } } }
|
|
local ALIEN_COLS = 10
|
|
local ALIEN_ROWS = 5
|
|
local RING_EVERY = 14 -- World units between rings in the polygon era: one passes each beat
|
|
-- The vector era: a field of the camera's view at z = 0, and the asteroids' sizes.
|
|
local VEC_HALF_W = 30
|
|
local VEC_HALF_H = 23
|
|
local VEC_SIZES = { 3.2, 1.9, 1.0 } -- Radius of a large, a medium, and a small asteroid
|
|
local VEC_POOL = { 6, 12, 24 } -- How many of each the field can hold
|
|
local VEC_EDGE = 0.14 -- How thick a glowing line is
|
|
local VEC_SHOTS = 24
|
|
local VEC_TURN = 540 -- Degrees a second the ship turns to aim
|
|
-- The light-gun era: three rails of targets, each sliding across in RAIL_BEATS beats.
|
|
local RAILS = { 150, 255, 360 }
|
|
local RAIL_BEATS = 4
|
|
local RAIL_GAP = 210 -- Pixels from one target to the next on a rail
|
|
-- The versus era.
|
|
local FIGHTER_SCALE = 4
|
|
local FIGHTER_FRAME = 40
|
|
local FIGHT_DAMAGE = 0.03
|
|
-- The rhythm era: the highway's lanes, what they are fed from, and how far ahead notes show.
|
|
local RHYTHM_LANES = { { list = "kicks", label = "KICK", rgb = { 255, 70, 90 } }, { list = "snares", label = "SNARE", rgb = { 80, 220, 255 } }, { list = "hats", label = "HAT", rgb = { 255, 220, 70 } } }
|
|
local RHYTHM_LOOK = 1.6 -- Seconds of notes on the highway
|
|
local RHYTHM_TOP = 120 -- Where the highway meets the horizon
|
|
local RHYTHM_HIT = 400 -- The hit line
|
|
local RHYTHM_SPAN = 130 -- Pixels between lanes at the hit line
|
|
|
|
local KART_HEIGHT = 3.2 -- The rival karts' height in the racer's world
|
|
local KART_SCALE = 4 -- The player's kart: screen pixels a pixel of its picture
|
|
local KART_W = 48 -- The kart pictures' size
|
|
local KART_H = 29
|
|
local GATE_RADIUS = 4.5 -- The gates' size: tight enough that the ship has to thread them
|
|
local COIN_ALIVE = 60 -- Coins in the bonus stage at once; the oldest are swept away first
|
|
local COIN_BELT = 11 -- World units a second the tunnel floor carries a coin, at its slowest
|
|
local COIN_RATE = 4 -- Coins dropped a beat
|
|
local COIN_RADIUS = 1.0
|
|
local COIN_THICK = 0.2
|
|
local BRICK_COLS = 9
|
|
local BRICK_ROWS = 6
|
|
|
|
local sync = K.load(DIR, W, H)
|
|
local beat = sync.beats[3] - sync.beats[2]
|
|
local art = {}
|
|
local world = {}
|
|
local lastT = 0
|
|
|
|
|
|
-- 1ST, 2ND, 3RD, 4TH ... 11TH, 12TH, 13TH ... 21ST.
|
|
local function ordinal(n)
|
|
local last = n % 10
|
|
local suffix = "TH"
|
|
|
|
if (n % 100 < 11) or (n % 100 > 13) then
|
|
suffix = ({ "ST", "ND", "RD" })[last] or "TH"
|
|
end
|
|
return n .. suffix
|
|
end
|
|
|
|
|
|
-- Pixel drawing ------------------------------------------------------------------------------------
|
|
|
|
-- Frame frame of a sheet of frames frameW wide, at x, y, scale pixels a pixel.
|
|
local function px(sprite, frameW, frame, x, y, scale)
|
|
local h = spriteGetHeight(sprite)
|
|
|
|
spriteDrawGrid(sprite, math.floor(x), math.floor(y), frame * frameW, 0, frameW, h, math.floor(x + frameW * scale) - 1, math.floor(y + h * scale) - 1)
|
|
end
|
|
|
|
|
|
-- Text in the 8 by 8 font, scale pixels a pixel. align "center" centres it on x.
|
|
local function ptext(x, y, text, scale, r, g, b, align)
|
|
local w = #text * 8 * scale
|
|
|
|
if align == "center" then
|
|
x = x - w / 2
|
|
end
|
|
spriteSetTint(art.font, r, g, b)
|
|
for i = 1, #text do
|
|
local c = text:byte(i) - 32
|
|
|
|
if c > 0 and c < 96 then
|
|
spriteDrawGrid(art.font, math.floor(x + (i - 1) * 8 * scale), math.floor(y), (c % 16) * 8, (c // 16) * 8, 8, 8, math.floor(x + i * 8 * scale) - 1, math.floor(y + 8 * scale) - 1)
|
|
end
|
|
end
|
|
spriteSetTint(art.font, 255, 255, 255)
|
|
end
|
|
|
|
|
|
-- The same, each letter its own colour round the wheel, cycling: the high score table's rainbow.
|
|
local function rainbow(x, y, text, scale, t, align)
|
|
local w = #text * 8 * scale
|
|
|
|
if align == "center" then
|
|
x = x - w / 2
|
|
end
|
|
for i = 1, #text do
|
|
local r, g, b = K.hue(i * 0.06 - t * 0.8)
|
|
|
|
ptext(x + (i - 1) * 8 * scale, y, text:sub(i, i), scale, r, g, b)
|
|
end
|
|
end
|
|
|
|
|
|
local function scanlines(alpha)
|
|
spriteSetAlpha(art.scanlines, alpha or 255)
|
|
spriteDraw(art.scanlines, 0, 0, W - 1, H - 1)
|
|
spriteSetAlpha(art.scanlines, 255)
|
|
end
|
|
|
|
|
|
-- Loading --------------------------------------------------------------------------------------------
|
|
|
|
local function loadArt()
|
|
for _, name in ipairs({ "invaderA", "invaderB", "cannon", "runner", "coin", "ship", "kart", "bossCore", "bossArm", "turret", "bullet", "font", "mountains", "hills", "ground", "scanlines", "fighterA", "fighterB", "target" }) do
|
|
art[name] = spriteLoad(DIR .. "art/" .. name .. ".png")
|
|
end
|
|
spriteQuality(art.bossCore, RENDER_SMOOTH)
|
|
spriteQuality(art.bossArm, RENDER_SMOOTH)
|
|
-- The plasma and the tunnel, worked out live in Lua a frame at a time.
|
|
art.plasma = K.plasmaNew(FX_W, FX_H, PLASMA_STOPS)
|
|
art.tunnel = K.tunnelNew(FX_W, FX_H, TUNNEL_STOPS)
|
|
|
|
-- Explosions on the overlay: square sparks added over everything.
|
|
art.boom = emitterNew()
|
|
emitterSetBlend(art.boom, PARTICLE_ADD)
|
|
emitterSetRate(art.boom, 0)
|
|
emitterSetLife(art.boom, 0.3, 0.9)
|
|
emitterSetSpeed(art.boom, 60, 320)
|
|
emitterSetSpread(art.boom, 180)
|
|
emitterSetDrag(art.boom, 2)
|
|
emitterSetSize(art.boom, 9, 2)
|
|
emitterSetMax(art.boom, 4000)
|
|
emitterStart(art.boom)
|
|
end
|
|
|
|
|
|
local function boom(x, y, count, r, g, b)
|
|
emitterSetPosition(art.boom, x, y)
|
|
emitterSetColor(art.boom, 255, 255, 255, 255, r, g, b, 0)
|
|
emitterBurst(art.boom, count)
|
|
end
|
|
|
|
|
|
-- The world --------------------------------------------------------------------------------------------
|
|
|
|
local function flatMesh(verts, faces)
|
|
local positions = {}
|
|
local normals = {}
|
|
local indices = {}
|
|
|
|
for _, f in ipairs(faces) do
|
|
local a = verts[f[1]]
|
|
local b = verts[f[2]]
|
|
local c = verts[f[3]]
|
|
local ux = b[1] - a[1]
|
|
local uy = b[2] - a[2]
|
|
local uz = b[3] - a[3]
|
|
local vx = c[1] - a[1]
|
|
local vy = c[2] - a[2]
|
|
local vz = c[3] - a[3]
|
|
local nx = uy * vz - uz * vy
|
|
local ny = uz * vx - ux * vz
|
|
local nz = ux * vy - uy * vx
|
|
local l = math.sqrt(nx * nx + ny * ny + nz * nz) + 1e-9
|
|
|
|
for _, p in ipairs({ a, b, c }) do
|
|
positions[#positions + 1] = p[1]
|
|
positions[#positions + 1] = p[2]
|
|
positions[#positions + 1] = p[3]
|
|
normals[#normals + 1] = nx / l
|
|
normals[#normals + 1] = ny / l
|
|
normals[#normals + 1] = nz / l
|
|
indices[#indices + 1] = #positions / 3
|
|
end
|
|
end
|
|
return meshNew(positions, normals, nil, indices)
|
|
end
|
|
|
|
|
|
-- A low-poly ship: a few triangles, flat shaded, nose toward -Z.
|
|
local function shipMesh()
|
|
local v = {
|
|
{ 0, 0, -2.2 }, { -0.5, 0.15, 0.4 }, { 0.5, 0.15, 0.4 }, { 0, 0.5, 0.2 }, { 0, -0.2, 0.6 },
|
|
{ -1.8, -0.05, 0.9 }, { 1.8, -0.05, 0.9 }, { 0, 0.15, 1.0 },
|
|
}
|
|
local f = {
|
|
{ 1, 4, 2 }, { 1, 3, 4 }, { 1, 2, 5 }, { 1, 5, 3 }, { 2, 4, 8 }, { 4, 3, 8 }, { 2, 8, 5 }, { 3, 5, 8 },
|
|
{ 2, 6, 5 }, { 3, 5, 7 }, { 2, 5, 6 }, { 3, 7, 5 },
|
|
}
|
|
|
|
return flatMesh(v, f)
|
|
end
|
|
|
|
|
|
local function octahedron(r)
|
|
local v = { { r, 0, 0 }, { -r, 0, 0 }, { 0, r, 0 }, { 0, -r, 0 }, { 0, 0, r }, { 0, 0, -r } }
|
|
local f = { { 1, 3, 5 }, { 5, 3, 2 }, { 2, 3, 6 }, { 6, 3, 1 }, { 5, 4, 1 }, { 2, 4, 5 }, { 6, 4, 2 }, { 1, 4, 6 } }
|
|
|
|
return flatMesh(v, f)
|
|
end
|
|
|
|
|
|
local function buildRacer()
|
|
local ground = materialNew()
|
|
|
|
materialSetTexture(ground, spriteLoad(DIR .. "art/track.png"))
|
|
materialSetFilter(ground, FILTER_NEAREST)
|
|
materialSetUnlit(ground, true)
|
|
world.track = nodeNew()
|
|
nodeSetMesh(world.track, meshPlane(400, 400), ground)
|
|
local skyMaterial = materialNew()
|
|
|
|
materialSetTexture(skyMaterial, spriteLoad(DIR .. "art/raceSky.png"))
|
|
materialSetUnlit(skyMaterial, true)
|
|
materialSetDoubleSided(skyMaterial, true)
|
|
world.raceSky = nodeNew()
|
|
nodeSetMesh(world.raceSky, meshSphere(320, 32), skyMaterial)
|
|
world.rivals = {}
|
|
for i = 1, 5 do
|
|
local n = nodeNew()
|
|
|
|
-- A crisp picture on a quad that the racer turns to face the camera: the same nearest-pixel look as the
|
|
-- player's kart, where a sprite node would be smoothed.
|
|
local m = materialNew()
|
|
local quad = nodeNew(n)
|
|
|
|
materialSetTexture(m, spriteLoad(DIR .. "art/kart" .. i .. ".png"))
|
|
materialSetFilter(m, FILTER_NEAREST)
|
|
materialSetUnlit(m, true)
|
|
materialSetDoubleSided(m, true)
|
|
materialSetCutoff(m, 0.5)
|
|
nodeSetMesh(quad, meshPlane(KART_HEIGHT * KART_W / KART_H, KART_HEIGHT), m)
|
|
nodeSetRotation(quad, 90, 0, 0)
|
|
world.rivals[i] = { node = n, offset = 0.12 + i * 0.09 }
|
|
end
|
|
end
|
|
|
|
|
|
local function buildPolygons()
|
|
local hull = materialNew()
|
|
|
|
materialSetColor(hull, 200, 210, 230)
|
|
materialSetRoughness(hull, 0.6)
|
|
-- The ship turns on a pivot that faces along the path, and banks on its own node under it.
|
|
world.shipPivot = nodeNew()
|
|
world.ship = nodeNew(world.shipPivot)
|
|
nodeSetMesh(world.ship, shipMesh(), hull)
|
|
world.rings = {}
|
|
world.ringMaterials = {}
|
|
for i = 1, 6 do
|
|
local m = materialNew()
|
|
local c = NEON[i]
|
|
|
|
materialSetColor(m, c[1] // 2, c[2] // 2, c[3] // 2)
|
|
materialSetRoughness(m, 0.7)
|
|
world.ringMaterials[i] = m
|
|
end
|
|
local ringMesh = meshTorus(GATE_RADIUS, 0.45, 6)
|
|
|
|
-- Each gate is a pivot that faces along the path, with the hexagon stood up under it so it can
|
|
-- turn in its own plane.
|
|
world.gates = {}
|
|
for i = 1, 14 do
|
|
local n = nodeNew()
|
|
local ring = nodeNew(n)
|
|
|
|
nodeSetMesh(ring, ringMesh, world.ringMaterials[(i - 1) % 6 + 1])
|
|
world.rings[i] = n
|
|
world.gates[i] = ring
|
|
end
|
|
local floorMaterial = materialNew()
|
|
|
|
materialSetTexture(floorMaterial, spriteLoad(DIR .. "art/ground.png"))
|
|
materialSetFilter(floorMaterial, FILTER_NEAREST)
|
|
materialSetTiling(floorMaterial, 30, 30)
|
|
materialSetColor(floorMaterial, 70, 55, 120)
|
|
world.polyFloor = nodeNew()
|
|
nodeSetMesh(world.polyFloor, meshPlane(300, 300, 4), floorMaterial)
|
|
world.enemies = {}
|
|
local enemyMaterial = materialNew()
|
|
|
|
materialSetColor(enemyMaterial, 255, 80, 60)
|
|
materialSetEmissive(enemyMaterial, 120, 20, 10)
|
|
local enemyMesh = octahedron(1.2)
|
|
|
|
for i = 1, 6 do
|
|
local n = nodeNew()
|
|
|
|
nodeSetMesh(n, enemyMesh, enemyMaterial)
|
|
world.enemies[i] = n
|
|
end
|
|
world.shardAnchor = nodeNew()
|
|
world.shards = emitterNew(world.shardAnchor)
|
|
emitterSetRate(world.shards, 0)
|
|
emitterSetLife(world.shards, 0.4, 1.0)
|
|
emitterSetSpeed(world.shards, 6, 16)
|
|
emitterSetSpread(world.shards, 180)
|
|
emitterSetGravity(world.shards, 0, -10, 0)
|
|
emitterSetSize(world.shards, 0.5, 0.1)
|
|
emitterSetColor(world.shards, 255, 200, 80, 255, 255, 60, 20, 0)
|
|
emitterSetBlend(world.shards, PARTICLE_ADD)
|
|
emitterStart(world.shards)
|
|
world.light = lightNew(LIGHT_DIRECTIONAL)
|
|
nodeSetPosition(world.light, 3, 8, 4)
|
|
nodeLookAt(world.light, 0, 0, 0)
|
|
lightSetIntensity(world.light, 0)
|
|
end
|
|
|
|
|
|
-- The modern era: a wall of glossy bricks on a dark floor, and chrome balls to knock it down.
|
|
local function buildModern()
|
|
local floorMaterial = materialNew()
|
|
|
|
materialSetColor(floorMaterial, 30, 30, 40)
|
|
materialSetMetallic(floorMaterial, 0.8)
|
|
materialSetRoughness(floorMaterial, 0.15)
|
|
world.modernFloor = nodeNew()
|
|
nodeSetMesh(world.modernFloor, meshBox(60, 0.4, 60), floorMaterial)
|
|
nodeSetPosition(world.modernFloor, 0, -0.2, 0)
|
|
bodyNew(world.modernFloor, BODY_STATIC, SHAPE_BOX, 60, 0.4, 60)
|
|
world.brickMaterials = {}
|
|
for i, c in ipairs(NEON) do
|
|
local m = materialNew()
|
|
|
|
materialSetColor(m, c[1], c[2], c[3])
|
|
materialSetMetallic(m, 0.3)
|
|
materialSetRoughness(m, 0.12)
|
|
world.brickMaterials[i] = m
|
|
end
|
|
world.brickMesh = meshBox(1.2, 0.6, 0.6)
|
|
world.bricks = {}
|
|
world.ballMesh = meshSphere(0.45, 24)
|
|
world.ballMaterial = K.chrome(240, 240, 250, 0.04)
|
|
world.balls = {}
|
|
world.spark = nodeNew()
|
|
world.sparks = emitterNew(world.spark)
|
|
emitterSetBlend(world.sparks, PARTICLE_ADD)
|
|
emitterSetRate(world.sparks, 0)
|
|
emitterSetLife(world.sparks, 0.3, 0.8)
|
|
emitterSetSpeed(world.sparks, 2, 7)
|
|
emitterSetSpread(world.sparks, 180)
|
|
emitterSetGravity(world.sparks, 0, -9, 0)
|
|
emitterSetSize(world.sparks, 0.08, 0.02)
|
|
emitterSetColor(world.sparks, 255, 240, 200, 255, 255, 120, 40, 0)
|
|
emitterSetTrail(world.sparks, 5, 0.03)
|
|
emitterStart(world.sparks)
|
|
world.sun = lightNew(LIGHT_DIRECTIONAL)
|
|
nodeSetPosition(world.sun, -6, 10, 8)
|
|
nodeLookAt(world.sun, 0, 0, 0)
|
|
lightSetIntensity(world.sun, 0)
|
|
lightSetShadow(world.sun, true)
|
|
end
|
|
|
|
|
|
local function buildBonus()
|
|
local tunnel = materialNew()
|
|
|
|
world.tunnelMaterial = tunnel
|
|
materialSetTexture(tunnel, art.tunnel.sprite)
|
|
materialSetUnlit(tunnel, true)
|
|
materialSetDoubleSided(tunnel, true)
|
|
world.tunnel = K.screenQuad(world.camera, FOV, tunnel, 80, 1.8)
|
|
-- A coin is a gold rim and two struck faces: the name on one side and the dragon on the other.
|
|
world.coinMesh = meshCylinder(COIN_RADIUS, COIN_THICK, 24)
|
|
world.coinMaterial = K.chrome(255, 200, 60, 0.25)
|
|
materialSetEmissive(world.coinMaterial, 150, 100, 10)
|
|
world.faceMesh = K.disc(COIN_RADIUS, 32)
|
|
world.faceMaterials = {}
|
|
for i, file in ipairs({ "coinSinge.png", "coinDragon.png" }) do
|
|
local m = materialNew()
|
|
local image = spriteLoad(DIR .. "art/" .. file)
|
|
|
|
materialSetTexture(m, image)
|
|
materialSetEmissiveMap(m, image)
|
|
materialSetEmissive(m, 150, 120, 60)
|
|
materialSetMetallic(m, 0.8)
|
|
materialSetRoughness(m, 0.3)
|
|
world.faceMaterials[i] = m
|
|
end
|
|
world.coins = {}
|
|
-- The tunnel's floor, which nobody sees: a long slab the coins land on, running from far down
|
|
-- the tunnel to behind the camera. It waits out of the way until the bonus stage.
|
|
world.coinFloor = nodeNew()
|
|
nodeSetPosition(world.coinFloor, 0, -200, 0)
|
|
bodyNew(world.coinFloor, BODY_KINEMATIC, SHAPE_BOX, 24, 0.4, 140)
|
|
end
|
|
|
|
|
|
-- The vector era: every line a thin glowing box, so bloom makes it glow as a vector monitor's beam
|
|
-- did. Asteroids are outlines of ten jagged points, a pool of each size; the ship is a triangle.
|
|
local function edge(parent, x1, y1, x2, y2)
|
|
local n = nodeNew(parent)
|
|
local dx = x2 - x1
|
|
local dy = y2 - y1
|
|
|
|
nodeSetMesh(n, world.vecEdge, world.vecGlow)
|
|
nodeSetPosition(n, (x1 + x2) / 2, (y1 + y2) / 2, 0)
|
|
nodeSetRotation(n, 0, 0, math.deg(math.atan(dy, dx)))
|
|
nodeSetScale(n, math.sqrt(dx * dx + dy * dy) + VEC_EDGE, VEC_EDGE, VEC_EDGE)
|
|
return n
|
|
end
|
|
|
|
|
|
local function buildVector()
|
|
world.vecEdge = meshBox(1, 1, 1)
|
|
world.vecGlow = materialNew()
|
|
materialSetColor(world.vecGlow, 0, 0, 0)
|
|
materialSetEmissive(world.vecGlow, 210, 235, 255)
|
|
world.vecField = nodeNew()
|
|
world.asteroids = {}
|
|
for size, count in ipairs(VEC_POOL) do
|
|
for i = 1, count do
|
|
local n = nodeNew(world.vecField)
|
|
local points = {}
|
|
|
|
for p = 0, 9 do
|
|
local a = p / 10 * 2 * math.pi
|
|
local r = VEC_SIZES[size] * (0.55 + 0.45 * ((p * 7 + i * 13 + size * 5) % 11) / 10)
|
|
|
|
points[#points + 1] = { math.cos(a) * r, math.sin(a) * r }
|
|
end
|
|
for p = 1, #points do
|
|
local q = points[p % #points + 1]
|
|
|
|
edge(n, points[p][1], points[p][2], q[1], q[2])
|
|
end
|
|
nodeSetVisible(n, false)
|
|
world.asteroids[#world.asteroids + 1] = { node = n, size = size, alive = false }
|
|
end
|
|
end
|
|
world.vecShip = nodeNew(world.vecField)
|
|
edge(world.vecShip, 1.6, 0, -1.1, 0.95)
|
|
edge(world.vecShip, 1.6, 0, -1.1, -0.95)
|
|
edge(world.vecShip, -0.7, 0.75, -0.7, -0.75)
|
|
world.vecFlame = nodeNew(world.vecShip)
|
|
edge(world.vecFlame, -0.7, 0.4, -1.7, 0)
|
|
edge(world.vecFlame, -0.7, -0.4, -1.7, 0)
|
|
world.vecShots = {}
|
|
for i = 1, VEC_SHOTS do
|
|
local n = nodeNew(world.vecField)
|
|
|
|
nodeSetMesh(n, world.vecEdge, world.vecGlow)
|
|
nodeSetScale(n, 0.35, 0.35, 0.35)
|
|
world.vecShots[i] = { node = n, alive = false }
|
|
end
|
|
world.vecBurstAt = nodeNew()
|
|
world.vecBurst = emitterNew(world.vecBurstAt)
|
|
emitterSetBlend(world.vecBurst, PARTICLE_ADD)
|
|
emitterSetRate(world.vecBurst, 0)
|
|
emitterSetLife(world.vecBurst, 0.4, 0.9)
|
|
emitterSetSpeed(world.vecBurst, 6, 22)
|
|
emitterSetSpread(world.vecBurst, 180)
|
|
emitterSetSize(world.vecBurst, 0.3, 0.05)
|
|
emitterSetColor(world.vecBurst, 255, 255, 255, 255, 160, 210, 255, 0)
|
|
emitterStart(world.vecBurst)
|
|
nodeSetVisible(world.vecField, false)
|
|
end
|
|
|
|
|
|
local function buildWorld()
|
|
sceneEnable(true)
|
|
sceneSetSky(DIR .. "art/duskSky.png")
|
|
sceneSetBloom(0.8, 0.5)
|
|
sceneSetAmbient(40, 40, 50)
|
|
world.camera = nodeNew()
|
|
cameraSet(world.camera)
|
|
cameraSetPerspective(FOV, 0.1, 800)
|
|
art.blocks = K.blockFont(dofile(DIR .. "art/font.lua"), world.camera, FOV, FEATURE_CELL, FEATURE_ALPHA)
|
|
buildRacer()
|
|
buildPolygons()
|
|
buildModern()
|
|
buildBonus()
|
|
buildVector()
|
|
end
|
|
|
|
|
|
local function hideAll()
|
|
for _, n in ipairs({ world.track, world.raceSky, world.shipPivot, world.polyFloor, world.modernFloor, world.tunnel }) do
|
|
nodeSetVisible(n, false)
|
|
end
|
|
for _, r in ipairs(world.rivals) do
|
|
nodeSetVisible(r.node, false)
|
|
end
|
|
for _, n in ipairs(world.rings) do
|
|
nodeSetVisible(n, false)
|
|
end
|
|
for _, n in ipairs(world.enemies) do
|
|
nodeSetVisible(n, false)
|
|
end
|
|
nodeSetVisible(world.vecField, false)
|
|
lightSetIntensity(world.light, 0)
|
|
lightSetIntensity(world.sun, 0)
|
|
sceneSetFog()
|
|
sceneSetSkyIntensity(1)
|
|
-- Behind a 2D era the 3D layer is plain black.
|
|
nodeSetPosition(world.camera, 0, -500, 0)
|
|
nodeLookAt(world.camera, 0, -500, -1)
|
|
end
|
|
|
|
|
|
-- The eras ----------------------------------------------------------------------------------------------
|
|
-- Each is a scene table without its bars, so the finale can run them again: enter, draw(t, bar,
|
|
-- k), and leave. An era's own time is the music's, so whatever it plays lands on the beat wherever
|
|
-- it is placed, as long as it is placed on a whole bar.
|
|
|
|
local ERA = {}
|
|
|
|
-- Switching on: the tube warms up from a line, INSERT COIN blinks on the beat, and a coin spins
|
|
-- down into the slot on the downbeat of bar 5.
|
|
ERA.boot = {
|
|
enter = function(t)
|
|
hideAll()
|
|
end,
|
|
draw = function(t, bar, k)
|
|
K.flash(0, 0, 0, 255)
|
|
local warm = K.smooth(1, 1.6, bar)
|
|
|
|
if warm < 1 then
|
|
local h = math.max(2, H * warm * warm)
|
|
|
|
K.box(0, H / 2 - h / 2, W - 1, H / 2 + h / 2, 255, 255, 255, math.floor(255 * (1 - warm * 0.85)))
|
|
return
|
|
end
|
|
if (K.beat(t) % 1) < 0.5 then
|
|
ptext(W / 2, 200, "INSERT COIN", 5, 255, 255, 255, "center")
|
|
end
|
|
local land = K.barTime(5)
|
|
local fall = K.clamp((t - (land - beat * 4)) / (beat * 4), 0, 1)
|
|
local cy = K.lerp(-60, 320, fall * fall)
|
|
local spin = math.floor(t * 12) % 4
|
|
|
|
if fall < 1 then
|
|
px(art.coin, 8, spin, W / 2 - 32, cy, 8)
|
|
end
|
|
K.box(W / 2 - 40, 392, W / 2 + 40, 402, 90, 90, 100, 255)
|
|
ptext(W / 2, 430, (fall >= 1) and "CREDIT 1" or "CREDIT 0", 3, 255, 220, 80, "center")
|
|
scanlines(160)
|
|
end,
|
|
}
|
|
|
|
-- Analog: two bats and a ball in green phosphor. The ball crosses the court once a beat, so it meets
|
|
-- a bat on every beat, and its afterglow trails behind it.
|
|
ERA.pong = {
|
|
enter = function(t)
|
|
hideAll()
|
|
world.pongScore = { 0, 0 }
|
|
end,
|
|
draw = function(t, bar, k)
|
|
K.flash(0, 6, 2, 255)
|
|
local b = K.beat(t)
|
|
local left = 60
|
|
local right = W - 60
|
|
|
|
for y = 20, H - 20, 24 do
|
|
K.box(W / 2 - 3, y, W / 2 + 3, y + 12, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], 160)
|
|
end
|
|
local function ballAt(beatPos)
|
|
local whole = math.floor(beatPos)
|
|
local p = beatPos - whole
|
|
local x = (whole % 2 == 0) and K.lerp(left + 12, right - 12, p) or K.lerp(right - 12, left + 12, p)
|
|
local tri = (beatPos * 0.37) % 2
|
|
|
|
tri = (tri < 1) and tri or (2 - tri)
|
|
return x, K.lerp(40, H - 40, tri)
|
|
end
|
|
for g = 6, 1, -1 do
|
|
local gx, gy = ballAt(b - g * 0.02)
|
|
|
|
K.box(gx - 7, gy - 7, gx + 7, gy + 7, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], 30 + (6 - g) * 20)
|
|
end
|
|
local x, y = ballAt(b)
|
|
|
|
K.box(x - 8, y - 8, x + 8, y + 8, 220, 255, 230, 255)
|
|
local _, ly = ballAt(math.floor(b) + ((math.floor(b) % 2 == 0) and 0 or 1))
|
|
local _, ry = ballAt(math.floor(b) + ((math.floor(b) % 2 == 0) and 1 or 0))
|
|
|
|
K.box(left - 10, ly - 40, left, ly + 40, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], 255)
|
|
K.box(right, ry - 40, right + 10, ry + 40, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], 255)
|
|
local points = math.floor(b / 4)
|
|
|
|
ptext(W / 2 - 80, 30, string.format("%02d", points % 100), 6, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], "center")
|
|
ptext(W / 2 + 80, 30, string.format("%02d", (points * 3) % 100), 6, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], "center")
|
|
K.strobe(t, "kicks", 20, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], 30)
|
|
scanlines(200)
|
|
end,
|
|
}
|
|
|
|
-- 8-bit: a formation of aliens that steps on every beat, the cannon below firing on every snare and
|
|
-- taking out the lowest alien above it.
|
|
local function alienPositions(step)
|
|
local cycle = step % 40
|
|
local dx = (cycle < 20) and cycle or (40 - cycle)
|
|
|
|
return 48 + dx * 8, 60 + math.floor(step / 20) * 16
|
|
end
|
|
|
|
|
|
ERA.invaders = {
|
|
enter = function(t)
|
|
hideAll()
|
|
world.alive = {}
|
|
for i = 1, ALIEN_COLS * ALIEN_ROWS do
|
|
world.alive[i] = true
|
|
end
|
|
world.shots = {}
|
|
world.alienBase = K.beat(t)
|
|
end,
|
|
draw = function(t, bar, k)
|
|
K.flash(0, 0, 8, 255)
|
|
local step = math.floor(K.beat(t) - world.alienBase)
|
|
local ox, oy = alienPositions(step)
|
|
local frame = step % 2
|
|
local cannonX = W / 2 + math.sin(t * 1.4) * 220
|
|
|
|
-- On a snare, a shot from the cannon at the lowest alien in the column above it.
|
|
if K.hitCount("snares", lastT, t) > 0 then
|
|
local col = math.floor((cannonX - ox) / 52 + 0.5)
|
|
|
|
if col >= 0 and col < ALIEN_COLS then
|
|
for row = ALIEN_ROWS - 1, 0, -1 do
|
|
local i = row * ALIEN_COLS + col + 1
|
|
|
|
if world.alive[i] then
|
|
world.shots[#world.shots + 1] = { x = cannonX, start = t, target = i }
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
for row = 0, ALIEN_ROWS - 1 do
|
|
local c = NEON[row % #NEON + 1]
|
|
local sprite = (row < 2) and art.invaderA or art.invaderB
|
|
|
|
spriteSetTint(sprite, c[1], c[2], c[3])
|
|
for col = 0, ALIEN_COLS - 1 do
|
|
if world.alive[row * ALIEN_COLS + col + 1] then
|
|
px(sprite, (row < 2) and 12 or 12, frame, ox + col * 52, oy + row * 40, 4)
|
|
end
|
|
end
|
|
spriteSetTint(sprite, 255, 255, 255)
|
|
end
|
|
for i = #world.shots, 1, -1 do
|
|
local s = world.shots[i]
|
|
local row = (s.target - 1) // ALIEN_COLS
|
|
local col = (s.target - 1) % ALIEN_COLS
|
|
local tx = ox + col * 52 + 24
|
|
local ty = oy + row * 40 + 16
|
|
local p = (t - s.start) / (beat * 0.35)
|
|
|
|
if p >= 1 then
|
|
if world.alive[s.target] then
|
|
world.alive[s.target] = false
|
|
local c = NEON[row % #NEON + 1]
|
|
|
|
boom(tx, ty, 60, c[1], c[2], c[3])
|
|
end
|
|
table.remove(world.shots, i)
|
|
else
|
|
local sx = K.lerp(s.x, tx, p)
|
|
local sy = K.lerp(H - 70, ty, p)
|
|
|
|
K.box(sx - 2, sy - 10, sx + 2, sy + 10, 255, 255, 255, 255)
|
|
end
|
|
end
|
|
spriteSetTint(art.cannon, 80, 255, 120)
|
|
px(art.cannon, 13, 0, cannonX - 26, H - 64, 4)
|
|
spriteSetTint(art.cannon, 255, 255, 255)
|
|
K.box(0, H - 28, W - 1, H - 26, 80, 255, 120, 255)
|
|
ptext(20, 14, string.format("SCORE %05d", (ALIEN_COLS * ALIEN_ROWS - (function() local n = 0 for _, a in ipairs(world.alive) do if a then n = n + 1 end end return n end)()) * 50), 2, 255, 255, 255)
|
|
emitterDraw(art.boom)
|
|
scanlines(180)
|
|
end,
|
|
}
|
|
|
|
-- 16-bit: a runner over parallax hills. The ground runs at a steady speed, the runner jumps on every
|
|
-- snare, as high as the note being played says, and the coins are placed where the jumps will take
|
|
-- him.
|
|
local RUN_SPEED = 260
|
|
local RUNNER_X = 170
|
|
|
|
ERA.runner = {
|
|
enter = function(t)
|
|
hideAll()
|
|
world.collected = {}
|
|
world.runScore = 0
|
|
end,
|
|
draw = function(t, bar, k)
|
|
for i = 0, 11 do
|
|
local r = 40 + i * 12
|
|
local g = 90 + i * 12
|
|
local b = 210 + i * 4
|
|
|
|
K.box(0, i * 30, W - 1, i * 30 + 29, r, g, math.min(255, b), 255)
|
|
end
|
|
local function layer(sprite, speed, y, scale)
|
|
local w = spriteGetWidth(sprite) * scale
|
|
local offset = (t * speed) % w
|
|
|
|
for x = -offset, W, w do
|
|
spriteDraw(sprite, math.floor(x), y, math.floor(x + w) - 1, y + spriteGetHeight(sprite) * scale - 1)
|
|
end
|
|
end
|
|
layer(art.mountains, RUN_SPEED * 0.15, 170, 2)
|
|
layer(art.hills, RUN_SPEED * 0.4, 260, 2)
|
|
layer(art.ground, RUN_SPEED, H - 96, 2)
|
|
local groundY = H - 96 - 64
|
|
local b = K.beat(t)
|
|
local jumpY = 0
|
|
|
|
-- Jumps on the backbeat: beats 2 and 4 of each bar, a beat long.
|
|
local whole = math.floor(b)
|
|
local p = b - whole
|
|
|
|
if (whole - (sync.barStart - 1)) % 2 == 1 then
|
|
local note = K.note(K.beatTime(whole))
|
|
|
|
jumpY = 4 * p * (1 - p) * (90 + note * 8)
|
|
end
|
|
-- Coins at the top of every jump, a beat's worth of ground apart.
|
|
for j = math.floor(b) - 2, math.floor(b) + 4 do
|
|
if (j - (sync.barStart - 1)) % 2 == 1 then
|
|
local apex = K.beatTime(j + 0.5)
|
|
local cx = RUNNER_X + 24 + (apex - t) * RUN_SPEED
|
|
local note = K.note(K.beatTime(j))
|
|
local cy = groundY - (90 + note * 8) + 8
|
|
|
|
if t < apex then
|
|
px(art.coin, 8, math.floor(t * 10) % 4, cx, cy, 4)
|
|
elseif not world.collected[j] then
|
|
world.collected[j] = true
|
|
world.runScore = world.runScore + 100
|
|
boom(RUNNER_X + 32, cy + 16, 30, 255, 230, 80)
|
|
end
|
|
end
|
|
end
|
|
local frame = (jumpY > 0) and 4 or (math.floor(b * 4) % 4)
|
|
|
|
px(art.runner, 16, frame, RUNNER_X, groundY - jumpY, 4)
|
|
ptext(20, 14, string.format("COINS %03d", world.runScore // 100), 2, 255, 255, 255)
|
|
ptext(W - 20 - 9 * 16, 14, string.format("WORLD 1-%d", math.floor(bar - 16) // 2 + 1), 2, 255, 255, 255)
|
|
emitterDraw(art.boom)
|
|
end,
|
|
}
|
|
|
|
-- 16-bit, the other trick: a kart on a flat track seen in perspective, the sky turning with it. It
|
|
-- laps once every four bars.
|
|
local function trackPoint(a)
|
|
return math.cos(a) * 144, math.sin(a) * 104
|
|
end
|
|
|
|
|
|
ERA.racer = {
|
|
enter = function(t)
|
|
hideAll()
|
|
nodeSetVisible(world.track, true)
|
|
nodeSetVisible(world.raceSky, true)
|
|
for _, r in ipairs(world.rivals) do
|
|
nodeSetVisible(r.node, true)
|
|
end
|
|
world.raceBase = t
|
|
end,
|
|
draw = function(t, bar, k)
|
|
local lap = (bar - 1) / 4
|
|
local a = lap * 2 * math.pi
|
|
local x, z = trackPoint(a)
|
|
local ax, az = trackPoint(a + 0.08)
|
|
|
|
nodeSetPosition(world.camera, x, 4.5, z)
|
|
nodeLookAt(world.camera, ax, 3.2, az)
|
|
nodeSetPosition(world.raceSky, x, 0, z)
|
|
for i, r in ipairs(world.rivals) do
|
|
local ra = a + r.offset + 0.02 * math.sin(t * 2 + i)
|
|
local rx, rz = trackPoint(ra)
|
|
|
|
local px2 = rx + math.sin(i * 2.1) * 6
|
|
local pz2 = rz + math.cos(i * 1.7) * 6
|
|
|
|
-- Turned square to the camera, level, so the picture is never seen edge on.
|
|
nodeSetPosition(r.node, px2, 1.4, pz2)
|
|
nodeLookAt(r.node, px2 + (px2 - x), 1.4, pz2 + (pz2 - z))
|
|
end
|
|
local lean = math.sin(a * 2) * 8
|
|
|
|
-- The player's kart, crisp, frame 1 of the sheet, sliding with the lean rather than turning.
|
|
px(art.kart, KART_W, 0, W / 2 - KART_W / 2 * KART_SCALE + lean * 2, H - 30 - KART_H * KART_SCALE, KART_SCALE)
|
|
ptext(20, 14, string.format("LAP %d/3", math.floor(lap) % 3 + 1), 3, 255, 255, 255)
|
|
ptext(W - 20 - 6 * 24, 14, string.format("POS %d", 6 - math.floor(lap * 1.5) % 5), 3, 255, 240, 80)
|
|
local laps = (t - world.raceBase)
|
|
|
|
ptext(W - 20 - 8 * 16, 48, string.format("%02d\"%02d", math.floor(laps) % 60, math.floor(laps * 100) % 100), 2, 255, 255, 255)
|
|
end,
|
|
leave = function(t)
|
|
end,
|
|
}
|
|
|
|
-- Loading: a tape loading, the border awash with stripes, a bar filling to the riser.
|
|
ERA.loading = {
|
|
enter = function(t)
|
|
hideAll()
|
|
end,
|
|
draw = function(t, bar, k)
|
|
local border = { { 20, 20, 120 }, { 255, 255, 80 }, { 60, 200, 200 }, { 200, 60, 200 }, { 255, 140, 60 }, { 255, 255, 255 }, { 40, 40, 40 } }
|
|
local seed = math.floor(t * 50)
|
|
local band = 3 + math.floor(6 * (1 - k))
|
|
|
|
for y = 0, H - 1, band do
|
|
local c = border[((seed * 31 + y * 7) // band) % #border + 1]
|
|
|
|
K.box(0, y, W - 1, y + band - 1, c[1], c[2], c[3], 255)
|
|
end
|
|
K.box(64, 56, W - 65, H - 57, 60, 60, 200, 255)
|
|
ptext(96, 90, "PRESS PLAY ON TAPE", 2, 160, 160, 255)
|
|
ptext(96, 130, "OK", 2, 160, 160, 255)
|
|
ptext(96, 170, "SEARCHING", 2, 160, 160, 255)
|
|
ptext(96, 210, "FOUND GAME", 2, 160, 160, 255)
|
|
ptext(96, 250, "LOADING", 2, 160, 160, 255)
|
|
if (t * 3) % 1 < 0.5 then
|
|
K.box(96 + 8 * 16, 250, 96 + 8 * 16 + 14, 265, 160, 160, 255, 255)
|
|
end
|
|
local fill = K.clamp(k, 0, 1)
|
|
|
|
K.box(96, 320, W - 97, 350, 20, 20, 90, 255)
|
|
K.box(98, 322, 98 + (W - 196 - 4) * fill, 348, 160, 160, 255, 255)
|
|
ptext(W / 2, 370, string.format("%3d%%", math.floor(fill * 100)), 2, 255, 255, 255, "center")
|
|
scanlines(120)
|
|
if bar > 40.6 then
|
|
K.flash(255, 255, 255, math.floor(255 * K.smooth(40.6, 41, bar)))
|
|
end
|
|
end,
|
|
}
|
|
|
|
-- 32-bit: a flat-shaded ship flying a winding path through hexagonal gates, one gate a beat. Each
|
|
-- gate stands on the path, facing along it and turning in its own plane, so the ship weaves through
|
|
-- them, banking into every turn; every position is snapped to a coarse grid as the machines of the
|
|
-- time snapped theirs. Enemy crystals wait along the path and burst on the snare.
|
|
local function snap(v)
|
|
return math.floor(v * 12 + 0.5) / 12
|
|
end
|
|
|
|
|
|
-- The path, s world units along it: across and up, and the depth, which is -s.
|
|
local function gatePath(s)
|
|
return 7 * math.sin(s * 0.045) + 3 * math.sin(s * 0.11 + 1), 3 + 2.5 * math.sin(s * 0.033 + 2), -s
|
|
end
|
|
|
|
|
|
-- Puts a pivot on the path at s, facing along it.
|
|
local function onPath(node, s)
|
|
local x, y, z = gatePath(s)
|
|
local ax, ay, az = gatePath(s + 2)
|
|
|
|
nodeSetPosition(node, snap(x), snap(y), snap(z))
|
|
nodeLookAt(node, ax, ay, az)
|
|
end
|
|
|
|
|
|
ERA.polygons = {
|
|
enter = function(t)
|
|
hideAll()
|
|
nodeSetVisible(world.shipPivot, true)
|
|
nodeSetVisible(world.polyFloor, true)
|
|
for _, n in ipairs(world.rings) do
|
|
nodeSetVisible(n, true)
|
|
end
|
|
lightSetIntensity(world.light, 3)
|
|
sceneSetFog(30, 10, 50, 10, 120)
|
|
sceneSetSkyIntensity(0)
|
|
world.enemyBase = K.beat(t)
|
|
end,
|
|
draw = function(t, bar, k)
|
|
local b = K.beat(t)
|
|
local s = b * RING_EVERY
|
|
local x0 = gatePath(s - 4)
|
|
local x1 = gatePath(s)
|
|
local x2 = gatePath(s + 4)
|
|
local bank = K.clamp((x0 - 2 * x1 + x2) * -60, -60, 60)
|
|
local cx, cy, cz = gatePath(s - 7)
|
|
local lx, ly, lz = gatePath(s + 12)
|
|
|
|
-- The ship on the path, banked into the turn; the camera on the path behind it.
|
|
onPath(world.shipPivot, s)
|
|
nodeSetRotation(world.ship, 0, 0, snap(bank))
|
|
nodeSetPosition(world.camera, snap(cx), snap(cy + 1.8), snap(cz))
|
|
nodeLookAt(world.camera, lx, ly + 0.6, lz)
|
|
nodeSetPosition(world.polyFloor, 0, -6, -s - (-s % 20))
|
|
local first = math.floor(b)
|
|
|
|
for i, n in ipairs(world.rings) do
|
|
local rb = first + i - 1
|
|
|
|
onPath(n, rb * RING_EVERY)
|
|
nodeSetRotation(world.gates[i], 90, rb * 15 + t * 40 * ((rb % 2 == 0) and 1 or -1), 0)
|
|
end
|
|
for i, m in ipairs(world.ringMaterials) do
|
|
local v = K.decay("kicks", t, 6)
|
|
local c = NEON[i]
|
|
|
|
materialSetEmissive(m, math.floor(c[1] * v), math.floor(c[2] * v), math.floor(c[3] * v))
|
|
end
|
|
if bar >= 49 then
|
|
for i, n in ipairs(world.enemies) do
|
|
local eb = math.floor(b) + i * 2
|
|
local ex, ey, ez = gatePath(eb * RING_EVERY + RING_EVERY / 2)
|
|
local alive = (eb + 0.5 - b) > 0.35
|
|
|
|
nodeSetVisible(n, alive)
|
|
nodeSetPosition(n, snap(ex + math.sin(eb * 1.7) * 2), snap(ey + math.cos(eb * 2.3) * 1.2), snap(ez))
|
|
nodeSetRotation(n, t * 90, t * 130, 0)
|
|
end
|
|
if K.hitCount("snares", lastT, t) > 0 then
|
|
local ax, ay, az = gatePath(s + 20)
|
|
|
|
nodeSetPosition(world.shardAnchor, ax, ay, az)
|
|
emitterBurst(world.shards, 80)
|
|
end
|
|
end
|
|
end,
|
|
}
|
|
|
|
-- Now: glass, chrome, and physics. A wall of glossy bricks under a dusk sky, and on every kick a
|
|
-- chrome ball thrown into it; the wall is built again at bar 69 and comes down harder.
|
|
local function buildWall()
|
|
for _, b in ipairs(world.bricks) do
|
|
bodyDelete(b)
|
|
nodeDelete(b)
|
|
end
|
|
for _, b in ipairs(world.balls) do
|
|
bodyDelete(b)
|
|
nodeDelete(b)
|
|
end
|
|
world.bricks = {}
|
|
world.balls = {}
|
|
for row = 0, BRICK_ROWS - 1 do
|
|
for col = 0, BRICK_COLS - 1 do
|
|
local n = nodeNew()
|
|
|
|
nodeSetMesh(n, world.brickMesh, world.brickMaterials[(row + col) % #world.brickMaterials + 1])
|
|
nodeSetPosition(n, (col - (BRICK_COLS - 1) / 2) * 1.25 + ((row % 2) * 0.62 - 0.31), 0.3 + row * 0.62, 0)
|
|
bodyNew(n, BODY_DYNAMIC, SHAPE_BOX, 1.2, 0.6, 0.6)
|
|
bodySetMass(n, 1)
|
|
bodySetFriction(n, 0.8)
|
|
world.bricks[#world.bricks + 1] = n
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
ERA.modern = {
|
|
enter = function(t)
|
|
hideAll()
|
|
nodeSetVisible(world.modernFloor, true)
|
|
lightSetIntensity(world.sun, 4)
|
|
sceneSetSkyIntensity(1.2)
|
|
buildWall()
|
|
world.wallBuilt = K.bar(t)
|
|
end,
|
|
draw = function(t, bar, k)
|
|
if bar >= 69 and world.wallBuilt < 69 then
|
|
buildWall()
|
|
world.wallBuilt = bar
|
|
end
|
|
local a = t * 0.12
|
|
|
|
nodeSetPosition(world.camera, math.sin(a) * 7, 2.4 + math.sin(t * 0.3) * 0.6, 7.5 + math.cos(a) * 2)
|
|
nodeLookAt(world.camera, 0, 1.6, 0)
|
|
if K.hitCount("kicks", lastT, t) > 0 and #world.balls < 40 then
|
|
local n = nodeNew()
|
|
local tx = (math.random() - 0.5) * 8
|
|
local ty = 0.5 + math.random() * 3
|
|
|
|
nodeSetMesh(n, world.ballMesh, world.ballMaterial)
|
|
nodeSetPosition(n, tx * 0.3, ty + 1.5, 14)
|
|
bodyNew(n, BODY_DYNAMIC, SHAPE_SPHERE, 0.45)
|
|
bodySetMass(n, (bar >= 69) and 8 or 4)
|
|
bodySetVelocity(n, tx * 0.9, 1.2, -32)
|
|
world.balls[#world.balls + 1] = n
|
|
end
|
|
end,
|
|
leave = function(t)
|
|
for _, b in ipairs(world.bricks) do
|
|
bodyDelete(b)
|
|
nodeDelete(b)
|
|
end
|
|
for _, b in ipairs(world.balls) do
|
|
bodyDelete(b)
|
|
nodeDelete(b)
|
|
end
|
|
world.bricks = {}
|
|
world.balls = {}
|
|
end,
|
|
}
|
|
|
|
-- Game over, and a countdown to continue, a number a bar, glitching, until the coin goes in.
|
|
ERA.continue = {
|
|
enter = function(t)
|
|
hideAll()
|
|
end,
|
|
draw = function(t, bar, k)
|
|
K.flash(0, 0, 0, 255)
|
|
local n = 8 - math.floor(bar - 73)
|
|
|
|
if bar < 74 then
|
|
ptext(W / 2, 200, "GAME OVER", 6, 255, 60, 60, "center")
|
|
else
|
|
ptext(W / 2, 80, "CONTINUE?", 4, 255, 255, 255, "center")
|
|
local pulse = 1 + 0.15 * K.decay("snares", t, 6)
|
|
local s = math.floor(18 * pulse)
|
|
local glitch = (math.random() < 0.15) and math.random(-20, 20) or 0
|
|
|
|
ptext(W / 2 + glitch, 170, tostring(math.max(1, n)), s, 255, 220, 60, "center")
|
|
for i = 1, 3 do
|
|
local y = math.random(0, H)
|
|
|
|
K.box(0, y, W - 1, y + math.random(1, 4), 255, 255, 255, math.random(20, 90))
|
|
end
|
|
end
|
|
if bar > 80.25 then
|
|
local p = K.smooth(80.25, 80.95, bar)
|
|
|
|
px(art.coin, 8, math.floor(t * 12) % 4, W / 2 - 32, K.lerp(-60, 380, p * p), 8)
|
|
end
|
|
ptext(W / 2, 430, "CREDIT 0", 3, 255, 220, 80, "center")
|
|
scanlines(160)
|
|
end,
|
|
}
|
|
|
|
-- The boss: a core and four turning arms with turrets, firing rings on the kick, spirals on the
|
|
-- hats, and aimed shots on the snare, while the ship below weaves and fires back. Its health runs
|
|
-- out over eight bars, and it goes up on the last.
|
|
ERA.boss = {
|
|
enter = function(t)
|
|
hideAll()
|
|
world.bullets = {}
|
|
world.bossHit = 0
|
|
end,
|
|
draw = function(t, bar, k)
|
|
K.plasmaUpdate(art.plasma, t)
|
|
spriteDraw(art.plasma.sprite, 0, 0, W - 1, H - 1)
|
|
K.flash(0, 0, 10, 200)
|
|
local dt = t - lastT
|
|
local cx = W / 2 + math.sin(t * 0.5) * 60
|
|
local cy = 150 + math.sin(t * 0.8) * 20
|
|
local spin = t * 40
|
|
local shipX = W / 2 + math.sin(t * 1.1) * 210 + math.sin(t * 2.7) * 40
|
|
local shipY = H - 70
|
|
local dying = bar >= 96
|
|
|
|
-- Stars streaming past.
|
|
for i = 1, 60 do
|
|
local sy = (i * 97 + t * (60 + (i % 5) * 50)) % H
|
|
local sx = (i * 173) % W
|
|
|
|
K.box(sx, sy, sx + 1, sy + 1 + (i % 3), 255, 255, 255, 120 + (i % 4) * 30)
|
|
end
|
|
if not dying then
|
|
if K.hitCount("kicks", lastT, t) > 0 then
|
|
for i = 1, 20 do
|
|
local a = i / 20 * 2 * math.pi + t
|
|
|
|
world.bullets[#world.bullets + 1] = { x = cx, y = cy, vx = math.cos(a) * 150, vy = math.sin(a) * 150, c = 1 }
|
|
end
|
|
end
|
|
if K.hitCount("hats", lastT, t) > 0 then
|
|
for arm = 0, 3 do
|
|
local a = math.rad(spin) + arm * math.pi / 2
|
|
|
|
world.bullets[#world.bullets + 1] = { x = cx + math.cos(a) * 110, y = cy + math.sin(a) * 110, vx = math.cos(a + 1.2) * 180, vy = math.sin(a + 1.2) * 180, c = 2 }
|
|
end
|
|
end
|
|
if K.hitCount("snares", lastT, t) > 0 then
|
|
for i = -1, 1 do
|
|
local a = math.atan(shipY - cy, shipX - cx) + i * 0.18
|
|
|
|
world.bullets[#world.bullets + 1] = { x = cx, y = cy, vx = math.cos(a) * 240, vy = math.sin(a) * 240, c = 3 }
|
|
end
|
|
end
|
|
end
|
|
-- The arms and turrets, then the core over them, flashing white while it is being hit.
|
|
local hitFlash = (math.floor(t * 30) % 2 == 0) and not dying
|
|
|
|
for arm = 0, 3 do
|
|
local a = spin + arm * 90
|
|
local ax = cx + math.cos(math.rad(a)) * 60
|
|
local ay = cy + math.sin(math.rad(a)) * 60
|
|
|
|
spriteRotate(art.bossArm, a)
|
|
spriteDraw(art.bossArm, math.floor(ax), math.floor(ay), true)
|
|
spriteDraw(art.turret, math.floor(cx + math.cos(math.rad(a)) * 110) - 14, math.floor(cy + math.sin(math.rad(a)) * 110) - 14)
|
|
end
|
|
spriteSetTint(art.bossCore, 255, hitFlash and 200 or 255, hitFlash and 200 or 255)
|
|
spriteRotateAndScale(art.bossCore, -spin * 0.5, 1.4 + 0.1 * K.decay("kicks", t, 8))
|
|
spriteDraw(art.bossCore, math.floor(cx), math.floor(cy), true)
|
|
spriteSetTint(art.bossCore, 255, 255, 255)
|
|
-- Bullets.
|
|
local tints = { { 255, 80, 220 }, { 80, 230, 255 }, { 255, 230, 80 } }
|
|
|
|
for i = #world.bullets, 1, -1 do
|
|
local bl = world.bullets[i]
|
|
|
|
bl.x = bl.x + bl.vx * dt
|
|
bl.y = bl.y + bl.vy * dt
|
|
if bl.x < -20 or bl.x > W + 20 or bl.y < -20 or bl.y > H + 20 then
|
|
table.remove(world.bullets, i)
|
|
else
|
|
local c = tints[bl.c]
|
|
|
|
spriteSetTint(art.bullet, c[1], c[2], c[3])
|
|
spriteDraw(art.bullet, math.floor(bl.x) - 6, math.floor(bl.y) - 6)
|
|
end
|
|
end
|
|
spriteSetTint(art.bullet, 255, 255, 255)
|
|
-- The ship and its fire.
|
|
for i = 0, 6 do
|
|
local fy = shipY - ((t * 900 + i * 70) % 420)
|
|
|
|
K.box(shipX - 2, fy, shipX + 2, fy + 12, 255, 255, 150, 255)
|
|
end
|
|
px(art.ship, 16, 0, shipX - 32, shipY - 32, 4)
|
|
-- Health.
|
|
local health = 1 - K.clamp((bar - 89) / 7, 0, 1)
|
|
|
|
K.box(120, 18, W - 121, 30, 60, 0, 0, 255)
|
|
K.box(122, 20, 122 + (W - 244 - 4) * health, 28, 255, 60, 60, 255)
|
|
ptext(W / 2, 36, "BOSS", 2, 255, 255, 255, "center")
|
|
if dying and K.hitCount("hats", lastT, t) > 0 then
|
|
boom(cx + math.random(-90, 90), cy + math.random(-90, 90), 90, NEON[math.random(1, 6)][1], NEON[math.random(1, 6)][2], 60)
|
|
end
|
|
emitterDraw(art.boom)
|
|
if bar > 96.85 then
|
|
K.flash(255, 255, 255, math.floor(255 * K.smooth(96.85, 97, bar)))
|
|
end
|
|
end,
|
|
leave = function(t)
|
|
spriteRotate(art.bossArm, 0)
|
|
spriteRotateAndScale(art.bossCore, 0, 1)
|
|
end,
|
|
}
|
|
|
|
-- The bonus stage: coins pour down further along the tunnel, COIN_RATE a beat, land on its floor, and the
|
|
-- tunnel's own motion carries them toward the camera and off the bottom of the screen; every coin
|
|
-- that rides past counts.
|
|
ERA.bonus = {
|
|
enter = function(t)
|
|
hideAll()
|
|
nodeSetVisible(world.tunnel, true)
|
|
nodeSetPosition(world.coinFloor, 0, -3.2, -50)
|
|
world.phase = 0
|
|
world.phaseT = t
|
|
world.bonusBase = t
|
|
world.dropped = 0
|
|
world.collected = 0
|
|
sceneSetSkyIntensity(1.4)
|
|
lightSetIntensity(world.sun, 3)
|
|
end,
|
|
draw = function(t, bar, k)
|
|
local push = 0.6 + 1.5 * K.decay("kicks", t, 5)
|
|
|
|
nodeSetPosition(world.camera, 0, 0.5, 14)
|
|
nodeLookAt(world.camera, 0, 0.5, -100)
|
|
world.phase = world.phase + (t - world.phaseT) * push
|
|
world.phaseT = t
|
|
K.tunnelUpdate(art.tunnel, (world.phase / TUNNEL_LAP) % 1, 0)
|
|
materialSetTexture(world.tunnelMaterial, art.tunnel.sprite)
|
|
nodeSetRotation(world.tunnel, 0, 0, t * 30)
|
|
local due = math.floor((K.beat(t) - K.beat(world.bonusBase)) * COIN_RATE)
|
|
|
|
while world.dropped < due do
|
|
local n = nodeNew()
|
|
|
|
world.dropped = world.dropped + 1
|
|
nodeSetMesh(n, world.coinMesh, world.coinMaterial)
|
|
for side = 1, 2 do
|
|
local face = nodeNew(n)
|
|
|
|
nodeSetMesh(face, world.faceMesh, world.faceMaterials[side])
|
|
nodeSetPosition(face, 0, (side == 1) and (COIN_THICK / 2 + 0.005) or -(COIN_THICK / 2 + 0.005), 0)
|
|
nodeSetRotation(face, 0, 0, (side == 1) and 0 or 180)
|
|
end
|
|
nodeSetPosition(n, (math.random() - 0.5) * 9, 7 + math.random() * 4, -30 - math.random() * 30)
|
|
nodeSetRotation(n, math.random(0, 360), math.random(0, 360), 0)
|
|
bodyNew(n, BODY_DYNAMIC, SHAPE_CYLINDER, COIN_RADIUS, COIN_THICK)
|
|
bodySetMass(n, 0.3)
|
|
bodySetBounce(n, 0.4)
|
|
bodySetAngularVelocity(n, (math.random() - 0.5) * 12, (math.random() - 0.5) * 12, 0)
|
|
world.coins[#world.coins + 1] = n
|
|
end
|
|
-- Coins on the floor ride the tunnel toward the camera at its speed; past the camera they go.
|
|
for i = #world.coins, 1, -1 do
|
|
local n = world.coins[i]
|
|
local x, y, z = nodeGetPosition(n)
|
|
local vx, vy, vz = bodyGetVelocity(n)
|
|
|
|
if z > 16 or #world.coins - i >= COIN_ALIVE then
|
|
bodyDelete(n)
|
|
nodeDelete(n)
|
|
table.remove(world.coins, i)
|
|
world.collected = world.collected + 1
|
|
elseif y < -1.8 then
|
|
bodySetVelocity(n, vx * 0.95, vy, COIN_BELT * push)
|
|
end
|
|
end
|
|
ptext(W / 2, 30, "BONUS STAGE", 4, 255, 230, 80, "center")
|
|
ptext(W / 2, 80, string.format("%07d", world.collected * 500), 4, 255, 255, 255, "center")
|
|
end,
|
|
leave = function(t)
|
|
for _, n in ipairs(world.coins) do
|
|
bodyDelete(n)
|
|
nodeDelete(n)
|
|
end
|
|
world.coins = {}
|
|
nodeSetPosition(world.coinFloor, 0, -200, 0)
|
|
end,
|
|
}
|
|
|
|
-- The high score table, which is also the credits.
|
|
ERA.scores = {
|
|
enter = function(t)
|
|
hideAll()
|
|
end,
|
|
draw = function(t, bar, k)
|
|
K.plasmaUpdate(art.plasma, t)
|
|
spriteDraw(art.plasma.sprite, 0, 0, W - 1, H - 1)
|
|
K.flash(0, 0, 20, 190)
|
|
rainbow(W / 2, 40, "HIGH SCORES", 4, t, "center")
|
|
-- Rows share the space under the title and appear one after another over the first half of the
|
|
-- scene, however many there are.
|
|
local count = #HIGH_SCORES
|
|
local top = 110
|
|
local spacing = math.min(52, (H - 40 - top) / math.max(count, 1))
|
|
local scale = (spacing >= 34) and 3 or 2
|
|
|
|
for i, row in ipairs(HIGH_SCORES) do
|
|
if bar >= 113 + (i - 1) * 4 / count then
|
|
local y = top + (i - 1) * spacing
|
|
local name = tostring(row[1])
|
|
local fit = math.floor((W - 330 - 16) / (8 * scale))
|
|
local size = (#name > fit) and math.max(1, scale - 1) or scale
|
|
|
|
ptext(40, y, ordinal(i), scale, 255, 255, 255)
|
|
ptext(140, y, string.format("%06d", math.floor(row[2] or 0) % 1000000), scale, 255, 230, 80)
|
|
rainbow(330, y, name:upper(), size, t + i * 0.3)
|
|
end
|
|
end
|
|
scanlines(120)
|
|
end,
|
|
leave = function(t)
|
|
end,
|
|
}
|
|
|
|
-- The end: game over, thanks, and the set switched off on the last hit.
|
|
ERA.outro = {
|
|
enter = function(t)
|
|
hideAll()
|
|
end,
|
|
draw = function(t, bar, k)
|
|
K.flash(0, 0, 0, 255)
|
|
local p = t - K.barTime(137)
|
|
local last = K.duration - 0.9
|
|
|
|
if t < last then
|
|
ptext(W / 2, 140, "GAME OVER", 7, 255, 60, 60, "center")
|
|
if p > 2 then
|
|
rainbow(W / 2, 260, "THANKS FOR PLAYING", 3, t, "center")
|
|
end
|
|
if p > 4 and (t * 2) % 1 < 0.5 then
|
|
ptext(W / 2, 340, "PRESS START", 3, 255, 255, 255, "center")
|
|
end
|
|
scanlines(160)
|
|
else
|
|
local q = (t - last) / 0.35
|
|
|
|
if q < 1 then
|
|
local h = math.max(2, H * (1 - q) ^ 3)
|
|
local w = (q < 0.6) and W or W * (1 - (q - 0.6) / 0.4)
|
|
|
|
K.box(W / 2 - w / 2, H / 2 - h / 2, W / 2 + w / 2, H / 2 + h / 2, 255, 255, 255, 255)
|
|
end
|
|
end
|
|
end,
|
|
}
|
|
|
|
-- Vector: wireframe asteroids drifting in the dark, glowing as a vector monitor's beam glowed. On
|
|
-- every snare the ship turns to the biggest rock, fires, and the rock splits in two as the shot
|
|
-- reaches it, down to the smallest, which bursts; on every hat it sprays a shot the way it faces.
|
|
-- The ship turns to face its mark before it fires, rather than snapping round.
|
|
local function vecSpawn(size, x, y)
|
|
for _, a in ipairs(world.asteroids) do
|
|
if a.size == size and not a.alive then
|
|
local angle = math.random() * 2 * math.pi
|
|
local pace = (4 + size * 1.5) * (0.6 + math.random() * 0.8)
|
|
|
|
a.alive = true
|
|
a.x = x
|
|
a.y = y
|
|
a.vx = math.cos(angle) * pace / size
|
|
a.vy = math.sin(angle) * pace / size
|
|
a.spin = (math.random() - 0.5) * 120
|
|
a.angle = math.random() * 360
|
|
nodeSetVisible(a.node, true)
|
|
return a
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
local function vecRefill()
|
|
local left = 0
|
|
|
|
for _, a in ipairs(world.asteroids) do
|
|
if a.alive then
|
|
left = left + 1
|
|
end
|
|
end
|
|
if left < 3 then
|
|
for i = 1, 4 do
|
|
local side = (i % 2 == 0) and 1 or -1
|
|
|
|
vecSpawn(1, side * VEC_HALF_W, (math.random() - 0.5) * VEC_HALF_H * 2)
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
local function vecFire(x, y, angle, speed, target)
|
|
for _, s in ipairs(world.vecShots) do
|
|
if not s.alive then
|
|
s.alive = true
|
|
s.x = x
|
|
s.y = y
|
|
s.vx = math.cos(math.rad(angle)) * speed
|
|
s.vy = math.sin(math.rad(angle)) * speed
|
|
s.life = 0.9
|
|
s.target = target
|
|
nodeSetVisible(s.node, true)
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
ERA.vector = {
|
|
enter = function(t)
|
|
hideAll()
|
|
nodeSetVisible(world.vecField, true)
|
|
for _, a in ipairs(world.asteroids) do
|
|
a.alive = false
|
|
nodeSetVisible(a.node, false)
|
|
end
|
|
for _, s in ipairs(world.vecShots) do
|
|
s.alive = false
|
|
nodeSetVisible(s.node, false)
|
|
end
|
|
vecRefill()
|
|
world.vecAngle = 90
|
|
world.vecPending = nil
|
|
world.vecScore = 0
|
|
sceneSetSkyIntensity(0)
|
|
sceneSetBloom(0.4, 0.9)
|
|
end,
|
|
draw = function(t, bar, k)
|
|
local dt = math.max(0, t - lastT)
|
|
|
|
nodeSetPosition(world.camera, 0, 0, 40)
|
|
nodeLookAt(world.camera, 0, 0, 0)
|
|
for _, a in ipairs(world.asteroids) do
|
|
if a.alive then
|
|
a.x = (a.x + a.vx * dt + VEC_HALF_W) % (VEC_HALF_W * 2) - VEC_HALF_W
|
|
a.y = (a.y + a.vy * dt + VEC_HALF_H) % (VEC_HALF_H * 2) - VEC_HALF_H
|
|
a.angle = a.angle + a.spin * dt
|
|
nodeSetPosition(a.node, a.x, a.y, 0)
|
|
nodeSetRotation(a.node, 0, 0, a.angle)
|
|
end
|
|
end
|
|
-- On a snare, the ship picks the biggest rock and turns toward it; it fires once it faces it.
|
|
if K.hitCount("snares", lastT, t) > 0 then
|
|
local best = nil
|
|
|
|
for _, a in ipairs(world.asteroids) do
|
|
if a.alive and (best == nil or a.size < best.size) then
|
|
best = a
|
|
end
|
|
end
|
|
world.vecPending = best
|
|
end
|
|
if world.vecPending and not world.vecPending.alive then
|
|
world.vecPending = nil
|
|
end
|
|
if world.vecPending then
|
|
local want = math.deg(math.atan(world.vecPending.y, world.vecPending.x))
|
|
local diff = (want - world.vecAngle + 540) % 360 - 180
|
|
local step = VEC_TURN * dt
|
|
|
|
world.vecAngle = world.vecAngle + K.clamp(diff, -step, step)
|
|
if math.abs(diff) <= step then
|
|
vecFire(0, 0, world.vecAngle, 80, world.vecPending)
|
|
world.vecPending = nil
|
|
end
|
|
end
|
|
if K.hitCount("hats", lastT, t) > 0 then
|
|
vecFire(0, 0, world.vecAngle + (math.random() - 0.5) * 30, 60, nil)
|
|
end
|
|
for _, s in ipairs(world.vecShots) do
|
|
if s.alive then
|
|
s.life = s.life - dt
|
|
if s.target then
|
|
-- The aimed shot homes on its rock and splits it on arrival.
|
|
local dx = s.target.x - s.x
|
|
local dy = s.target.y - s.y
|
|
local d = math.sqrt(dx * dx + dy * dy)
|
|
|
|
if d < VEC_SIZES[s.target.size] or not s.target.alive then
|
|
if s.target.alive then
|
|
s.target.alive = false
|
|
nodeSetVisible(s.target.node, false)
|
|
nodeSetPosition(world.vecBurstAt, s.target.x, s.target.y, 0)
|
|
emitterBurst(world.vecBurst, 40 * (4 - s.target.size))
|
|
world.vecScore = world.vecScore + ({ 20, 50, 100 })[s.target.size]
|
|
if s.target.size < 3 then
|
|
vecSpawn(s.target.size + 1, s.target.x, s.target.y)
|
|
vecSpawn(s.target.size + 1, s.target.x, s.target.y)
|
|
end
|
|
end
|
|
s.alive = false
|
|
else
|
|
s.vx = dx / d * 80
|
|
s.vy = dy / d * 80
|
|
end
|
|
end
|
|
s.x = s.x + s.vx * dt
|
|
s.y = s.y + s.vy * dt
|
|
if s.life <= 0 or math.abs(s.x) > VEC_HALF_W or math.abs(s.y) > VEC_HALF_H then
|
|
s.alive = false
|
|
end
|
|
nodeSetVisible(s.node, s.alive)
|
|
nodeSetPosition(s.node, s.x, s.y, 0)
|
|
end
|
|
end
|
|
vecRefill()
|
|
nodeSetRotation(world.vecShip, 0, 0, world.vecAngle)
|
|
nodeSetVisible(world.vecFlame, K.decay("kicks", t, 10) > 0.3)
|
|
ptext(30, 20, string.format("%05d", world.vecScore), 3, 210, 235, 255)
|
|
end,
|
|
leave = function(t)
|
|
sceneSetBloom(0.8, 0.5)
|
|
end,
|
|
}
|
|
|
|
-- Light gun: a shooting gallery, the cabinets Singe has always played. Bullseyes slide along three
|
|
-- rails; the crosshair glides to the nearest and, on every snare, the screen flashes white the way a
|
|
-- light gun's cabinet flashes to find where the gun points, and the target spins away, hit.
|
|
ERA.gallery = {
|
|
enter = function(t)
|
|
hideAll()
|
|
world.gallery = { cx = W / 2, cy = H / 2, aim = nil, falling = {}, hits = 0, flash = -10, shot = {} }
|
|
end,
|
|
draw = function(t, bar, k)
|
|
local g = world.gallery
|
|
local dt = math.max(0, t - lastT)
|
|
local b = K.beat(t)
|
|
local live = {}
|
|
|
|
-- The backboard: planks, a banner, and the rails.
|
|
for x = 0, W, 48 do
|
|
local shade = (x // 48) % 2 == 0 and 0 or 12
|
|
|
|
K.box(x, 0, x + 47, H - 1, 120 + shade, 70 + shade, 40 + shade // 2, 255)
|
|
K.box(x + 46, 0, x + 47, H - 1, 70, 40, 20, 255)
|
|
end
|
|
K.box(0, 0, W - 1, 56, 120, 20, 30, 255)
|
|
ptext(W / 2, 16, "SHOOTING GALLERY", 3, 255, 230, 120, "center")
|
|
-- The targets on each rail, sliding across a RAIL_GAP apart and round again; a hit one is gone
|
|
-- from its rail until it comes round the next time.
|
|
for rail, y in ipairs(RAILS) do
|
|
local dir = (rail % 2 == 1) and 1 or -1
|
|
local travel = (b / RAIL_BEATS) * (W + RAIL_GAP)
|
|
|
|
K.box(0, y + 70, W - 1, y + 78, 60, 35, 20, 255)
|
|
local period = (math.ceil(W / RAIL_GAP) + 2) * RAIL_GAP
|
|
|
|
for slot = 0, math.ceil(W / RAIL_GAP) + 1 do
|
|
local along = travel + slot * RAIL_GAP
|
|
local x = along % period - RAIL_GAP
|
|
local id = rail .. ":" .. slot .. ":" .. math.floor(along / period)
|
|
|
|
if dir < 0 then
|
|
x = W - x
|
|
end
|
|
if not g.shot[id] and x > -40 and x < W + 40 then
|
|
spriteDraw(art.target, math.floor(x - 32), y - 20)
|
|
live[#live + 1] = { id = id, x = x, y = y + 12 }
|
|
end
|
|
end
|
|
end
|
|
-- The crosshair glides toward the target nearest it that is well on screen.
|
|
local best = nil
|
|
local bestD = math.huge
|
|
|
|
for _, tg in ipairs(live) do
|
|
if tg.x > 60 and tg.x < W - 60 then
|
|
local d = (tg.x - g.cx) ^ 2 + (tg.y - g.cy) ^ 2
|
|
|
|
if d < bestD then
|
|
best = tg
|
|
bestD = d
|
|
end
|
|
end
|
|
end
|
|
if best then
|
|
local ease = math.min(1, dt * 10)
|
|
|
|
g.cx = g.cx + (best.x - g.cx) * ease
|
|
g.cy = g.cy + (best.y - g.cy) * ease
|
|
end
|
|
-- A snare fires: the light gun's white flash, and whatever is under the crosshair is hit.
|
|
if K.hitCount("snares", lastT, t) > 0 then
|
|
g.flash = t
|
|
if best and math.abs(best.x - g.cx) < 40 and math.abs(best.y - g.cy) < 40 then
|
|
g.shot[best.id] = true
|
|
g.hits = g.hits + 1
|
|
g.falling[#g.falling + 1] = { x = best.x, y = best.y - 12, vx = (math.random() - 0.5) * 120, vy = -220, spin = 0, t = t }
|
|
boom(best.x, best.y, 30, 255, 220, 120)
|
|
end
|
|
end
|
|
-- Hit targets spin and fall: one picture, turned.
|
|
for i = #g.falling, 1, -1 do
|
|
local f = g.falling[i]
|
|
|
|
f.vy = f.vy + 900 * dt
|
|
f.x = f.x + f.vx * dt
|
|
f.y = f.y + f.vy * dt
|
|
f.spin = f.spin + 900 * dt
|
|
if f.y > H + 80 then
|
|
table.remove(g.falling, i)
|
|
else
|
|
spriteRotate(art.target, math.floor(f.spin) % 360)
|
|
spriteDraw(art.target, math.floor(f.x), math.floor(f.y), true)
|
|
end
|
|
end
|
|
spriteRotate(art.target, 0)
|
|
emitterDraw(art.boom)
|
|
-- The crosshair.
|
|
local cx = math.floor(g.cx)
|
|
local cy = math.floor(g.cy)
|
|
|
|
colorForeground(255, 40, 40, 255)
|
|
overlayCircle(cx, cy, 26)
|
|
overlayCircle(cx, cy, 27)
|
|
K.box(cx - 40, cy - 1, cx - 12, cy + 1, 255, 40, 40, 255)
|
|
K.box(cx + 12, cy - 1, cx + 40, cy + 1, 255, 40, 40, 255)
|
|
K.box(cx - 1, cy - 40, cx + 1, cy - 12, 255, 40, 40, 255)
|
|
K.box(cx - 1, cy + 12, cx + 1, cy + 40, 255, 40, 40, 255)
|
|
ptext(20, H - 40, string.format("HITS %03d", g.hits), 3, 255, 255, 255)
|
|
if t - g.flash < 0.05 then
|
|
K.flash(255, 255, 255, 255)
|
|
end
|
|
scanlines(90)
|
|
end,
|
|
}
|
|
|
|
-- Versus: two fighters face off. ROUND 1 and FIGHT! land on the first bar's downbeats; then the
|
|
-- red fighter strikes on every kick drum and the blue on every snare, each hit knocking the other's
|
|
-- health down, with sparks and a shake, until the last beat puts blue down for good.
|
|
local function fighterDraw(sprite, frame, x, y)
|
|
px(sprite, FIGHTER_FRAME, frame, x, y, FIGHTER_SCALE)
|
|
end
|
|
|
|
|
|
ERA.fighter = {
|
|
enter = function(t)
|
|
hideAll()
|
|
world.fight = { red = 1, blue = 1, redPose = 0, bluePose = 0, redUntil = 0, blueUntil = 0, shake = 0, start = K.bar(t) }
|
|
end,
|
|
draw = function(t, bar, k)
|
|
local f = world.fight
|
|
local into = bar - f.start
|
|
local ox = 0
|
|
local oy = 0
|
|
local floor = H - 50
|
|
local ko = into >= 7.5
|
|
|
|
-- The stage: a dusk sky in bands, far hills, and a floor.
|
|
for i = 0, 9 do
|
|
K.box(0, i * 33, W - 1, i * 33 + 32, 60 + i * 14, 30 + i * 8, 90 + i * 6, 255)
|
|
end
|
|
spriteDraw(art.hills, 0, floor - 150, W - 1, floor - 30)
|
|
K.box(0, floor - 30, W - 1, H - 1, 90, 60, 50, 255)
|
|
for x = 0, W, 64 do
|
|
K.box(x, floor - 30, x + 2, H - 1, 70, 45, 38, 255)
|
|
end
|
|
if into >= 1 and not ko then
|
|
if K.hitCount("kicks", lastT, t) > 0 then
|
|
f.redPose = (math.random() < 0.5) and 1 or 2
|
|
f.redUntil = t + 0.18
|
|
f.bluePose = 3
|
|
f.blueUntil = t + 0.25
|
|
f.blue = math.max(0.06, f.blue - FIGHT_DAMAGE)
|
|
f.shake = t
|
|
boom(W / 2 + 10, floor - 120, 40, 255, 230, 80)
|
|
end
|
|
if K.hitCount("snares", lastT, t) > 0 then
|
|
f.bluePose = (math.random() < 0.5) and 1 or 2
|
|
f.blueUntil = t + 0.18
|
|
f.redPose = 3
|
|
f.redUntil = t + 0.25
|
|
f.red = math.max(0.06, f.red - FIGHT_DAMAGE * 0.8)
|
|
f.shake = t
|
|
boom(W / 2 - 10, floor - 120, 40, 120, 200, 255)
|
|
end
|
|
end
|
|
if ko then
|
|
f.blue = 0
|
|
f.bluePose = 3
|
|
f.blueUntil = t + 1
|
|
end
|
|
if t - f.shake < 0.12 then
|
|
ox = math.random(-6, 6)
|
|
oy = math.random(-4, 4)
|
|
end
|
|
local bob = math.floor(2 * math.sin(K.beat(t) * math.pi * 2))
|
|
|
|
fighterDraw(art.fighterA, (t < f.redUntil) and f.redPose or 0, W / 2 - 150 + ox, floor - 48 * FIGHTER_SCALE + oy + ((t < f.redUntil) and 0 or bob))
|
|
fighterDraw(art.fighterB, (t < f.blueUntil) and f.bluePose or 0, W / 2 - 10 + ox, floor - 48 * FIGHTER_SCALE + oy + ((t < f.blueUntil) and 0 or bob))
|
|
emitterDraw(art.boom)
|
|
-- Health, names, and the clock.
|
|
K.box(20, 20, 270, 38, 60, 0, 0, 255)
|
|
K.box(22, 22, 22 + 246 * f.red, 36, 255, 220, 40, 255)
|
|
K.box(W - 271, 20, W - 21, 38, 60, 0, 0, 255)
|
|
K.box(W - 23 - 246 * f.blue, 22, W - 23, 36, 255, 220, 40, 255)
|
|
ptext(20, 44, "P1", 2, 255, 120, 120)
|
|
ptext(W - 20 - 3 * 16, 44, "CPU", 2, 140, 180, 255)
|
|
ptext(W / 2, 18, string.format("%02d", math.max(0, 99 - math.floor(K.beat(t) - K.beat(K.barTime(f.start))))), 3, 255, 255, 255, "center")
|
|
if into < 0.5 then
|
|
ptext(W / 2, 160, "ROUND 1", 7, 255, 230, 80, "center")
|
|
elseif into < 1 then
|
|
ptext(W / 2, 150, "FIGHT!", 9, 255, 80, 60, "center")
|
|
elseif ko then
|
|
ptext(W / 2, 150, "K.O.", 10, 255, 60, 60, "center")
|
|
end
|
|
scanlines(110)
|
|
end,
|
|
}
|
|
|
|
-- Rhythm: a note highway whose notes are the song's own drums, read out of the music: kicks,
|
|
-- snares, and hats each in their lane, scrolling down to the hit line and landing on it exactly as
|
|
-- the drum sounds. Every hit is PERFECT, of course: the music is playing itself.
|
|
local function rhythmY(ahead)
|
|
local s = 1 / (1 + 3 * ahead / RHYTHM_LOOK)
|
|
|
|
return RHYTHM_TOP + (RHYTHM_HIT - RHYTHM_TOP) * s, s
|
|
end
|
|
|
|
|
|
ERA.rhythm = {
|
|
enter = function(t)
|
|
hideAll()
|
|
world.rhythm = { score = 0, combo = 0, flash = { -10, -10, -10 }, words = {} }
|
|
end,
|
|
draw = function(t, bar, k)
|
|
local r = world.rhythm
|
|
|
|
K.plasmaUpdate(art.plasma, t)
|
|
spriteDraw(art.plasma.sprite, 0, 0, W - 1, H - 1)
|
|
K.flash(0, 0, 20, 200)
|
|
-- The highway, a row at a time, narrowing to the horizon, and the beat lines rushing down it.
|
|
for y = RHYTHM_TOP, H - 1, 2 do
|
|
local s = (y - RHYTHM_TOP) / (RHYTHM_HIT - RHYTHM_TOP)
|
|
local half = RHYTHM_SPAN * 1.7 * s
|
|
|
|
K.box(W / 2 - half, y, W / 2 + half, y + 1, 20, 14, 40, 230)
|
|
end
|
|
local b = K.beat(t)
|
|
|
|
for i = math.ceil(b), math.floor(b) + 5 do
|
|
local ahead = K.beatTime(i) - t
|
|
|
|
if ahead >= 0 and ahead <= RHYTHM_LOOK then
|
|
local y, s = rhythmY(ahead)
|
|
|
|
K.box(W / 2 - RHYTHM_SPAN * 1.7 * s, y, W / 2 + RHYTHM_SPAN * 1.7 * s, y + math.max(1, 3 * s), 90, 70, 140, 255)
|
|
end
|
|
end
|
|
-- The notes: each lane's drum hits in the next RHYTHM_LOOK seconds.
|
|
for lane, info in ipairs(RHYTHM_LANES) do
|
|
local offset = (lane - 2) * RHYTHM_SPAN
|
|
local c = info.rgb
|
|
|
|
for _, hit in ipairs(sync[info.list]) do
|
|
local ahead = hit - t
|
|
|
|
if ahead > RHYTHM_LOOK then
|
|
break
|
|
end
|
|
if ahead >= 0 then
|
|
local y, s = rhythmY(ahead)
|
|
local x = W / 2 + offset * s
|
|
local w = 46 * s
|
|
local h = 14 * s
|
|
|
|
K.box(x - w, y - h, x + w, y + h, c[1], c[2], c[3], 255)
|
|
K.box(x - w, y - h, x + w, y - h * 0.4, 255, 255, 255, 160)
|
|
end
|
|
end
|
|
-- A hit since last frame: the target flashes, sparks fly, and a word pops up.
|
|
if K.hitCount(info.list, lastT, t) > 0 then
|
|
r.flash[lane] = t
|
|
r.combo = r.combo + 1
|
|
r.score = r.score + 100 + 10 * math.min(r.combo, 50)
|
|
r.words[lane] = t
|
|
boom(W / 2 + offset, RHYTHM_HIT, 25, c[1], c[2], c[3])
|
|
end
|
|
local glow = math.exp(-(t - r.flash[lane]) * 12)
|
|
local x = W / 2 + offset
|
|
|
|
K.box(x - 50, RHYTHM_HIT - 18, x + 50, RHYTHM_HIT + 18, c[1], c[2], c[3], math.floor(60 + 195 * glow))
|
|
K.box(x - 44, RHYTHM_HIT - 12, x + 44, RHYTHM_HIT + 12, 10, 10, 20, math.floor(200 * (1 - glow)))
|
|
ptext(x, H - 40, info.label, 2, c[1], c[2], c[3], "center")
|
|
end
|
|
K.box(W / 2 - RHYTHM_SPAN * 1.7, RHYTHM_HIT - 1, W / 2 + RHYTHM_SPAN * 1.7, RHYTHM_HIT + 1, 255, 255, 255, 200)
|
|
emitterDraw(art.boom)
|
|
-- The latest hit's word in each lane, rising and gone in under half a second.
|
|
for lane, when in pairs(r.words) do
|
|
local age = t - when
|
|
|
|
if age < 0.4 then
|
|
local c = RHYTHM_LANES[lane].rgb
|
|
|
|
ptext(W / 2 + (lane - 2) * RHYTHM_SPAN, RHYTHM_HIT - 60 - age * 80, "PERFECT", 2, c[1], c[2], c[3], "center")
|
|
end
|
|
end
|
|
ptext(20, 20, string.format("SCORE %07d", r.score), 2, 255, 255, 255)
|
|
ptext(W - 20 - 10 * 16, 20, string.format("COMBO %4d", r.combo), 2, 255, 230, 80)
|
|
scanlines(90)
|
|
end,
|
|
}
|
|
|
|
-- The finale runs the eras again, a bar each, every one on its own beat; its time is moved by whole
|
|
-- bars, so the beat lands where it did. The last two bars are GAME ON.
|
|
local MONTAGE = {
|
|
{ era = "pong", bar = 6, label = "ANALOG" }, { era = "invaders", bar = 12, label = "8-BIT" },
|
|
{ era = "runner", bar = 19, label = "16-BIT" }, { era = "racer", bar = 27, label = "16-BIT" },
|
|
{ era = "loading", bar = 37, label = "TAPE" }, { era = "vector", bar = 44, label = "VECTOR" },
|
|
{ era = "polygons", bar = 52, label = "32-BIT" }, { era = "gallery", bar = 59, label = "LIGHT GUN" },
|
|
{ era = "modern", bar = 67, label = "NOW" }, { era = "continue", bar = 76, label = "CONTINUE" },
|
|
{ era = "fighter", bar = 84, label = "VERSUS" }, { era = "boss", bar = 92, label = "BOSS" },
|
|
{ era = "bonus", bar = 100, label = "BONUS" }, { era = "rhythm", bar = 108, label = "RHYTHM" },
|
|
}
|
|
|
|
ERA.montage = {
|
|
enter = function(t)
|
|
world.montageCut = 0
|
|
end,
|
|
draw = function(t, bar, k)
|
|
local cut = math.floor(bar - 121) + 1
|
|
local final = cut > #MONTAGE
|
|
local m = MONTAGE[math.min(cut, #MONTAGE)]
|
|
|
|
if final then
|
|
-- The last two bars: the name, slammed in on the downbeat, over everything flashing.
|
|
if world.montageCut ~= cut then
|
|
if world.montageEra and ERA[world.montageEra].leave then
|
|
ERA[world.montageEra].leave(t)
|
|
end
|
|
world.montageEra = nil
|
|
world.montageCut = cut
|
|
hideAll()
|
|
end
|
|
local r, g, b = K.hue(t * 0.7)
|
|
|
|
K.flash(r // 3, g // 3, b // 3, 255)
|
|
local s = 10 + 4 * math.exp(-(bar - 135) * 6)
|
|
|
|
rainbow(W / 2, 170, "GAME ON", math.floor(s), t, "center")
|
|
K.strobe(t, "kicks", 12, 255, 255, 255, 120)
|
|
scanlines(160)
|
|
return
|
|
end
|
|
local shift = K.barTime(m.bar) - K.barTime(121 + cut - 1)
|
|
local et = t + shift
|
|
|
|
if world.montageCut ~= cut then
|
|
if world.montageEra and ERA[world.montageEra].leave then
|
|
ERA[world.montageEra].leave(t)
|
|
end
|
|
world.montageEra = m.era
|
|
world.montageCut = cut
|
|
ERA[m.era].enter(et)
|
|
end
|
|
local saved = lastT
|
|
|
|
lastT = saved + shift
|
|
ERA[m.era].draw(et, K.bar(et), 0)
|
|
lastT = saved
|
|
ptext(W - 20 - #m.label * 24, H - 40, m.label, 3, 255, 255, 255)
|
|
K.flash(255, 255, 255, math.floor(220 * math.exp(-((bar - 121) % 1) * 1.7778 * 10)))
|
|
end,
|
|
leave = function(t)
|
|
if world.montageEra and ERA[world.montageEra].leave then
|
|
ERA[world.montageEra].leave(t)
|
|
end
|
|
end,
|
|
}
|
|
|
|
|
|
local function place(era, from, to)
|
|
return { from = from, to = to, enter = ERA[era].enter, draw = ERA[era].draw, leave = ERA[era].leave }
|
|
end
|
|
|
|
|
|
local SCENES = {
|
|
place("boot", 0, 5),
|
|
place("pong", 5, 9),
|
|
place("invaders", 9, 17),
|
|
place("runner", 17, 25),
|
|
place("racer", 25, 33),
|
|
place("loading", 33, 41),
|
|
place("vector", 41, 49),
|
|
place("polygons", 49, 57),
|
|
place("gallery", 57, 65),
|
|
place("modern", 65, 73),
|
|
place("continue", 73, 81),
|
|
place("fighter", 81, 89),
|
|
place("boss", 89, 97),
|
|
place("bonus", 97, 105),
|
|
place("rhythm", 105, 113),
|
|
place("scores", 113, 121),
|
|
-- Over every era until the recap, in the front layer so it stands over 2D and 3D alike.
|
|
{
|
|
from = 1, to = 121,
|
|
draw = function(t, bar, k)
|
|
K.scroller3D(t, art.blocks, FEATURE_TEXT, H * 0.5, K.barTime(1), K.barTime(121))
|
|
end,
|
|
leave = function(t)
|
|
K.scroller3DStop(art.blocks)
|
|
end,
|
|
},
|
|
place("montage", 121, 137),
|
|
place("outro", 137, 200),
|
|
-- The time at the end of each frame, for "what happened since last frame".
|
|
{
|
|
from = -10, to = 1000,
|
|
draw = function(t, bar, k)
|
|
lastT = t
|
|
end,
|
|
},
|
|
}
|
|
|
|
-- A ball meeting a brick throws sparks where they met.
|
|
function onCollision(a, b, x, y, z, speed)
|
|
if speed > 4 then
|
|
nodeSetPosition(world.spark, x, y, z)
|
|
emitterBurst(world.sparks, math.min(60, math.floor(speed * 3)))
|
|
end
|
|
end
|
|
|
|
loadArt()
|
|
buildWorld()
|
|
hideAll()
|
|
K.run(SCENES)
|