345 lines
15 KiB
GDScript
345 lines
15 KiB
GDScript
@tool
|
|
extends Node2D
|
|
const SIZE := Vector2(640, 800)
|
|
const VANISH := Vector2(320, 535)
|
|
const GREEN := Color("30ff35")
|
|
const YELLOW := Color("fff633")
|
|
const BLUE := Color("3044ff")
|
|
const CYAN := Color("24edff")
|
|
# Stroke alphabet: XY pairs on a 4 x 6 grid. Every glyph is drawn as vectors.
|
|
const GLYPHS := {
|
|
"0":"00 40 46 06 00", "1":"10 20 26;06 46", "2":"00 40 43 03 06 46", "3":"00 40 46 06;03 43", "4":"00 03 43;40 46", "5":"40 00 03 43 46 06", "6":"40 00 06 46 43 03", "7":"00 40 16", "8":"00 40 46 06 00;03 43", "9":"46 40 00 03 43",
|
|
"A":"06 20 46;14 34", "B":"00 30 41 42 33 03;33 44 45 36 06 00", "C":"40 00 06 46", "D":"00 30 42 44 36 06 00", "E":"40 00 06 46;03 33", "F":"40 00 06;03 33", "G":"40 00 06 46 43 23", "H":"00 06;40 46;03 43", "I":"00 40;20 26;06 46", "J":"00 40 46 06 04", "K":"00 06;40 03 46", "L":"00 06 46", "M":"06 00 23 40 46", "N":"06 00 46 40", "O":"00 40 46 06 00", "P":"06 00 40 43 03", "Q":"00 40 46 06 00;24 46", "R":"06 00 40 43 03;23 46", "S":"40 00 03 43 46 06", "T":"00 40;20 26", "U":"00 06 46 40", "V":"00 26 40", "W":"00 06 23 46 40", "X":"00 46;40 06", "Y":"00 23 40;23 26", "Z":"00 40 06 46", "!":"20 24;25 26", "-":"03 43", "/":"06 40", ":":"21 22;24 25", ".":"25 26", "<":"40 03 46", ">":"00 43 06"
|
|
}
|
|
# Stroke coordinates traced from the supplied logo reference. Shared baselines
|
|
# and open strokes are intentional; these are not outlined block letters.
|
|
const LOGO_STROKES := [
|
|
[Vector2(87,61),Vector2(205,61)], [Vector2(147,61),Vector2(147,153)],
|
|
[Vector2(272,61),Vector2(216,61),Vector2(186,153),Vector2(305,153)],
|
|
[Vector2(201,107),Vector2(280,107)],
|
|
[Vector2(284,153),Vector2(320,61),Vector2(330,61),Vector2(354,121),Vector2(377,61),Vector2(389,61),Vector2(422,153)],
|
|
[Vector2(400,153),Vector2(436,153)],
|
|
[Vector2(428,153),Vector2(428,61),Vector2(491,61),Vector2(512,111),Vector2(428,111)],
|
|
[Vector2(606,61),Vector2(550,61),Vector2(520,153),Vector2(620,153)],
|
|
[Vector2(535,107),Vector2(615,107)],
|
|
[Vector2(632,125),Vector2(619,153),Vector2(724,153),Vector2(724,112),Vector2(644,89),Vector2(655,61),Vector2(728,61),Vector2(717,83)],
|
|
[Vector2(728,61),Vector2(846,61)], [Vector2(787,61),Vector2(787,153)]
|
|
]
|
|
var game: Node
|
|
var beam_texture: ImageTexture
|
|
var beam_mesh := ArrayMesh.new()
|
|
var beam_vertices := PackedVector3Array()
|
|
var beam_uvs := PackedVector2Array()
|
|
var beam_colors := PackedColorArray()
|
|
var beam_indices := PackedInt32Array()
|
|
var glyph_cache: Dictionary = {}
|
|
|
|
func _make_beam_texture() -> void:
|
|
# Build the phosphor falloff once. UVs stretch its central column along a
|
|
# segment and use its two halves as rounded end caps.
|
|
var image := Image.create(128,128,false,Image.FORMAT_RGBA8)
|
|
for y in range(128):
|
|
for x in range(128):
|
|
var r := Vector2((x-63.5)/63.5,(y-63.5)/63.5).length()
|
|
var alpha := (.07*exp(-r*r*3.0)+.16*exp(-r*r*15.0)+.75*exp(-r*r*200.0)) * (1.0-smoothstep(.85,1.0,r))
|
|
image.set_pixel(x,y,Color(1,1,1,alpha))
|
|
beam_texture = ImageTexture.create_from_image(image)
|
|
for character in GLYPHS:
|
|
var strokes: Array[PackedVector2Array] = []
|
|
for stroke in GLYPHS[character].split(";"):
|
|
var points := PackedVector2Array()
|
|
for pair in stroke.split(" "):
|
|
points.append(Vector2(float(pair[0]),float(pair[1])))
|
|
strokes.append(points)
|
|
glyph_cache[character] = strokes
|
|
|
|
func _beam_quad(a: Vector2, b: Vector2, normal: Vector2, u0: float, u1: float, tint: Color) -> void:
|
|
var base := beam_vertices.size()
|
|
for point in [a-normal,b-normal,b+normal,a+normal]:
|
|
beam_vertices.append(Vector3(point.x,point.y,0))
|
|
beam_colors.append(tint)
|
|
beam_uvs.append_array(PackedVector2Array([Vector2(u0,0),Vector2(u1,0),Vector2(u1,1),Vector2(u0,1)]))
|
|
beam_indices.append_array(PackedInt32Array([base,base+1,base+2,base,base+2,base+3]))
|
|
|
|
func _flush_beams() -> void:
|
|
beam_mesh.clear_surfaces()
|
|
if beam_vertices.is_empty():
|
|
return
|
|
var arrays := []
|
|
arrays.resize(Mesh.ARRAY_MAX)
|
|
arrays[Mesh.ARRAY_VERTEX] = beam_vertices
|
|
arrays[Mesh.ARRAY_TEX_UV] = beam_uvs
|
|
arrays[Mesh.ARRAY_COLOR] = beam_colors
|
|
arrays[Mesh.ARRAY_INDEX] = beam_indices
|
|
beam_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES,arrays)
|
|
draw_mesh(beam_mesh,beam_texture)
|
|
|
|
func _ready() -> void:
|
|
game = get_parent()
|
|
_make_beam_texture()
|
|
|
|
func _process(_delta: float) -> void:
|
|
if not Engine.is_editor_hint():
|
|
var available := get_viewport_rect().size
|
|
scale = Vector2.ONE * minf(available.x / SIZE.x, available.y / SIZE.y)
|
|
position = (available - SIZE * scale) * 0.5
|
|
queue_redraw()
|
|
|
|
func profile(index: float, level: int) -> Vector2:
|
|
# Hand-authored outlines; level nine is an open V with seventeen vertices.
|
|
if (level - 1) % 9 == 8:
|
|
# The arcade-like open V has a short flat bottom, not a collapsed point.
|
|
if index <= 7.0:
|
|
return Vector2(-1,-1).lerp(Vector2(-.14,1),index/7.0)
|
|
if index <= 9.0:
|
|
return Vector2(-.14,1).lerp(Vector2(.14,1),(index-7.0)/2.0)
|
|
return Vector2(.14,1).lerp(Vector2(1,-1),(index-9.0)/7.0)
|
|
var angle := index * TAU / 16.0 - PI / 2.0
|
|
var p := Vector2(cos(angle), sin(angle))
|
|
match (level - 1) % 9:
|
|
1: p /= maxf(absf(p.x), absf(p.y))
|
|
2:
|
|
var cross := [Vector2(0,-1),Vector2(.5,-1),Vector2(.5,-.5),Vector2(1,-.5),Vector2(1,0),Vector2(1,.5),Vector2(.5,.5),Vector2(.5,1),Vector2(0,1),Vector2(-.5,1),Vector2(-.5,.5),Vector2(-1,.5),Vector2(-1,0),Vector2(-1,-.5),Vector2(-.5,-.5),Vector2(-.5,-1)]
|
|
var i := int(floor(index))
|
|
p = cross[posmod(i,16)].lerp(cross[posmod(i+1,16)], fposmod(index,1.0))
|
|
3: p *= Vector2(1.0, 0.72)
|
|
4: p *= 0.76 + 0.24 * cos(angle * 4.0)
|
|
5: p /= absf(p.x) + absf(p.y)
|
|
6: p *= 0.73 + 0.27 * cos(angle * 4.0 + PI)
|
|
7: p *= Vector2(0.72, 1.0)
|
|
8: p *= 0.8 + 0.2 * cos(angle * 3.0)
|
|
return p
|
|
|
|
func perspective(depth: float) -> float:
|
|
return (1.0 / (1.0 + 4.0 * (1.0 - clampf(depth,0,1))) - 0.2) / 0.8
|
|
|
|
func tunnel_focus() -> Vector2:
|
|
# Open V lanes collapse visually if they use the low circular-tube focus.
|
|
return Vector2(320,345) if game.is_open_tube() else VANISH
|
|
|
|
func rim(index: float, depth: float) -> Vector2:
|
|
var p := profile(index, game.stage)
|
|
return (tunnel_focus() + p * Vector2(34,43 if game.is_open_tube() else 34)).lerp(Vector2(320,410) + p * 265.0, perspective(depth))
|
|
|
|
func glow(points: PackedVector2Array, color: Color, strength: float = 1.0) -> void:
|
|
if points.size() < 2:
|
|
return
|
|
var tint := Color(color,minf(1.0,strength*game.glow_strength))
|
|
var radius := 10.0 * sqrt(maxf(.1,game.glow_strength))
|
|
for i in range(points.size()-1):
|
|
var a := points[i]
|
|
var b := points[i+1]
|
|
if a.distance_squared_to(b) < .0001:
|
|
continue
|
|
var direction := (b-a).normalized()
|
|
var normal := direction.orthogonal()*radius
|
|
_beam_quad(a,b,normal,.5,.5,tint)
|
|
_beam_quad(a-direction*radius,a,normal,0,.5,tint)
|
|
_beam_quad(b,b+direction*radius,normal,.5,1,tint)
|
|
func line(a: Vector2, b: Vector2, color: Color, strength: float = 1.0) -> void:
|
|
glow(PackedVector2Array([a,b]),color,strength)
|
|
|
|
func text_at(value: String, origin: Vector2, height: float, color: Color, strength: float = 0.8) -> void:
|
|
var unit := height / 6.0
|
|
var x := origin.x
|
|
for character in value:
|
|
for stroke in glyph_cache.get(character,[]):
|
|
var points := PackedVector2Array()
|
|
for point in stroke:
|
|
points.append(Vector2(x,origin.y)+point*unit)
|
|
glow(points,color,strength)
|
|
x += unit*6.0
|
|
func label_at(value: String, y: float, height: float, color: Color) -> void:
|
|
text_at(value,Vector2((640 - value.length() * height + height / 3.0)/2,y),height,color)
|
|
|
|
func actor(which_lane: float, depth: float, kind: String, roll: float = 0.0, roll_direction: int = 1) -> void:
|
|
var center := rim(which_lane + .5,depth)
|
|
var tangent := (rim(which_lane+1,depth)-rim(which_lane,depth)).normalized()
|
|
var inward := (tunnel_focus()-center).normalized()
|
|
var s := lerpf(3.0,17.0,perspective(depth))
|
|
var color := Color("ff2929")
|
|
var shape := [Vector2(-1,-.6),Vector2(1,.6),Vector2(1,-.6),Vector2(-1,.6),Vector2(-1,-.6)]
|
|
if kind == "player":
|
|
color = YELLOW
|
|
s = lerpf(4.0,26.0,perspective(depth))
|
|
shape = [Vector2(-1,.9),Vector2(-.85,-.25),Vector2(0,-.7),Vector2(.85,-.25),Vector2(1,.9),Vector2(.45,.1),Vector2(0,-.15),Vector2(-.45,.1),Vector2(-1,.9)]
|
|
elif kind == "tanker":
|
|
color = Color("df36ff")
|
|
shape = [Vector2(-1,0),Vector2(-.45,-.75),Vector2(.45,-.75),Vector2(1,0),Vector2(.45,.75),Vector2(-.45,.75),Vector2(-1,0),Vector2(1,0)]
|
|
elif kind == "spiker":
|
|
color = GREEN
|
|
shape = []
|
|
for i in range(17):
|
|
shape.append(Vector2.from_angle(i * TAU / 8.0 + game.clock * 7.0) * (1.0 if i % 2 == 0 else .3))
|
|
var points := PackedVector2Array()
|
|
for point in shape:
|
|
var p: Vector2 = point
|
|
if roll > 0:
|
|
p = p.rotated((1.0-roll/.24)*PI*roll_direction)
|
|
points.append(center + (tangent*p.x+inward*p.y)*s)
|
|
glow(points,color)
|
|
|
|
func draw_tube(zoom: float = 1.0, strength: float = 1.0) -> void:
|
|
var pivot := tunnel_focus()
|
|
for i in range(17 if game.is_open_tube() else 16):
|
|
var selected: bool = i == game.lane or i == (game.lane+1 if game.is_open_tube() else (game.lane+1)%16)
|
|
line(pivot+(rim(i,0)-pivot)*zoom,pivot+(rim(i,1)-pivot)*zoom,YELLOW if selected else BLUE,strength)
|
|
for d in [0.0,1.0]:
|
|
var ring := PackedVector2Array()
|
|
for i in range(17):
|
|
ring.append(pivot+(rim(i,d)-pivot)*zoom)
|
|
glow(ring,BLUE,strength)
|
|
|
|
func stars(strength: float = 1.0) -> void:
|
|
for i in range(95):
|
|
var angle := i * 2.399963
|
|
var t := fposmod(sin(i * 127.1 + 17.3) * 43758.5453 + game.clock * .43,1.0)
|
|
var radius := pow(t,2.3)*700.0
|
|
var direction := Vector2.from_angle(angle)
|
|
line(tunnel_focus()+direction*radius,tunnel_focus()+direction*(radius+2+t*24),[CYAN,BLUE,Color.WHITE][i%3],strength*t*.75)
|
|
|
|
func hud() -> void:
|
|
text_at(str(game.score),Vector2(45,43),32,GREEN)
|
|
text_at("HIGH SCORE",Vector2(323,28),13,GREEN)
|
|
text_at(str(game.high_score),Vector2(342,52),19,GREEN)
|
|
text_at(str(game.stage),Vector2(342,83),16,BLUE)
|
|
for i in range(mini(game.lives,9)):
|
|
var x := 46.0 + i * 27
|
|
glow(PackedVector2Array([Vector2(x,104),Vector2(x+10,98),Vector2(x+20,104),Vector2(x+14,111),Vector2(x+10,105),Vector2(x+6,111),Vector2(x,104)]),YELLOW,.7)
|
|
|
|
func title_logo() -> void:
|
|
# The white leading copy grows from the distance. Each older copy is a
|
|
# smaller scale of exactly the same strokes, converging below the logo.
|
|
var time: float = fmod(game.attract_clock,28.0)
|
|
var front_scale := lerpf(.05,1.0,smoothstep(0.0,3.0,time))
|
|
var pivot := Vector2(320,408)
|
|
for layer in range(14,-1,-1):
|
|
var age := 0.0 if layer == 0 else float(layer)-fposmod(game.clock*6.0,1.0)
|
|
var copy_scale := front_scale * (1.0-age*.027)
|
|
var tint := Color.WHITE if layer == 0 else Color.from_hsv(fposmod(game.clock*.16-age*.055,1.0),1.0,1.0)
|
|
for stroke in LOGO_STROKES:
|
|
var points := PackedVector2Array()
|
|
for point in stroke:
|
|
var front: Vector2 = Vector2(320,286)+(point-Vector2(466.5,107))*.71
|
|
points.append(pivot+(front-pivot)*copy_scale)
|
|
glow(points,tint,.72 if layer > 0 else 1.0)
|
|
func level_select() -> void:
|
|
label_at("PLAYER 1",182,36,Color.WHITE)
|
|
label_at("RATE YOURSELF",245,19,GREEN)
|
|
label_at("TURN TO CHANGE",275,18,CYAN)
|
|
label_at("PRESS FIRE TO SELECT",304,18,YELLOW)
|
|
text_at("NOVICE",Vector2(102,368),14,Color.RED)
|
|
text_at("EXPERT",Vector2(463,368),14,Color.RED)
|
|
for i in range(5):
|
|
var x := 123.0+i*98
|
|
text_at(str(game.START_LEVELS[i]),Vector2(x,405),21,GREEN)
|
|
var shape := PackedVector2Array()
|
|
for j in range(17):
|
|
shape.append(Vector2(x+8,465)+profile(j,game.START_LEVELS[i])*24)
|
|
glow(shape,BLUE)
|
|
text_at(str(game.START_BONUSES[i]),Vector2(x-25,518),13,Color("ff3030"))
|
|
if i == game.selection:
|
|
glow(PackedVector2Array([Vector2(x-37,390),Vector2(x+48,390),Vector2(x+48,550),Vector2(x-37,550),Vector2(x-37,390)]),Color.WHITE,.7)
|
|
text_at("LEVEL",Vector2(14,410),13,GREEN)
|
|
text_at("HOLE",Vector2(14,465),13,GREEN)
|
|
text_at("BONUS",Vector2(14,520),13,GREEN)
|
|
label_at("TIME %d" % ceili(game.phase_time),651,20,GREEN)
|
|
|
|
func _draw() -> void:
|
|
beam_vertices.clear()
|
|
beam_uvs.clear()
|
|
beam_colors.clear()
|
|
beam_indices.clear()
|
|
_draw_board()
|
|
_flush_beams()
|
|
|
|
func _draw_board() -> void:
|
|
if game == null: return
|
|
if Engine.is_editor_hint():
|
|
draw_rect(Rect2(Vector2.ZERO,SIZE),Color.BLACK)
|
|
else:
|
|
draw_rect(Rect2(-position/scale,get_viewport_rect().size/scale),Color.BLACK)
|
|
if game.phase == "select":
|
|
level_select()
|
|
return
|
|
if game.phase == "title" and not Engine.is_editor_hint():
|
|
var page: float = fmod(game.attract_clock,28.0)
|
|
if page < 9.0:
|
|
title_logo()
|
|
elif page < 15.0:
|
|
label_at("HIGH SCORES",222,29,Color("ff3030"))
|
|
label_at("1 PLAYER %06d" % game.high_score,316,22,BLUE)
|
|
label_at("THIS SESSION",370,14,CYAN)
|
|
elif page < 23.0:
|
|
draw_tube()
|
|
actor(floor(game.clock*1.5),1.0,"player")
|
|
for i in range(6):
|
|
actor(i*3,fposmod(game.clock*.08+i*.16,1.0),"flipper")
|
|
label_at("GAME OVER",130,21,GREEN)
|
|
else:
|
|
label_at("DESTROY THE ENEMIES",270,23,Color.RED)
|
|
label_at("FIRE DOWN THE TUBE",330,20,YELLOW)
|
|
label_at("SUPERZAPPER",401,24,CYAN)
|
|
label_at("FIRST BLAST - ALL",451,18,GREEN)
|
|
label_at("SECOND BLAST - ONE",486,18,GREEN)
|
|
label_at("PRESS START",697,23,GREEN)
|
|
label_at("BONUS EVERY 20000",740,15,CYAN)
|
|
label_at("FREE PLAY",774,14,GREEN)
|
|
return
|
|
if game.phase in ["space","victory","won"]:
|
|
stars()
|
|
if game.phase in ["victory","won"]:
|
|
label_at("YOU WIN!",350,40,YELLOW)
|
|
elif game.awarded_bonus > 0:
|
|
label_at("BONUS %d" % game.awarded_bonus,310,25,YELLOW)
|
|
elif game.phase == "arrive":
|
|
var t := clampf(1.0-game.phase_time/1.6,0,1)
|
|
stars(1.0-t)
|
|
draw_tube(lerpf(.025,1.0,t*t*(3.0-2.0*t)))
|
|
elif game.phase == "dive":
|
|
var t: float = 1.0-game.dive_depth()
|
|
stars(t)
|
|
draw_tube(1.0+t*t*7,1.0-t)
|
|
for spike_lane in game.spikes:
|
|
line(rim(spike_lane+.5,0),rim(spike_lane+.5,game.spikes[spike_lane]),GREEN,1.0-t)
|
|
actor(game.lane,game.dive_depth(),"player")
|
|
if not game.spikes.is_empty(): label_at("AVOID SPIKES",720,18,Color.RED)
|
|
else:
|
|
draw_tube()
|
|
for spike_lane in game.spikes:
|
|
line(rim(spike_lane+.5,0),rim(spike_lane+.5,game.spikes[spike_lane]),GREEN)
|
|
for enemy in game.enemies:
|
|
var visible_lane: float = enemy.lane
|
|
var rolling: float = enemy.get("roll",0.0)
|
|
if rolling > 0:
|
|
visible_lane = float(enemy.from_lane)+(1.0-rolling/.24)*enemy.direction
|
|
actor(visible_lane,enemy.depth,enemy.kind,rolling,enemy.get("direction",1))
|
|
for shot in game.shots:
|
|
line(rim(shot.lane+.5,shot.depth),rim(shot.lane+.5,maxf(0,shot.depth-.08)),YELLOW)
|
|
if game.phase not in ["death","lost"]:
|
|
actor(game.lane,1,"player")
|
|
for spark in game.sparks:
|
|
var origin := rim(spark.lane+.5,spark.depth)
|
|
for i in range(8):
|
|
var direction := Vector2.from_angle(i*TAU/8+game.clock)
|
|
var r: float = (0.7-spark.life)*65
|
|
line(origin+direction*r*.5,origin+direction*r,YELLOW,minf(1,spark.life*3))
|
|
if game.phase == "ready": label_at("PLAYER 1",345,29,Color.WHITE)
|
|
if game.phase == "lost": label_at("GAME OVER",345,29,Color.RED)
|
|
if game.zap_flash > 0:
|
|
for i in range(16): line(rim(i+.5,0),rim(i+.5,1),Color.WHITE,game.zap_flash*3)
|
|
hud()
|
|
if game.phase in ["play","ready"]:
|
|
label_at("SUPERZAPPER %d" % (2-game.zap_uses),749,14,CYAN)
|
|
label_at("ARROWS MOVE SPACE FIRE Z ZAP",779,11,GREEN)
|
|
elif game.phase in ["won","lost"]:
|
|
label_at("PRESS START",749,22,GREEN)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|