-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain.lua
583 lines (507 loc) · 19.4 KB
/
main.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
-- TODO:
-- add header comments to all functions describing what they do in "plane" english ☜(゚ヮ゚☜)
-- only move vertically when hitting aircurrents (replace 'faux' movement)
-- better target spawning patterns
-- success particle fx when you collide with targets
-- FURTHER IDEAS:
-- day night/cycle for the background
-- wobble on the plane
-- add clouds that you pass by (very top of the perspective plane) just for visual flair
-- add trees to the map for obstacle dodging
-- target tweaks:
-- 1. add some sort of "fog" effect
-- 2. make the targets start closer in the fog and move more slowly towards you
-- GLOBAL CONSTANTS
-- the z coordinate of where the screen/near-plane lives
c_target_spawn_z = -50
c_target_despawn_z = 5
c_eye_z = 7
c_pico_8_screen_size = 128
c_target_speed = 1/4
-- these 'perspective weights' help us fudge our perspective view so it's not as strong and reads more easily. A higher weight value means more perspective distortion. A lower weight value means less perspective distortion. A weight value of 0 means no distortion (i.e. everything is rendered flatly)
c_perspective_pos_weight = 0.85
c_perspective_size_weight = 0.80
-- N.B. constrain a number of gameplay elements to only happen within some border of the game window.
-- FIXME NOW: we should update our sprite selection to take into account these new boundaries
c_gameplay_boundaries = { left = 10, right = 118, top = 10, bottom = 118 }
c_lanes = {
top = 20,
mid = 64,
bottom = 108
}
function get_spritesheet_pos(sprite_n)
return {
x = (sprite_n % 16) * 8,
y = (sprite_n \ 16) * 8
}
end
-- GLOBAL VARIABLES
g_input = nil
g_plane_pos = nil
g_plane_vertical_slide = nil
g_plane_size = { width = 16, height = 16 }
g_plane_sprites = {
-- contains a separate table for each 'lane' that the plane can fly in
-- each lane contains 3 sprite indices: 1 for the far left, one for left, and one for center
-- the right side is done by flipping the left sprites
top_lane = {
outside = {
origin = { x = 8, y = 0 },
nose_offset = { x = 10, y = 12 }
},
leaning = {
origin = { x = 24, y = 0 },
nose_offset = { x = 9, y = 11 }
},
center = {
origin = { x = 40, y = 0 },
nose_offset = { x = 7, y = 11 }
},
},
mid_lane= {
outside = {
origin = { x = 56, y = 0 },
nose_offset = { x = 13, y = 8 }
},
leaning = {
origin = { x = 72, y = 0 },
nose_offset = { x = 13, y = 8 }
},
center = {
origin = { x = 88, y = 0 },
nose_offset = { x = 7, y = 8 }
},
},
bottom_lane = {
outside = {
origin = { x = 8, y = 16 },
nose_offset = { x = 15, y = 0 }
},
leaning = {
origin = { x = 24, y = 16 },
nose_offset = { x = 10, y = 0 }
},
center = {
origin = { x = 40, y = 16 },
nose_offset = { x = 7, y = 0 }
},
},
}
g_score = 0
g_target_spritesheet_index = 13
g_target_spritesheet_sprite_pos = get_spritesheet_pos(g_target_spritesheet_index)
g_targets = {}
function clamp(lower, value, upper)
return mid(lower, value, upper)
end
function rnd_int_range(lower, upper)
return flr(rnd(upper - lower)) + lower
end
function get_2d_distance(p1, p2)
local x_diff = p1.x - p2.x
local y_diff = p1.y - p2.y
return sqrt((x_diff * x_diff) + (y_diff * y_diff))
end
function rnd_choice(choices)
local choice_count = 0
local choice_map = {}
for k,v in pairs(choices) do
add(choice_map, k)
choice_count += 1
end
local rnd_choice_index = rnd_int_range(1, choice_count + 1)
return choice_map[rnd_choice_index]
end
function poll_input(input)
if input == nil then
input = {
btn_left = false,
btn_left_change = false,
btn_right = false,
btn_right_change = false,
btn_up = false,
btn_up_change = false,
btn_down = false,
btn_down_change = false,
btn_o = false,
btn_o_change = false,
btn_x = false,
btn_x_change = false,
}
end
local new_input = {
btn_left = btn(0),
btn_right = btn(1),
btn_up = btn(2),
btn_down = btn(3),
btn_o = btn(4),
btn_x = btn(5),
}
input.btn_left_change = (input.btn_left ~= new_input.btn_left)
input.btn_left = new_input.btn_left
input.btn_right_change = (input.btn_right ~= new_input.btn_right)
input.btn_right = new_input.btn_right
input.btn_up_change = (input.btn_up ~= new_input.btn_up)
input.btn_up = new_input.btn_up
input.btn_down_change = (input.btn_down ~= new_input.btn_down)
input.btn_down = new_input.btn_down
input.btn_o_change = (input.btn_o ~= new_input.btn_o)
input.btn_o = new_input.btn_o
input.btn_x_change = (input.btn_x ~= new_input.btn_x)
input.btn_x = new_input.btn_x
return input
end
function move_plane_horizontal(input, plane_pos_x, plane_size, move_speed)
local new_x = plane_pos_x
if input.btn_left then
new_x -= move_speed
end
if input.btn_right then
new_x += move_speed
end
-- constrain the plane to only be able to move within the given game box
new_x = clamp(
c_gameplay_boundaries.left + (plane_size.width / 2),
new_x,
c_gameplay_boundaries.right - (plane_size.width / 2))
return new_x
end
function move_plane_vertical(input, plane_pos, lanes, move_speed)
local dest_y
if input.btn_up and input.btn_up_change then
if plane_pos.y <= lanes.top then
-- we are in the top lane and tried to move up.
-- noop
return nil
elseif plane_pos.y <= lanes.mid then
dest_y = lanes.top
else
dest_y = lanes.mid
end
elseif input.btn_down and input.btn_down_change then
if plane_pos.y <= lanes.top then
dest_y = lanes.mid
elseif plane_pos.y <= lanes.mid then
dest_y = lanes.bottom
else
-- we are in the bottom lane and tried to move down.
-- noop
return nil
end
else
-- neither up nor down was just pressed
-- noop
return nil
end
local move_to_next_lane =
function()
assert(move_speed != 0, "Attempted to perform a move without speed")
local update_count = 0
local total_update_count = 30 / move_speed
local original_y = plane_pos.y
while true do
update_count += 1
local move_completion_ratio = 1 - (update_count / total_update_count)
-- cubic ease out
local ease_out_factor = 1 - (move_completion_ratio * move_completion_ratio * move_completion_ratio)
local cumulative_offset = (dest_y - original_y) * ease_out_factor
plane_pos.y = cumulative_offset + original_y
if plane_pos.y != dest_y then
yield()
else
break
end
end
end
return cocreate(move_to_next_lane)
end
function get_plane_sprite(plane_sprites, plane_pos)
-- N.B lane bands are NOT perfectly even
-- top lane and bottom lane are slightly larger than the mid lane
-- IMO this looks better
local lane
if plane_pos.y < 52 then
lane = 'top_lane'
elseif plane_pos.y < 76 then
lane = 'mid_lane'
else
lane = 'bottom_lane'
end
-- N.B. the different zones which correspond to each type of sprite 'lean' are not even
-- 'outside' and 'center' are wider than 'leaning'.
-- IMO this looks better
local flip
local sprite_lean
if plane_pos.x < 39 then
flip = false
sprite_lean = 'outside'
elseif plane_pos.x < 50 then
flip = false
sprite_lean = 'leaning'
elseif plane_pos.x < 79 then
flip = false
sprite_lean = 'center'
elseif plane_pos.x < 90 then
flip = true
sprite_lean = 'leaning'
else
flip = true
sprite_lean = 'outside'
end
return {
origin = plane_sprites[lane][sprite_lean].origin,
nose_offset = plane_sprites[lane][sprite_lean].nose_offset,
flip = flip
}
end
function debug_render_sprite_grid()
-- N.B. currently these grid positions are hardcoded here and in get_plane_sprite and must be manually kept in sync.
-- probably worth improving in the future
local divider_color = 7 -- white
-- lane dividers
line(0, 52, c_pico_8_screen_size, 52, divider_color) -- draw top lane divider
line(0, 76, c_pico_8_screen_size, 76, divider_color) -- draw mid lane divider
-- lean zone dividers
line(39, 0, 39, c_pico_8_screen_size, divider_color) -- left outside zone divider
line(50, 0, 50, c_pico_8_screen_size, divider_color) -- left leaning zone divider
line(79, 0, 79, c_pico_8_screen_size, divider_color) -- center zone divider
line(90, 0, 90, c_pico_8_screen_size, divider_color) -- right leaning zone divider zone divider
end
function draw_plane_guideline(plane_pos)
local c_perspective_pos_weight = 0.85 -- hardcoded to match what's in draw_target
nose_line_endpos = {
x = plane_pos.x,
y = plane_pos.y,
z = c_target_spawn_z
}
-- draw a perspective skewed dotted line from the plane's nose to the target spawn wall
-- we simulate a dotted line by drawing 10 evenly spaced points in worldspace
for i=1,10 do
point_on_dotted_line = {
x = plane_pos.x,
y = plane_pos.y,
z = (c_target_spawn_z * i / 10)
}
local perspective_scale = calculate_perspective_scale(point_on_dotted_line.z, 0, c_eye_z)
local perspective_line_point = apply_perspective_scale_to_screen_pos(point_on_dotted_line, perspective_scale, c_perspective_pos_weight)
pset(perspective_line_point.x, perspective_line_point.y, 7)
end
end
function debug_render_plane_nose(plane_pos)
local c_perspective_pos_weight = 0.85 -- hardcoded to match what's in draw_target
nose_line_endpos = {
x = plane_pos.x,
y = plane_pos.y,
z = c_target_spawn_z
}
local perspective_scale = calculate_perspective_scale(nose_line_endpos.z, 0, c_eye_z)
local nose_line_perspective_endpos = apply_perspective_scale_to_screen_pos(nose_line_endpos, perspective_scale, c_perspective_pos_weight)
-- draw a perspective skewed line from the plane's nose to the target spawn wall
line(plane_pos.x, plane_pos.y, nose_line_perspective_endpos.x, nose_line_perspective_endpos.y, 7)
-- render the center of the plane
local plane_pos_marker_color = 8 -- white
pset(plane_pos.x, plane_pos.y, plane_pos_marker_color)
end
function calculate_perspective_scale(target_z, screen_z, eye_z)
assert(eye_z > target_z, 'The camera (eye_z) must be in front of the target (target_z)')
assert(eye_z > screen_z, 'The camera (eye_z) must be in front of the screen (screen_z)')
local world_space_z_distance = eye_z - target_z
local screen_space_z_distance = eye_z - screen_z
local scale = screen_space_z_distance / world_space_z_distance
return scale
end
function screen_space_to_world_space(screen_pos, screen_size)
local half_screen_size = screen_size / 2
return {
x = screen_pos.x - half_screen_size,
y = -1 * (screen_pos.y - half_screen_size)
}
end
function world_space_to_screen_space(world_pos, screen_size)
local half_screen_size = screen_size / 2
return {
x = world_pos.x + half_screen_size,
y = (-1 * world_pos.y) + half_screen_size
}
end
function apply_weighted_scale(value, scale, weight)
local weighted_portion = value * weight * scale
local unweighted_portion = value * (1 - weight)
return weighted_portion + unweighted_portion
end
function apply_perspective_scale_to_screen_pos(screen_pos, perspective_scale, perspective_weight)
assert(perspective_weight >= 0 or weight <= 1.0, 'Perspective weight must be a ratio value between 0 and 1')
local world_pos = screen_space_to_world_space(screen_pos, c_pico_8_screen_size)
-- this helper function scales a value but allows for weighting how much that scale applies.
-- i.e. 'I want to scale down by 75% but only apply that scale at roughly 80% potency'
local scaled_world_pos = {
x = apply_weighted_scale(world_pos.x, perspective_scale, perspective_weight),
y = apply_weighted_scale(world_pos.y, perspective_scale, perspective_weight)
}
return world_space_to_screen_space(scaled_world_pos, c_pico_8_screen_size)
end
function try_spawn_target(lanes)
-- spawn a target on average about once every second (with random variation)
local should_spawn = (rnd_int_range(0, 30) == 1)
if not should_spawn then
return nil
end
local target_size = { width = 16, height = 16 }
-- a target can be generated anywhere within the gameplay zone
-- make sure to account for the target's width when determining those borders
local rnd_x_pos = rnd_int_range(
c_gameplay_boundaries.left + (target_size.width / 2),
c_gameplay_boundaries.right + 1 - (target_size.width / 2))
local rnd_lane = rnd_choice(lanes)
local rnd_y_pos = lanes[rnd_lane]
return {
pos = { x = rnd_x_pos, y = rnd_y_pos, z = c_target_spawn_z },
size = target_size
}
end
function handle_target_collisions(plane_pos, targets)
local next_target_index = 1
while next_target_index <= count(targets) do
local next_target = targets[next_target_index]
if next_target.pos.z >= plane_pos.z then
local target_plane_distance = get_2d_distance(plane_pos, next_target.pos)
if target_plane_distance <= 4 then
sfx(1)
g_score += 100
deli(targets, next_target_index)
elseif target_plane_distance <= 8 then
sfx(2)
g_score += 10
deli(targets, next_target_index)
else
-- target and plane haven't collided. move onto next target
next_target_index += 1
end
else
-- target and plane haven't collided. move onto next target
next_target_index += 1
end
end
end
function check_for_despawned_targets(targets)
local next_target_index = 1
while next_target_index <= count(targets) do
if targets[next_target_index].pos.z >= c_target_despawn_z then
sfx(0)
deli(targets, next_target_index)
else
next_target_index += 1
end
end
end
function draw_plane(pos, size, plane_sprites)
local sprite_data = get_plane_sprite(plane_sprites, pos)
nose_offset_x = sprite_data.nose_offset.x
nose_offset_y = sprite_data.nose_offset.y
-- if we flip the sprite, we also have to flip the nose offset
if sprite_data.flip then
nose_offset_x = 16 - nose_offset_x - 1
end
local pos_centered_on_nose = {
x = pos.x - nose_offset_x,
y = pos.y - nose_offset_y
}
sspr(
sprite_data.origin.x,
sprite_data.origin.y,
16,
16,
pos_centered_on_nose.x,
pos_centered_on_nose.y,
size.width,
size.height,
sprite_data.flip)
end
function draw_target(target)
local perspective_scale = calculate_perspective_scale(target.pos.z, 0, c_eye_z)
-- fudge the numbers here for a better perspective view. True perspective view makes the dots appear too close to the center of the screen when they start
-- the target's position is at its center but we need its topleft coordinate to do the sprite draw
local perspective_pos = apply_perspective_scale_to_screen_pos(target.pos, perspective_scale, c_perspective_pos_weight)
local scaled_target_size = {
width = apply_weighted_scale(target.size.width, perspective_scale, c_perspective_size_weight),
height = apply_weighted_scale(target.size.height, perspective_scale, c_perspective_size_weight),
}
local target_topleft_corner_pos = {
x = perspective_pos.x - (scaled_target_size.width / 2),
y = perspective_pos.y - (scaled_target_size.height / 2) }
-- draw the target sprite
sspr(
g_target_spritesheet_sprite_pos.x, -- sx
g_target_spritesheet_sprite_pos.y, -- sy
target.size.width, -- sw
target.size.height, -- sh
target_topleft_corner_pos.x, -- dx
target_topleft_corner_pos.y, -- dy
scaled_target_size.width, -- dw
scaled_target_size.height) -- dh
end
function render_debug_target_centers(target)
local perspective_scale = calculate_perspective_scale(target.pos.z, 0, c_eye_z)
-- fudge the numbers here for a better perspective view. True perspective view makes the dots appear too close to the center of the screen when they start
-- the target's position is at its center but we need its topleft coordinate to do the sprite draw
local perspective_pos = apply_perspective_scale_to_screen_pos(target.pos, perspective_scale, c_perspective_pos_weight)
pset(perspective_pos.x, perspective_pos.y, 4)
end
function _init()
-- initialize the plane's position to the center of the bottom lane
g_plane_pos = {
x = c_pico_8_screen_size / 2,
y = c_lanes.bottom,
z = 0
}
end
function _update()
-- handle using player input to move the plane
g_input = poll_input(g_input)
g_plane_pos.x = move_plane_horizontal(g_input, g_plane_pos.x, g_plane_size, 1.5)
if g_plane_vertical_slide == nil then
g_plane_vertical_slide = move_plane_vertical(g_input, g_plane_pos, c_lanes, 1.1)
end
if g_plane_vertical_slide != nil then
assert(costatus(g_plane_vertical_slide) != 'dead')
local active, exception = coresume(g_plane_vertical_slide)
if exception then
printh(exception)
printh(trace(g_plane_vertical_slide, exception))
end
if costatus(g_plane_vertical_slide) == 'dead' then
g_plane_vertical_slide = nil
end
end
-- handle perodically spawning a new target
local maybe_new_target = try_spawn_target(c_lanes)
if maybe_new_target != nil then
add(g_targets, maybe_new_target)
end
-- move targets into the screen
for target in all(g_targets) do
-- N.B. we need this to be a power of 2 so that it will eventually sum
-- to exactly 0 without any precision issues.
target.pos.z += c_target_speed
end
-- check for any target collisions
handle_target_collisions(g_plane_pos, g_targets)
-- check for any despawned targets
check_for_despawned_targets(g_targets)
end
function _draw()
-- first draw the map
map(0, 0, 0, 0, 32, 32)
-- draw targets in perspective
foreach(g_targets, draw_target)
-- foreach(g_targets, render_debug_target_centers)
-- draw the plane
draw_plane(g_plane_pos, g_plane_size, g_plane_sprites)
draw_plane_guideline(g_plane_pos)
-- uncomment this to draw a debug grid showing where plane sprites change
-- debug_render_sprite_grid()
-- uncomment this to draw a debug marker showing where the plane's nose is being registered
-- debug_render_plane_nose(g_plane_pos)
-- draw the score
print("score: "..g_score, 0, 0, 7)
end