Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e0c666c2b | ||
|
|
7489528e60 | ||
|
|
9cf032c203 | ||
|
|
b721760803 | ||
|
|
721913c040 | ||
|
|
b8038c1f94 | ||
|
|
940540d60c | ||
|
|
080e3bb928 | ||
|
|
aabd5804cf | ||
|
|
2206b55873 | ||
|
|
90c34f6c74 | ||
|
|
970eafc1de | ||
|
|
ac4b96ef49 | ||
|
|
1ece202731 | ||
|
|
ea25318201 | ||
|
|
710dcbf230 | ||
|
|
397983b611 | ||
|
|
1e89780bd7 | ||
|
|
902d6922e7 | ||
|
|
ddc3de595e | ||
|
|
988baa8fd6 | ||
|
|
eb17b3d607 | ||
|
|
d455361328 | ||
|
|
7d344b2a0a | ||
|
|
ec7a8f1f17 | ||
|
|
0810c99f08 | ||
|
|
551360f2e9 | ||
|
|
c5c41b85e7 | ||
|
|
8412280e71 | ||
|
|
39ad050dc6 | ||
|
|
f9ed46746d | ||
|
|
0c48b5e739 | ||
|
|
1a2a27b6e8 | ||
|
|
7a78d1b1a6 | ||
|
|
457ae7023c | ||
|
|
209169206b | ||
|
|
d8982fb800 | ||
|
|
b50597b757 | ||
|
|
0c595d775d | ||
|
|
bd0cc842ff | ||
|
|
8122e280b7 | ||
|
|
3c632276af | ||
|
|
3debcd7edd | ||
|
|
26f3728546 | ||
|
|
c221584848 | ||
|
|
88b53abf52 | ||
|
|
890e1c812f | ||
|
|
5f1b5a9b29 | ||
|
|
85c9bd9d8d | ||
|
|
12bd5eb7bf | ||
|
|
c8d4731232 | ||
|
|
01f053e01a | ||
|
|
4883197ef3 | ||
|
|
03b1c85569 | ||
|
|
b1d179ec9c | ||
|
|
6616952660 |
238 changed files with 14899 additions and 567 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -13,3 +13,7 @@ android/build/
|
|||
|
||||
# Android's "don't index this as media" marker, not project content.
|
||||
.nomedia
|
||||
|
||||
# Per-test verdicts from the last tests/run_tests.sh, kept only so a long run
|
||||
# whose terminal output was lost can still be read back.
|
||||
.test-results/
|
||||
|
|
|
|||
63
audio/Music.gd
Normal file
63
audio/Music.gd
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
class_name Music
|
||||
extends RefCounted
|
||||
|
||||
# Forgejo #148: "the space_4 file in the folder is the main theme of the
|
||||
# game, make it play all the time."
|
||||
#
|
||||
# "All the time" is the whole of the requirement, and it is what decides the
|
||||
# shape of this. The theme must survive the main menu becoming the game, the
|
||||
# game becoming a planet surface, and the surface becoming the game again -
|
||||
# so the player cannot live in any of those scenes, because
|
||||
# change_scene_to_file() frees the one that is current.
|
||||
#
|
||||
# It is parented to the TREE ROOT instead, alongside the GameState autoload,
|
||||
# which is the same trick for the same reason. Doing it this way rather than
|
||||
# by adding an autoload keeps project.godot out of it - that file carries
|
||||
# the developer's own uncommitted window settings, and a change here should
|
||||
# not drag those into a commit.
|
||||
#
|
||||
# ensure() is idempotent by NAME: whatever scene loads next asks for the
|
||||
# music, and gets the one already playing rather than a second copy of it.
|
||||
|
||||
const THEME_PATH: String = "res://audio/space_4.mp3"
|
||||
const NODE_NAME: String = "MusicPlayer"
|
||||
# Under everything else. It is a bed, not an event.
|
||||
const VOLUME_DB: float = -14.0
|
||||
|
||||
|
||||
# Starts the theme if nothing is playing it yet, and returns the player
|
||||
# either way. Safe to call from any scene's _ready.
|
||||
static func ensure(tree: SceneTree) -> AudioStreamPlayer:
|
||||
if tree == null:
|
||||
return null
|
||||
var existing: Node = tree.root.get_node_or_null(NODE_NAME)
|
||||
if existing != null:
|
||||
return existing as AudioStreamPlayer
|
||||
|
||||
var stream: AudioStream = load(THEME_PATH)
|
||||
if stream == null:
|
||||
return null
|
||||
# Looping is a property of the STREAM, not of the player, so it is set
|
||||
# here rather than relied on from the .import file - which Godot
|
||||
# regenerates, and which would silently take the loop away with it.
|
||||
if "loop" in stream:
|
||||
stream.loop = true
|
||||
|
||||
var player := AudioStreamPlayer.new()
|
||||
player.name = NODE_NAME
|
||||
player.stream = stream
|
||||
player.volume_db = VOLUME_DB
|
||||
player.autoplay = false
|
||||
# #128 stops the tree whenever a dialog is up. Music is the one thing
|
||||
# that should not stop with it - a soundtrack cutting out every time
|
||||
# someone reads a line is worse than no soundtrack.
|
||||
player.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
# A backstop for the loop above: if a stream format ever turns up that
|
||||
# cannot loop itself, it still comes round again rather than falling
|
||||
# silent for the rest of the session.
|
||||
player.finished.connect(player.play)
|
||||
# Deferred: this is called from a scene's _ready, and the root is busy
|
||||
# adding that scene's own children at the time.
|
||||
tree.root.add_child.call_deferred(player)
|
||||
player.play.call_deferred()
|
||||
return player
|
||||
1
audio/Music.gd.uid
Normal file
1
audio/Music.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bwck7xas3hae3
|
||||
96
audio/Sfx.gd
Normal file
96
audio/Sfx.gd
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
class_name Sfx
|
||||
extends RefCounted
|
||||
|
||||
# Forgejo #146: the three things that happen to something and should be
|
||||
# heard - a shot leaving a gun, a vessel dying, a structure coming down.
|
||||
#
|
||||
# The sounds themselves are synthesised (see audio/make_sfx.py) rather than
|
||||
# downloaded. The ticket asked for free sources and the good ones are in its
|
||||
# comments, but a generated sound has no licence to track, no attribution to
|
||||
# keep, and no binary in the repository whose provenance nobody can check
|
||||
# later. Swapping in a file from one of those packs is a matter of replacing
|
||||
# the .wav - nothing here knows or cares which it is.
|
||||
#
|
||||
# Parented to the tree root for the same reason Music is: a sound started by
|
||||
# something that is about to be freed - which is every one of these, a dying
|
||||
# ship being the obvious case - cannot live on the thing that started it.
|
||||
#
|
||||
# A POOL rather than one player, because these overlap constantly: a fleet
|
||||
# firing is several shots inside one shot's length. The oldest is recycled
|
||||
# when they are all busy, which is the right thing to lose.
|
||||
|
||||
const NODE_NAME: String = "SfxPlayers"
|
||||
const VOICES: int = 12
|
||||
|
||||
const SHOT: String = "shot"
|
||||
const SHIP_DEATH: String = "ship_death"
|
||||
const BUILDING_DEATH: String = "building_death"
|
||||
|
||||
const SOUNDS: Dictionary = {
|
||||
SHOT: "res://audio/sfx_shot.wav",
|
||||
SHIP_DEATH: "res://audio/sfx_ship_death.wav",
|
||||
BUILDING_DEATH: "res://audio/sfx_building_death.wav",
|
||||
}
|
||||
|
||||
# Per-sound level. A shot is fired constantly and a death happens once, so
|
||||
# they cannot share a volume and stay in proportion.
|
||||
const LEVELS: Dictionary = {
|
||||
SHOT: -22.0,
|
||||
SHIP_DEATH: -6.0,
|
||||
BUILDING_DEATH: -4.0,
|
||||
}
|
||||
|
||||
# Shots vary a little in pitch so a stream of them does not turn into one
|
||||
# flat machine-gun note. The deaths do not: they are events, and a wobbling
|
||||
# explosion sounds broken rather than varied.
|
||||
const SHOT_PITCH_SPREAD: float = 0.12
|
||||
|
||||
|
||||
static func _pool(tree: SceneTree) -> Node:
|
||||
var existing: Node = tree.root.get_node_or_null(NODE_NAME)
|
||||
if existing != null:
|
||||
return existing
|
||||
var pool := Node.new()
|
||||
pool.name = NODE_NAME
|
||||
# #128 stops the tree for dialogs. A sound already in flight should
|
||||
# finish rather than freeze mid-bang.
|
||||
pool.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
tree.root.add_child(pool)
|
||||
for i in range(VOICES):
|
||||
var voice := AudioStreamPlayer.new()
|
||||
voice.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
pool.add_child(voice)
|
||||
return pool
|
||||
|
||||
|
||||
# Plays one sound. Safe from a node on its way out of the tree, and safe to
|
||||
# call far more often than there are voices.
|
||||
static func play(tree: SceneTree, id: String) -> void:
|
||||
if tree == null or not SOUNDS.has(id):
|
||||
return
|
||||
var stream: AudioStream = load(String(SOUNDS[id]))
|
||||
if stream == null:
|
||||
return
|
||||
var pool: Node = _pool(tree)
|
||||
var voice: AudioStreamPlayer = null
|
||||
for child in pool.get_children():
|
||||
if not (child as AudioStreamPlayer).playing:
|
||||
voice = child
|
||||
break
|
||||
if voice == null:
|
||||
# All busy. The oldest is the one furthest through its own sound, so
|
||||
# it is the one with the least left to lose.
|
||||
var most: float = -1.0
|
||||
for child in pool.get_children():
|
||||
var at: float = (child as AudioStreamPlayer).get_playback_position()
|
||||
if at > most:
|
||||
most = at
|
||||
voice = child
|
||||
if voice == null:
|
||||
return
|
||||
voice.stream = stream
|
||||
voice.volume_db = float(LEVELS.get(id, -10.0))
|
||||
voice.pitch_scale = 1.0
|
||||
if id == SHOT:
|
||||
voice.pitch_scale = 1.0 + randf_range(-SHOT_PITCH_SPREAD, SHOT_PITCH_SPREAD)
|
||||
voice.play()
|
||||
1
audio/Sfx.gd.uid
Normal file
1
audio/Sfx.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://nhg0c87ie053
|
||||
169
audio/make_sfx.py
Normal file
169
audio/make_sfx.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generates the game's sound effects, from scratch.
|
||||
|
||||
Forgejo #146 asked for free SFX sources, and the ones worth using are listed
|
||||
in that ticket - Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound
|
||||
filtered to CC0. What ships here is synthesised instead, for one reason: a
|
||||
generated sound has no licence, no attribution to keep track of, and no
|
||||
binary in the repository whose provenance nobody can check a year from now.
|
||||
|
||||
It also means the sounds are DATA rather than assets. If a shot is too sharp
|
||||
or an explosion too short, the fix is a number in this file and a re-run,
|
||||
not a hunt through a sound library.
|
||||
|
||||
#150 and #151 are why the first pass was thrown away. "Shot sounds sound
|
||||
like a toy" and "death sounds from ship are really cheap", and both were
|
||||
fair: the shot was a bare descending sine, which is the sound every toy ray
|
||||
gun has ever made, and the death was one band of filtered noise with a sine
|
||||
under it - thin, and gone before the eye had finished with the explosion.
|
||||
|
||||
What the rewrite adds, and why each part is there:
|
||||
|
||||
* LAYERS, not a waveform. A real bang is a crack, then a body, then a
|
||||
tail, and they have different lengths. One envelope over one source
|
||||
cannot be any of those things.
|
||||
* A sub. Below about 80Hz is where "big" lives; without it an explosion is
|
||||
a hiss however loud it is played.
|
||||
* SATURATION. Soft-clipping folds in harmonics and stops the peak
|
||||
normalisation below from turning everything into the same quiet mush.
|
||||
* Detuned partials on the shot, so it is a machine rather than a flute -
|
||||
the single sine was the whole of the toy problem.
|
||||
|
||||
Run: python3 audio/make_sfx.py
|
||||
"""
|
||||
import math
|
||||
import random
|
||||
import struct
|
||||
import wave
|
||||
|
||||
RATE = 44100
|
||||
|
||||
|
||||
def _write(name, samples):
|
||||
peak = max(1e-9, max(abs(s) for s in samples))
|
||||
frames = b"".join(struct.pack("<h", int(max(-1.0, min(1.0, s / peak * 0.92)) * 32767))
|
||||
for s in samples)
|
||||
with wave.open("audio/%s.wav" % name, "w") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(RATE)
|
||||
w.writeframes(frames)
|
||||
print("audio/%s.wav %5.2fs %7d bytes" % (name, len(samples) / RATE, len(frames)))
|
||||
|
||||
|
||||
def _decay(i, n, power):
|
||||
return (1.0 - i / n) ** power
|
||||
|
||||
|
||||
def _attack(i, seconds):
|
||||
"""A few milliseconds of ramp, so nothing starts on a click."""
|
||||
a = max(1, int(RATE * seconds))
|
||||
return min(1.0, i / a)
|
||||
|
||||
|
||||
def _saturate(x, drive):
|
||||
"""Soft clip. Adds the harmonics that make a sound read as loud rather
|
||||
than merely being loud."""
|
||||
return math.tanh(x * drive) / math.tanh(drive)
|
||||
|
||||
|
||||
def _mix(*layers):
|
||||
n = max(len(layer) for layer in layers)
|
||||
out = [0.0] * n
|
||||
for layer in layers:
|
||||
for i, v in enumerate(layer):
|
||||
out[i] += v
|
||||
return out
|
||||
|
||||
|
||||
def _noise(n, seed, cutoff_at):
|
||||
"""White noise through a one-pole low-pass whose cutoff is a function of
|
||||
position. Sweeping the cutoff down is what turns a hiss into a roar."""
|
||||
rng = random.Random(seed)
|
||||
out = []
|
||||
last = 0.0
|
||||
for i in range(n):
|
||||
cutoff = cutoff_at(i / n)
|
||||
last += cutoff * (rng.uniform(-1.0, 1.0) - last)
|
||||
out.append(last)
|
||||
return out
|
||||
|
||||
|
||||
def _sweep(n, start_hz, end_hz, curve=1.0):
|
||||
out = []
|
||||
phase = 0.0
|
||||
for i in range(n):
|
||||
t = (i / n) ** curve
|
||||
hz = start_hz * (end_hz / start_hz) ** t
|
||||
phase += 2.0 * math.pi * hz / RATE
|
||||
out.append(math.sin(phase))
|
||||
return out
|
||||
|
||||
|
||||
def shot(seconds=0.22):
|
||||
"""An energy weapon discharging: a bright transient, three detuned
|
||||
partials falling together, and a short sub thump under it.
|
||||
|
||||
The partials are what stop it being a toy. One sine sliding down is a
|
||||
"pew"; three of them at slightly wrong ratios beat against each other
|
||||
and read as something with a mechanism inside it."""
|
||||
n = int(RATE * seconds)
|
||||
|
||||
crack = [v * _decay(i, n, 14.0) * 0.5
|
||||
for i, v in enumerate(_noise(n, 11, lambda t: 0.85 - 0.5 * t))]
|
||||
|
||||
body = [0.0] * n
|
||||
for ratio, level, curve in ((1.0, 0.6, 0.9), (1.49, 0.3, 1.05), (2.02, 0.18, 0.8)):
|
||||
part = _sweep(n, 900.0 * ratio, 190.0 * ratio, curve)
|
||||
for i, v in enumerate(part):
|
||||
body[i] += v * level * _decay(i, n, 2.6)
|
||||
|
||||
sub = [v * _decay(i, n, 3.2) * 0.55
|
||||
for i, v in enumerate(_sweep(n, 220.0, 60.0, 0.7))]
|
||||
|
||||
mixed = _mix(crack, body, sub)
|
||||
return [_saturate(v, 2.0) * _attack(i, 0.0015) for i, v in enumerate(mixed)]
|
||||
|
||||
|
||||
def explosion(seconds, sub_from, sub_to, roar_seed, debris_level, drive):
|
||||
"""A blast in three layers on three clocks: the crack is over in a
|
||||
fifth of a second, the roar runs most of the length, and the debris
|
||||
keeps falling afterwards."""
|
||||
n = int(RATE * seconds)
|
||||
|
||||
crack_n = int(n * 0.18)
|
||||
crack = [v * _decay(i, crack_n, 5.0) * 0.9
|
||||
for i, v in enumerate(_noise(crack_n, 3, lambda t: 0.95 - 0.55 * t))]
|
||||
crack += [0.0] * (n - crack_n)
|
||||
|
||||
roar = [v * _decay(i, n, 1.5)
|
||||
for i, v in enumerate(_noise(n, roar_seed, lambda t: 0.30 * (1.0 - t) ** 1.4 + 0.012))]
|
||||
|
||||
# The sub is the size of the thing. It falls slowly and outlasts the
|
||||
# crack by a long way.
|
||||
sub = [v * _decay(i, n, 1.15) * 1.25
|
||||
for i, v in enumerate(_sweep(n, sub_from, sub_to, 0.55))]
|
||||
|
||||
# Debris: sparse, dry ticks over the back half, so the sound has an
|
||||
# aftermath rather than simply stopping.
|
||||
rng = random.Random(roar_seed + 40)
|
||||
debris = [0.0] * n
|
||||
for _ in range(int(seconds * 34)):
|
||||
at = rng.randint(int(n * 0.25), n - 1)
|
||||
length = rng.randint(300, 1400)
|
||||
pitch = rng.uniform(140.0, 900.0)
|
||||
for k in range(min(length, n - at)):
|
||||
debris[at + k] += (math.sin(2.0 * math.pi * pitch * k / RATE)
|
||||
* (1.0 - k / length) ** 3.0
|
||||
* debris_level * rng.uniform(0.5, 1.0))
|
||||
|
||||
mixed = _mix(crack, roar, sub, debris)
|
||||
return [_saturate(v, drive) * _attack(i, 0.001) for i, v in enumerate(mixed)]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_write("sfx_shot", shot())
|
||||
# A vessel coming apart: bright, fast, with real low end under it.
|
||||
_write("sfx_ship_death", explosion(1.45, 260.0, 38.0, 5, 0.16, 2.6))
|
||||
# A structure: longer, deeper, and it keeps coming down afterwards.
|
||||
_write("sfx_building_death", explosion(2.3, 150.0, 24.0, 9, 0.24, 3.1))
|
||||
0
audio/masters/.gdignore
Normal file
0
audio/masters/.gdignore
Normal file
18
audio/masters/README.md
Normal file
18
audio/masters/README.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Audio masters
|
||||
|
||||
The source files the shipped audio is made from. Versioned, but **not part of
|
||||
the game**: the empty `.gdignore` beside this file tells Godot to skip this
|
||||
folder entirely, so nothing here is imported into `res://` and nothing here
|
||||
is carried into an export.
|
||||
|
||||
That split is the point. `SPACE_4.wav` is 59MB of 16-bit stereo; the theme
|
||||
the game actually loads is `../space_4.mp3`, the same 5m36s at LAME 160k in
|
||||
6.7MB. Shipping the WAV as well would put 59MB into every Android and web
|
||||
build for a file nothing opens.
|
||||
|
||||
| master | shipped as | made with |
|
||||
| --- | --- | --- |
|
||||
| `SPACE_4.wav` | `../space_4.mp3` | `ffmpeg -i SPACE_4.wav -c:a libmp3lame -b:a 160k ../space_4.mp3` |
|
||||
|
||||
The sound effects have no master here - they are generated outright by
|
||||
`../make_sfx.py`, which is their source.
|
||||
BIN
audio/masters/SPACE_4.wav
Normal file
BIN
audio/masters/SPACE_4.wav
Normal file
Binary file not shown.
BIN
audio/sfx_building_death.wav
Normal file
BIN
audio/sfx_building_death.wav
Normal file
Binary file not shown.
24
audio/sfx_building_death.wav.import
Normal file
24
audio/sfx_building_death.wav.import
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://cucu8do3xu62b"
|
||||
path="res://.godot/imported/sfx_building_death.wav-5a0672915182205beec566265df53340.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/sfx_building_death.wav"
|
||||
dest_files=["res://.godot/imported/sfx_building_death.wav-5a0672915182205beec566265df53340.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
audio/sfx_ship_death.wav
Normal file
BIN
audio/sfx_ship_death.wav
Normal file
Binary file not shown.
24
audio/sfx_ship_death.wav.import
Normal file
24
audio/sfx_ship_death.wav.import
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://cr6bt4w8n6d24"
|
||||
path="res://.godot/imported/sfx_ship_death.wav-0969b7a92e0e9fb802408222617228e0.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/sfx_ship_death.wav"
|
||||
dest_files=["res://.godot/imported/sfx_ship_death.wav-0969b7a92e0e9fb802408222617228e0.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
audio/sfx_shot.wav
Normal file
BIN
audio/sfx_shot.wav
Normal file
Binary file not shown.
24
audio/sfx_shot.wav.import
Normal file
24
audio/sfx_shot.wav.import
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://c0le6ax67g411"
|
||||
path="res://.godot/imported/sfx_shot.wav-cdb41c709bd7357ff93382562f01a47e.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/sfx_shot.wav"
|
||||
dest_files=["res://.godot/imported/sfx_shot.wav-cdb41c709bd7357ff93382562f01a47e.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
audio/space_4.mp3
Normal file
BIN
audio/space_4.mp3
Normal file
Binary file not shown.
19
audio/space_4.mp3.import
Normal file
19
audio/space_4.mp3.import
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[remap]
|
||||
|
||||
importer="mp3"
|
||||
type="AudioStreamMP3"
|
||||
uid="uid://bks0l0gbo0k48"
|
||||
path="res://.godot/imported/space_4.mp3-c5b265d027b77338d1ac353a03658ba9.mp3str"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/space_4.mp3"
|
||||
dest_files=["res://.godot/imported/space_4.mp3-c5b265d027b77338d1ac353a03658ba9.mp3str"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=true
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
|
|
@ -14,6 +14,18 @@ var building_type: String:
|
|||
set(value): definition_id = value
|
||||
|
||||
|
||||
# #99: drawn only once the structure has actually been hit. A full bar over
|
||||
# every building would be permanent clutter saying nothing, and the fleet
|
||||
# would be flying over a field of meters in peacetime.
|
||||
const HEALTH_BAR_WIDTH: float = 74.0
|
||||
const HEALTH_BAR_HEIGHT: float = 7.0
|
||||
const HEALTH_BAR_OFFSET: float = -78.0
|
||||
const HEALTH_FULL_COLOR: Color = Color(0.45, 0.9, 0.55)
|
||||
const HEALTH_HURT_COLOR: Color = Color(1.0, 0.35, 0.25)
|
||||
|
||||
var _drawn_health: float = 1.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("buildings")
|
||||
z_index = -1
|
||||
|
|
@ -24,6 +36,41 @@ func _ready() -> void:
|
|||
get_node("VisualHost").add_child(visual)
|
||||
|
||||
|
||||
func health_fraction() -> float:
|
||||
return GameState.building_health_fraction(building_id) if building_id != 0 else 1.0
|
||||
|
||||
|
||||
func is_damaged() -> bool:
|
||||
return health_fraction() < 0.999
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# Redraw only when the number moves. A building that is not being shot at
|
||||
# costs nothing, which is the same bargain Ship.gd's constant glow makes.
|
||||
var health: float = health_fraction()
|
||||
if is_equal_approx(health, _drawn_health):
|
||||
return
|
||||
_drawn_health = health
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not is_damaged():
|
||||
return
|
||||
# Above the structure rather than across it: the visual scenes all draw
|
||||
# around their own origin, and a bar laid over one would be read as part
|
||||
# of the building.
|
||||
var health: float = health_fraction()
|
||||
var left: float = -HEALTH_BAR_WIDTH * 0.5
|
||||
var top: float = HEALTH_BAR_OFFSET
|
||||
draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT),
|
||||
Color(0.02, 0.02, 0.06, 0.85))
|
||||
draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH * health, HEALTH_BAR_HEIGHT),
|
||||
HEALTH_HURT_COLOR.lerp(HEALTH_FULL_COLOR, health))
|
||||
draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT),
|
||||
Color(0.55, 0.78, 0.86, 0.55), false, 1.0)
|
||||
|
||||
|
||||
func available_actions() -> PackedStringArray:
|
||||
if visual == null:
|
||||
return PackedStringArray()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://buildings/Building.gd" id="1"]
|
||||
[ext_resource type="Script" path="res://buildings/BuildingHitbox.gd" id="2"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 72.0
|
||||
|
||||
[node name="Building" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="VisualHost" type="Node2D" parent="."]
|
||||
|
||||
[node name="Hitbox" type="Area2D" parent="."]
|
||||
script = ExtResource("2")
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="Hitbox"]
|
||||
shape = SubResource("1")
|
||||
|
|
|
|||
|
|
@ -4,7 +4,17 @@ extends Resource
|
|||
@export var definition_id: String
|
||||
@export var display_name: String
|
||||
@export_multiline var description: String
|
||||
# What it costs in ORE, and the whole price when it wants more than ore.
|
||||
#
|
||||
# #138: structures all charged rocks and nothing else, whatever they were.
|
||||
# The dictionary is the same shape VesselDefinition has used since gas
|
||||
# bombers and laser ships (build_cost / build_resource_costs): when it is
|
||||
# empty the price is `cost` in ore, and when it is set it IS the price, ore
|
||||
# included - and its ore entry must agree with `cost`, which the test pins,
|
||||
# because repair costs are quoted from `cost` alone (a repair drone carries
|
||||
# ore and nothing else).
|
||||
@export var cost: int
|
||||
@export var build_resource_costs: Dictionary = {}
|
||||
@export var action_icon: int
|
||||
@export var menu_icon: int
|
||||
@export var visual_scene: PackedScene
|
||||
|
|
@ -19,3 +29,7 @@ extends Resource
|
|||
@export var menu_cost_name: String
|
||||
@export_enum("space", "planet") var placement_mode: String = "space"
|
||||
@export var unique: bool = false
|
||||
# #99: what a wave has to chew through to level this. Data rather than one
|
||||
# constant for every structure - an extractor bolted to a rock is not the
|
||||
# mothership. See GameState.damage_building().
|
||||
@export var max_hp: int = 40
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
class_name BuildingDefinitions
|
||||
extends RefCounted
|
||||
|
||||
const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "extractor", "transport_hub", "ship_lab", "gas_burner"]
|
||||
const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "outpost", "planning_center", "spacial_workshop", "extractor", "transport_hub", "ship_lab", "gas_burner"]
|
||||
const DEFINITIONS: Dictionary = {
|
||||
"mothership": preload("res://buildings/MothershipBuildingDefinition.tres"),
|
||||
"ship_crafter": preload("res://buildings/CrafterBuildingDefinition.tres"),
|
||||
"command_center": preload("res://buildings/CommandBuildingDefinition.tres"),
|
||||
"outpost": preload("res://buildings/OutpostBuildingDefinition.tres"),
|
||||
"planning_center": preload("res://buildings/PlanningBuildingDefinition.tres"),
|
||||
"spacial_workshop": preload("res://buildings/WorkshopBuildingDefinition.tres"),
|
||||
"extractor": preload("res://buildings/ExtractorBuildingDefinition.tres"),
|
||||
"transport_hub": preload("res://buildings/TransportHubBuildingDefinition.tres"),
|
||||
"ship_lab": preload("res://buildings/ShipLabBuildingDefinition.tres"),
|
||||
|
|
|
|||
|
|
@ -22,9 +22,28 @@ var spawned: Array = []
|
|||
|
||||
|
||||
func _ready() -> void:
|
||||
# #99: the record is the truth, so the node follows it out. Connected
|
||||
# here rather than in Building.gd because the field owns `spawned` and
|
||||
# would otherwise be left holding a freed node.
|
||||
if not GameState.building_destroyed.is_connected(_on_building_destroyed):
|
||||
GameState.building_destroyed.connect(_on_building_destroyed)
|
||||
rebuild_all()
|
||||
|
||||
|
||||
func _on_building_destroyed(building_id: int, _type: String) -> void:
|
||||
var kept: Array = []
|
||||
for b in spawned:
|
||||
if not is_instance_valid(b):
|
||||
continue
|
||||
if int(b.building_id) == building_id:
|
||||
BuildingExplosionVFX.spawn(get_tree().root, b.global_position)
|
||||
Sfx.play(get_tree(), Sfx.BUILDING_DEATH)
|
||||
b.queue_free()
|
||||
continue
|
||||
kept.append(b)
|
||||
spawned = kept
|
||||
|
||||
|
||||
func rebuild_all() -> void:
|
||||
for b in spawned:
|
||||
if is_instance_valid(b):
|
||||
|
|
|
|||
54
buildings/BuildingHitbox.gd
Normal file
54
buildings/BuildingHitbox.gd
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
extends Area2D
|
||||
|
||||
# #101 (#97c): the part of a structure a wave can actually hit.
|
||||
#
|
||||
# A Building is a plain Node2D, so nothing could collide with it - enemies
|
||||
# flew through the mothership without noticing. This gives it a body in the
|
||||
# same shape the rest of the game already understands: an Area2D in a group,
|
||||
# exactly like a Ship or an asteroid, so EnemyProjectile needed one extra
|
||||
# branch rather than a new concept.
|
||||
#
|
||||
# Damage is applied on a TICK rather than on area_entered. Entry fires once,
|
||||
# which is right for a rammer - it dies on the first strike - but would let
|
||||
# a sniper that drifted inside sit there scratch-free for ever. On a tick,
|
||||
# an enemy parked on a structure is besieging it, which is what #97 asks
|
||||
# for.
|
||||
|
||||
const STRIKE_INTERVAL: float = 0.7
|
||||
|
||||
var _strike_timer: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("player_buildings")
|
||||
|
||||
|
||||
func building_id() -> int:
|
||||
var building: Node = get_parent()
|
||||
return 0 if building == null else int(building.building_id)
|
||||
|
||||
|
||||
# Called by EnemyProjectile, which knows nothing about buildings beyond this
|
||||
# name - the same call it makes on a Ship.
|
||||
func take_damage(amount: int) -> void:
|
||||
GameState.damage_building(building_id(), amount)
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_strike_timer -= delta
|
||||
if _strike_timer > 0.0:
|
||||
return
|
||||
_strike_timer = STRIKE_INTERVAL
|
||||
var id: int = building_id()
|
||||
if id == 0:
|
||||
return
|
||||
for area in get_overlapping_areas():
|
||||
if not is_instance_valid(area) or not area.is_in_group("enemies"):
|
||||
continue
|
||||
var damage: int = area.contact_damage() if area.has_method("contact_damage") else 6
|
||||
# The structure is told first, for the same reason Ship tells the
|
||||
# enemy first: resolving the enemy's own death can free it, and a
|
||||
# rammer must still land its hit on the way out.
|
||||
GameState.damage_building(id, damage)
|
||||
if area.has_method("collide_with_player_building"):
|
||||
area.collide_with_player_building()
|
||||
1
buildings/BuildingHitbox.gd.uid
Normal file
1
buildings/BuildingHitbox.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dggpwqg2fa62x
|
||||
|
|
@ -26,6 +26,46 @@ const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85)
|
|||
const SHIP_LAB_COLOR: Color = Color(0.55, 0.95, 0.9)
|
||||
const GAS_BURNER_COLOR: Color = Color(0.95, 0.35, 0.15)
|
||||
const GAS_BURNER_ENERGY_COLOR: Color = Color(0.85, 0.95, 0.15)
|
||||
# #102. Olive rather than another blue or amber: every existing structure is
|
||||
# a place you go to DO something, and this is the one you leave people at.
|
||||
const OUTPOST_COLOR: Color = Color(0.72, 0.86, 0.45)
|
||||
# #106. A cool grey-blue: this is the one structure that does no work at all,
|
||||
# it only decides what work the others do.
|
||||
const PLANNING_COLOR: Color = Color(0.62, 0.74, 0.92)
|
||||
# #116. Warm steel: the workshop is the only structure whose whole job is
|
||||
# looking after the others, and it should read as a yard rather than a plant.
|
||||
const WORKSHOP_COLOR: Color = Color(0.5, 0.95, 0.8)
|
||||
|
||||
# Workshop: an open service gantry with a cradle under it and drone berths
|
||||
# either side. Deliberately unenclosed - what it holds is other people's
|
||||
# buildings, arriving and leaving.
|
||||
const YARD_GANTRY_W: float = 96.0
|
||||
const YARD_GANTRY_H: float = 16.0
|
||||
const YARD_LEG_DROP: float = 58.0
|
||||
const YARD_CRADLE_R: float = 34.0
|
||||
const YARD_BERTH_X: float = 78.0
|
||||
|
||||
# Planning centre: a standing board with rows of tallies on it, flanked by
|
||||
# two slim pylons. Rectilinear on purpose - every other structure here is
|
||||
# radial or trussed, and this one is an office.
|
||||
const PLAN_BOARD_W: float = 86.0
|
||||
const PLAN_BOARD_H: float = 62.0
|
||||
const PLAN_ROW_COUNT: int = 4
|
||||
const PLAN_PYLON_X: float = 104.0
|
||||
const PLAN_PYLON_H: float = 54.0
|
||||
|
||||
# Outpost: a watch mast on a tripod footing, with two open berths beside it
|
||||
# where a garrison sits. Deliberately the least machinery of any structure
|
||||
# here - it makes nothing and refines nothing, it just holds a position -
|
||||
# and deliberately not radial, so it cannot be mistaken for a small hub.
|
||||
const OUTPOST_MAST_TOP: float = -84.0
|
||||
const OUTPOST_MAST_FOOT: float = 46.0
|
||||
const OUTPOST_HEAD_RADIUS: float = 20.0
|
||||
const OUTPOST_FOOT_SPAN: float = 62.0
|
||||
const OUTPOST_FOOT_DROP: float = 26.0
|
||||
const OUTPOST_BERTH_X: float = 74.0
|
||||
const OUTPOST_BERTH_RADIUS: float = 22.0
|
||||
const OUTPOST_VANE_COUNT: int = 3
|
||||
|
||||
# Ship lab: a test frame with a vessel held in it and instruments sweeping
|
||||
# over it. Reads as a workshop rather than a factory - the crafter already
|
||||
|
|
@ -112,7 +152,42 @@ var _flash_ring: NeonPiece = null
|
|||
var _wash: NeonPiece = null
|
||||
|
||||
|
||||
# #117: "The ship crafter and the ship lab should only animate when
|
||||
# something is being crafted in them."
|
||||
#
|
||||
# The clock stops rather than the drawing: every moving element is a NeonPiece
|
||||
# whose position is pushed from this clock, so a frozen clock leaves the
|
||||
# building standing exactly as it was with the cranes parked. Stopping
|
||||
# apply_animation() instead would leave them wherever the last frame put
|
||||
# them, which is not the same thing as idle.
|
||||
#
|
||||
# Default true: a mothership and a transport hub turn because that is what
|
||||
# they are, not because they have an order in hand.
|
||||
func is_working() -> bool:
|
||||
return true
|
||||
|
||||
|
||||
# The record this visual belongs to. The chain is Building -> VisualHost ->
|
||||
# this, and the id lives on the Building.
|
||||
# By tree lookup, not by the bare `GameState` identifier. These visual
|
||||
# scripts are reached through a BuildingDefinition's visual_scene, which is
|
||||
# preloaded while the definition catalog is still loading - early enough that
|
||||
# the autoload is not yet in the global class table, and a bare reference
|
||||
# fails to COMPILE the whole file. Same hazard GameState notes for WORLD_GEN.
|
||||
func _state() -> Node:
|
||||
var tree: SceneTree = get_tree()
|
||||
return tree.root.get_node_or_null("GameState") if tree != null else null
|
||||
|
||||
|
||||
func building_id() -> int:
|
||||
var host: Node = get_parent()
|
||||
var building: Node = host.get_parent() if host != null else null
|
||||
return int(building.building_id) if building != null and "building_id" in building else 0
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not is_working():
|
||||
return
|
||||
anim_time = fmod(anim_time + delta, CYCLE)
|
||||
phase_time += delta
|
||||
apply_animation()
|
||||
|
|
@ -359,6 +434,119 @@ func _draw_command_center() -> void:
|
|||
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9)
|
||||
|
||||
|
||||
func _draw_workshop() -> void:
|
||||
var c: Color = WORKSHOP_COLOR
|
||||
var lit: Color = Color(0.86, 1.0, 0.95)
|
||||
|
||||
# The gantry beam, and the legs it stands on.
|
||||
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
|
||||
Vector2(-YARD_GANTRY_W, -YARD_GANTRY_H - 40.0),
|
||||
Vector2(YARD_GANTRY_W, -YARD_GANTRY_H - 40.0),
|
||||
Vector2(YARD_GANTRY_W, -40.0), Vector2(-YARD_GANTRY_W, -40.0),
|
||||
]), c, 1.0)
|
||||
for side in [-1.0, 1.0]:
|
||||
var x: float = YARD_GANTRY_W * side * 0.82
|
||||
NeonDraw.draw_glowing_line(self, Vector2(x, -40.0),
|
||||
Vector2(x, YARD_LEG_DROP), c, 0.8, 0.42)
|
||||
# Cross-bracing, so it reads as a frame rather than two posts.
|
||||
NeonDraw.draw_glowing_line(self, Vector2(x, -40.0),
|
||||
Vector2(x * 0.35, YARD_LEG_DROP), c, 0.45, 0.26)
|
||||
|
||||
# The cradle: an open arc under the beam, with the thing being worked on
|
||||
# implied by the lit core rather than drawn.
|
||||
NeonDraw.draw_glowing_path(self, _arc(Vector2(0.0, 6.0), YARD_CRADLE_R,
|
||||
0.0, PI, 14), c, 0.85, 0.4)
|
||||
NeonDraw.draw_glowing_circle(self, Vector2(0.0, 6.0), 11.0, lit, 0.9)
|
||||
# A hoist line down from the beam to what is in the cradle.
|
||||
NeonDraw.draw_glowing_line(self, Vector2(0.0, -40.0), Vector2(0.0, -6.0),
|
||||
c, 0.6, 0.3)
|
||||
|
||||
# Drone berths, one either side, drawn as open brackets.
|
||||
for side in [-1.0, 1.0]:
|
||||
var berth: Vector2 = Vector2(YARD_BERTH_X * side, -70.0)
|
||||
NeonDraw.draw_glowing_path(self, PackedVector2Array([
|
||||
berth + Vector2(-14.0 * side, -12.0), berth + Vector2(6.0 * side, -12.0),
|
||||
berth + Vector2(6.0 * side, 12.0), berth + Vector2(-14.0 * side, 12.0),
|
||||
]), c, 0.7, 0.32)
|
||||
NeonDraw.draw_glowing_polygon(self, _diamond(berth, 7.0), lit, 0.85)
|
||||
|
||||
|
||||
func _draw_planning_center() -> void:
|
||||
var c: Color = PLANNING_COLOR
|
||||
var lit: Color = Color(0.88, 0.94, 1.0)
|
||||
|
||||
# The board.
|
||||
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
|
||||
Vector2(-PLAN_BOARD_W, -PLAN_BOARD_H), Vector2(PLAN_BOARD_W, -PLAN_BOARD_H),
|
||||
Vector2(PLAN_BOARD_W, PLAN_BOARD_H), Vector2(-PLAN_BOARD_W, PLAN_BOARD_H),
|
||||
]), c, 1.0)
|
||||
|
||||
# Rows of tallies at differing lengths - a plan being met unevenly, which
|
||||
# is what the building is for.
|
||||
var fractions: Array = [0.86, 0.42, 0.68, 0.24]
|
||||
for i in range(PLAN_ROW_COUNT):
|
||||
var y: float = lerpf(-PLAN_BOARD_H + 16.0, PLAN_BOARD_H - 16.0,
|
||||
float(i) / float(PLAN_ROW_COUNT - 1))
|
||||
var left: float = -PLAN_BOARD_W + 14.0
|
||||
var span: float = (PLAN_BOARD_W - 14.0) * 2.0
|
||||
NeonDraw.draw_glowing_line(self, Vector2(left, y),
|
||||
Vector2(left + span * float(fractions[i]), y), lit, 0.8, 0.34)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(left + span * float(fractions[i]), y),
|
||||
Vector2(left + span, y), c, 0.35, 0.22)
|
||||
|
||||
# Pylons holding it up, and a footing bar under the whole thing.
|
||||
for side in [-1.0, 1.0]:
|
||||
var x: float = PLAN_PYLON_X * side
|
||||
NeonDraw.draw_glowing_line(self, Vector2(x, -PLAN_PYLON_H),
|
||||
Vector2(x, PLAN_PYLON_H), c, 0.75, 0.4)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(PLAN_BOARD_W * side, 0.0),
|
||||
Vector2(x, 0.0), c, 0.55, 0.3)
|
||||
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2(x, -PLAN_PYLON_H), 9.0), c, 0.9)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(-PLAN_PYLON_X, PLAN_PYLON_H),
|
||||
Vector2(PLAN_PYLON_X, PLAN_PYLON_H), c, 0.5, 0.3)
|
||||
|
||||
|
||||
func _draw_outpost() -> void:
|
||||
var c: Color = OUTPOST_COLOR
|
||||
var lit: Color = Color(0.94, 0.99, 0.82)
|
||||
|
||||
# Tripod footing.
|
||||
var foot: Vector2 = Vector2(0.0, OUTPOST_MAST_FOOT)
|
||||
for side in [-1.0, 0.0, 1.0]:
|
||||
var toe: Vector2 = Vector2(OUTPOST_FOOT_SPAN * side, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP)
|
||||
NeonDraw.draw_glowing_line(self, foot, toe, c, 0.7, 0.42)
|
||||
NeonDraw.draw_glowing_line(self,
|
||||
Vector2(-OUTPOST_FOOT_SPAN, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP),
|
||||
Vector2(OUTPOST_FOOT_SPAN, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP), c, 0.55, 0.34)
|
||||
|
||||
# The mast, with rungs so it reads as climbable structure rather than a
|
||||
# drawn line.
|
||||
var top: Vector2 = Vector2(0.0, OUTPOST_MAST_TOP)
|
||||
NeonDraw.draw_glowing_line(self, foot, top, c, 0.9, 0.5)
|
||||
for i in range(4):
|
||||
var y: float = lerpf(OUTPOST_MAST_FOOT, OUTPOST_MAST_TOP + OUTPOST_HEAD_RADIUS,
|
||||
float(i + 1) / 5.0)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(-11.0, y), Vector2(11.0, y), c, 0.45, 0.28)
|
||||
|
||||
# Sensor head: a lit diamond inside a cage, with vanes looking outward.
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(top, OUTPOST_HEAD_RADIUS, 6, PI / 6.0), c, 1.0)
|
||||
NeonDraw.draw_glowing_polygon(self, _diamond(top, OUTPOST_HEAD_RADIUS * 0.5), lit, 0.95)
|
||||
for i in range(OUTPOST_VANE_COUNT):
|
||||
var a: float = PI + (float(i) / float(OUTPOST_VANE_COUNT - 1)) * PI
|
||||
var dir: Vector2 = Vector2(cos(a), sin(a))
|
||||
NeonDraw.draw_glowing_line(self, top + dir * OUTPOST_HEAD_RADIUS,
|
||||
top + dir * (OUTPOST_HEAD_RADIUS + 16.0), c, 0.7, 0.3)
|
||||
|
||||
# Two open berths. Open, not enclosed: what sits in them belongs to the
|
||||
# player and flies away, so they are cradles rather than hangars.
|
||||
for side in [-1.0, 1.0]:
|
||||
var berth: Vector2 = Vector2(OUTPOST_BERTH_X * side, 4.0)
|
||||
NeonDraw.draw_glowing_path(self, _arc(berth, OUTPOST_BERTH_RADIUS,
|
||||
-PI * 0.5, PI * 0.5, 10), c, 0.75, 0.36)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(12.0 * side, 4.0),
|
||||
berth - Vector2(OUTPOST_BERTH_RADIUS * side, 0.0), c, 0.6, 0.34)
|
||||
|
||||
|
||||
# --- mothership: a radial flagship ----------------------------------------
|
||||
# Not a big fighter. A mothership is the thing everything else orbits, so it
|
||||
# is built radially around a reactor core rather than pointed in a direction:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ definition_id = "command_center"
|
|||
display_name = "Command Center"
|
||||
description = "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it."
|
||||
cost = 15
|
||||
build_resource_costs = {"ore": 15, "gas": 6}
|
||||
action_icon = 10
|
||||
menu_icon = 10
|
||||
visual_scene = ExtResource("2")
|
||||
|
|
@ -18,3 +19,4 @@ map_phase = 0.785398
|
|||
menu_button_name = "CommandButton"
|
||||
menu_label_name = "CommandLabel"
|
||||
menu_cost_name = "CommandCost"
|
||||
max_hp = 70
|
||||
|
|
|
|||
|
|
@ -17,3 +17,4 @@ map_radius = 7.0
|
|||
menu_button_name = "CrafterButton"
|
||||
menu_label_name = "CrafterLabel"
|
||||
menu_cost_name = "CrafterCost"
|
||||
max_hp = 90
|
||||
|
|
|
|||
|
|
@ -6,6 +6,16 @@ func _ready() -> void:
|
|||
_build_crafter_pieces()
|
||||
queue_redraw()
|
||||
|
||||
# #117: the line runs when there is something on it. An idle crafter with
|
||||
# its cranes swinging read as a factory working on nothing.
|
||||
func is_working() -> bool:
|
||||
var id: int = building_id()
|
||||
if id == 0:
|
||||
return false
|
||||
var state: Node = _state()
|
||||
return state != null and state.pending_orders(id) > 0
|
||||
|
||||
|
||||
func apply_animation() -> void:
|
||||
_animate_crafter()
|
||||
|
||||
|
|
|
|||
|
|
@ -14,3 +14,4 @@ menu_button_name = "ExtractorButton"
|
|||
menu_label_name = "ExtractorLabel"
|
||||
menu_cost_name = "ExtractorCost"
|
||||
placement_mode = "planet"
|
||||
max_hp = 40
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=4 format=3]
|
||||
[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"]
|
||||
[gd_resource type="Resource" script_class="BuildingDefinition" format=3 uid="uid://cia6vbju65sbe"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://d10s0frtxiw62" path="res://buildings/BuildingDefinition.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://buildings/GasBurnerBuildingVisual.tscn" id="2"]
|
||||
[ext_resource type="Script" path="res://buildings/BuildingLogisticsProfile.gd" id="3"]
|
||||
[ext_resource type="Script" uid="uid://cydp18abylrgd" path="res://buildings/BuildingLogisticsProfile.gd" id="3"]
|
||||
|
||||
[sub_resource type="Resource" id="Resource_logistics"]
|
||||
script = ExtResource("3")
|
||||
|
|
@ -19,7 +20,11 @@ script = ExtResource("1")
|
|||
definition_id = "gas_burner"
|
||||
display_name = "Gas Burner"
|
||||
description = "Burns gas into energy at its own site. Hub transporters keep it stocked with gas and collect the energy it makes."
|
||||
cost = 30
|
||||
cost = 40
|
||||
build_resource_costs = {
|
||||
"gas": 22,
|
||||
"ore": 40
|
||||
}
|
||||
action_icon = 33
|
||||
menu_icon = 33
|
||||
visual_scene = ExtResource("2")
|
||||
|
|
@ -27,8 +32,7 @@ capabilities = PackedStringArray("gas_burner")
|
|||
logistics = SubResource("Resource_logistics")
|
||||
map_color = Color(0.95, 0.35, 0.15, 1)
|
||||
map_sides = 5
|
||||
map_radius = 7.0
|
||||
map_phase = 0.0
|
||||
menu_button_name = "GasBurnerButton"
|
||||
menu_label_name = "GasBurnerLabel"
|
||||
menu_cost_name = "GasBurnerCost"
|
||||
max_hp = 60
|
||||
|
|
|
|||
|
|
@ -19,3 +19,4 @@ menu_button_name = "MothershipButton"
|
|||
menu_label_name = "MothershipLabel"
|
||||
menu_cost_name = "MothershipCost"
|
||||
unique = true
|
||||
max_hp = 240
|
||||
|
|
|
|||
21
buildings/OutpostBuildingDefinition.tres
Normal file
21
buildings/OutpostBuildingDefinition.tres
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
|
||||
[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://buildings/OutpostBuildingVisual.tscn" id="2"]
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
definition_id = "outpost"
|
||||
display_name = "Outpost"
|
||||
description = "A forward position for the fleet to garrison. Vessels stationed here hold station and fight what comes at them, without you flying back."
|
||||
cost = 30
|
||||
build_resource_costs = {"ore": 30, "gas": 16}
|
||||
action_icon = 40
|
||||
menu_icon = 40
|
||||
visual_scene = ExtResource("2")
|
||||
capabilities = PackedStringArray("garrison")
|
||||
map_color = Color(0.72, 0.86, 0.45, 1)
|
||||
map_sides = 3
|
||||
map_radius = 7.0
|
||||
menu_button_name = "OutpostButton"
|
||||
menu_label_name = "OutpostLabel"
|
||||
menu_cost_name = "OutpostCost"
|
||||
max_hp = 120
|
||||
13
buildings/OutpostBuildingVisual.gd
Normal file
13
buildings/OutpostBuildingVisual.gd
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
extends "res://buildings/BuildingVisualBase.gd"
|
||||
|
||||
# Drawn once and never redrawn, like the command centre: nothing about a
|
||||
# watch mast animates, and per-frame redraws of art that never changes were
|
||||
# a real framerate cost earlier on.
|
||||
|
||||
func _ready() -> void:
|
||||
set_process(false)
|
||||
queue_redraw()
|
||||
|
||||
func _draw() -> void:
|
||||
register_draw()
|
||||
_draw_outpost()
|
||||
1
buildings/OutpostBuildingVisual.gd.uid
Normal file
1
buildings/OutpostBuildingVisual.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://orxnu20av3xv
|
||||
11
buildings/OutpostBuildingVisual.tscn
Normal file
11
buildings/OutpostBuildingVisual.tscn
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://buildings/OutpostBuildingVisual.gd" id="1"]
|
||||
[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"]
|
||||
|
||||
[node name="Visual" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="ActionProvider" type="Node" parent="."]
|
||||
script = ExtResource("2")
|
||||
actions = PackedStringArray("garrison", "transfer")
|
||||
23
buildings/PlanningBuildingDefinition.tres
Normal file
23
buildings/PlanningBuildingDefinition.tres
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
|
||||
[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://buildings/PlanningBuildingVisual.tscn" id="2"]
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
definition_id = "planning_center"
|
||||
display_name = "Planning Center"
|
||||
description = "Holds standing orders for the whole game: say how many of each vessel should always exist, and the ship crafters are kept working until they do."
|
||||
cost = 50
|
||||
build_resource_costs = {"ore": 50, "gas": 30}
|
||||
action_icon = 41
|
||||
menu_icon = 41
|
||||
visual_scene = ExtResource("2")
|
||||
capabilities = PackedStringArray("planning")
|
||||
map_color = Color(0.62, 0.74, 0.92, 1)
|
||||
map_sides = 4
|
||||
map_radius = 7.5
|
||||
map_phase = 0.785398
|
||||
menu_button_name = "PlanningButton"
|
||||
menu_label_name = "PlanningLabel"
|
||||
menu_cost_name = "PlanningCost"
|
||||
unique = true
|
||||
max_hp = 80
|
||||
13
buildings/PlanningBuildingVisual.gd
Normal file
13
buildings/PlanningBuildingVisual.gd
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
extends "res://buildings/BuildingVisualBase.gd"
|
||||
|
||||
# Drawn once, like the command centre and the outpost: a board of numbers
|
||||
# does not animate, and per-frame redraws of art that never changes were a
|
||||
# real framerate cost earlier on.
|
||||
|
||||
func _ready() -> void:
|
||||
set_process(false)
|
||||
queue_redraw()
|
||||
|
||||
func _draw() -> void:
|
||||
register_draw()
|
||||
_draw_planning_center()
|
||||
1
buildings/PlanningBuildingVisual.gd.uid
Normal file
1
buildings/PlanningBuildingVisual.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://utiitnb7womc
|
||||
11
buildings/PlanningBuildingVisual.tscn
Normal file
11
buildings/PlanningBuildingVisual.tscn
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://buildings/PlanningBuildingVisual.gd" id="1"]
|
||||
[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"]
|
||||
|
||||
[node name="Visual" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="ActionProvider" type="Node" parent="."]
|
||||
script = ExtResource("2")
|
||||
actions = PackedStringArray("planning", "transfer")
|
||||
|
|
@ -6,7 +6,8 @@ script = ExtResource("1")
|
|||
definition_id = "ship_lab"
|
||||
display_name = "Ship Lab"
|
||||
description = "Fly the fleet onto it to upgrade each vessel type and unlock new vessel designs."
|
||||
cost = 35
|
||||
cost = 30
|
||||
build_resource_costs = {"ore": 30, "gas": 18}
|
||||
action_icon = 13
|
||||
menu_icon = 13
|
||||
visual_scene = ExtResource("2")
|
||||
|
|
@ -18,3 +19,4 @@ map_phase = -1.570796
|
|||
menu_button_name = "ShipLabButton"
|
||||
menu_label_name = "ShipLabLabel"
|
||||
menu_cost_name = "ShipLabCost"
|
||||
max_hp = 90
|
||||
|
|
|
|||
|
|
@ -4,6 +4,16 @@ func _ready() -> void:
|
|||
_build_lab_pieces()
|
||||
queue_redraw()
|
||||
|
||||
# #117: the instruments sweep while something is being researched, and are
|
||||
# parked otherwise.
|
||||
func is_working() -> bool:
|
||||
var id: int = building_id()
|
||||
if id == 0:
|
||||
return false
|
||||
var state: Node = _state()
|
||||
return state != null and not state.lab_research_order(id).is_empty()
|
||||
|
||||
|
||||
func apply_animation() -> void:
|
||||
_animate_ship_lab()
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ script = ExtResource("1")
|
|||
definition_id = "transport_hub"
|
||||
display_name = "Transport Hub"
|
||||
description = "Stations transport vessels. They collect extracted resources and supply buildings that are waiting on materials."
|
||||
cost = 30
|
||||
cost = 28
|
||||
build_resource_costs = {"ore": 28, "gas": 20}
|
||||
action_icon = 12
|
||||
menu_icon = 12
|
||||
visual_scene = ExtResource("2")
|
||||
|
|
@ -18,3 +19,4 @@ map_phase = 0.392699
|
|||
menu_button_name = "TransportHubButton"
|
||||
menu_label_name = "TransportHubLabel"
|
||||
menu_cost_name = "TransportHubCost"
|
||||
max_hp = 80
|
||||
|
|
|
|||
21
buildings/WorkshopBuildingDefinition.tres
Normal file
21
buildings/WorkshopBuildingDefinition.tres
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
|
||||
[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://buildings/WorkshopBuildingVisual.tscn" id="2"]
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
definition_id = "spacial_workshop"
|
||||
display_name = "Spacial Workshop"
|
||||
description = "Sends repair drones out to damaged structures. Repairs cost ore in proportion to the damage, delivered here by hub transporters."
|
||||
cost = 50
|
||||
build_resource_costs = {"ore": 50, "gas": 28, "energy": 18}
|
||||
action_icon = 42
|
||||
menu_icon = 42
|
||||
visual_scene = ExtResource("2")
|
||||
capabilities = PackedStringArray("repair")
|
||||
map_color = Color(0.5, 0.95, 0.8, 1)
|
||||
map_sides = 5
|
||||
map_radius = 7.0
|
||||
menu_button_name = "WorkshopButton"
|
||||
menu_label_name = "WorkshopLabel"
|
||||
menu_cost_name = "WorkshopCost"
|
||||
max_hp = 70
|
||||
13
buildings/WorkshopBuildingVisual.gd
Normal file
13
buildings/WorkshopBuildingVisual.gd
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
extends "res://buildings/BuildingVisualBase.gd"
|
||||
|
||||
# Drawn once, like the command centre and the outpost. #117 makes the
|
||||
# animated buildings stop when idle; this one has nothing that moves, so
|
||||
# there is nothing to gate.
|
||||
|
||||
func _ready() -> void:
|
||||
set_process(false)
|
||||
queue_redraw()
|
||||
|
||||
func _draw() -> void:
|
||||
register_draw()
|
||||
_draw_workshop()
|
||||
1
buildings/WorkshopBuildingVisual.gd.uid
Normal file
1
buildings/WorkshopBuildingVisual.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ctwgqsgr2bsfs
|
||||
11
buildings/WorkshopBuildingVisual.tscn
Normal file
11
buildings/WorkshopBuildingVisual.tscn
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://buildings/WorkshopBuildingVisual.gd" id="1"]
|
||||
[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"]
|
||||
|
||||
[node name="Visual" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="ActionProvider" type="Node" parent="."]
|
||||
script = ExtResource("2")
|
||||
actions = PackedStringArray("transfer")
|
||||
|
|
@ -33,7 +33,10 @@ func _ready() -> void:
|
|||
|
||||
func _process(delta: float) -> void:
|
||||
var had_visuals: bool = aiming or _has_effect_visuals()
|
||||
button.visible = fleet.has_capability(_component_id())
|
||||
# #122: "there's no action button available, only movements". Checked
|
||||
# here rather than by the director reaching in, because this line runs
|
||||
# every frame and would put the button straight back.
|
||||
button.visible = fleet.has_capability(_component_id()) and not GameState.intro_active
|
||||
button.enabled = _next_ready_ship() != null and _can_afford_shot()
|
||||
if not button.visible and aiming:
|
||||
_cancel_aim()
|
||||
|
|
|
|||
|
|
@ -32,14 +32,14 @@ const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd")
|
|||
get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"),
|
||||
get_node("../UI/LabMenu/VesselC"), get_node("../UI/LabMenu/VesselD"),
|
||||
get_node("../UI/LabMenu/VesselE"), get_node("../UI/LabMenu/VesselF"),
|
||||
get_node("../UI/LabMenu/VesselG"),
|
||||
get_node("../UI/LabMenu/VesselG"), get_node("../UI/LabMenu/VesselH"),
|
||||
]
|
||||
@onready var lab_vessel_label: Label = get_node("../UI/LabMenu/VesselLabel")
|
||||
@onready var lab_vessel_labels: Array = [
|
||||
get_node("../UI/LabMenu/VesselALabel"), get_node("../UI/LabMenu/VesselBLabel"),
|
||||
get_node("../UI/LabMenu/VesselCLabel"), get_node("../UI/LabMenu/VesselDLabel"),
|
||||
get_node("../UI/LabMenu/VesselELabel"), get_node("../UI/LabMenu/VesselFLabel"),
|
||||
get_node("../UI/LabMenu/VesselGLabel"),
|
||||
get_node("../UI/LabMenu/VesselGLabel"), get_node("../UI/LabMenu/VesselHLabel"),
|
||||
]
|
||||
@onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel")
|
||||
@onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel")
|
||||
|
|
@ -109,7 +109,12 @@ var lab_building_id: int = 0
|
|||
@onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup")
|
||||
@onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel")
|
||||
@onready var info_message: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/MessageLabel")
|
||||
@onready var info_cost: NeonNumber = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber")
|
||||
# Typed Control, not PriceTag, and deliberately. PriceTag reads the GameState
|
||||
# autoload, and an `extends SceneTree` test script that so much as names a
|
||||
# class like that fails to compile - GameState is not an identifier in that
|
||||
# context - which then poisons the scene's copy of the same script. The nodes
|
||||
# are duck-typed everywhere for that reason; see tests/test_menu_price_unification.gd.
|
||||
@onready var info_cost: Control = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber")
|
||||
@onready var info_build_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/BuildButton")
|
||||
@onready var info_close_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/CloseButton")
|
||||
|
||||
|
|
@ -156,10 +161,28 @@ var lab_building_id: int = 0
|
|||
get_node("../UI/LabMenu/Bar"),
|
||||
]
|
||||
|
||||
# #103: the outpost's own panel. garrison_building_id is remembered for the
|
||||
# same reason craft_building_id is - _process() clears current_building while
|
||||
# a menu is open, so by the next frame the panel would not know which outpost
|
||||
# it belongs to and every button in it would bail out at its `== 0` guard.
|
||||
@onready var garrison_menu: Control = get_node("../UI/GarrisonMenu")
|
||||
@onready var garrison_panel: Control = get_node("../UI/GarrisonMenu/Panel")
|
||||
@onready var garrison_status: Label = get_node("../UI/GarrisonMenu/StatusLabel")
|
||||
@onready var garrison_close_button: IconButton = get_node("../UI/GarrisonMenu/CloseButton")
|
||||
var garrison_building_id: int = 0
|
||||
|
||||
# #106: the standing-order board. No building id is remembered because the
|
||||
# plan is global - there is one plan for the whole game, and a second
|
||||
# planning centre is another place to read it rather than a competing one.
|
||||
@onready var planning_menu: Control = get_node("../UI/PlanningMenu")
|
||||
@onready var planning_panel: Control = get_node("../UI/PlanningMenu/Panel")
|
||||
@onready var planning_status: Label = get_node("../UI/PlanningMenu/StatusLabel")
|
||||
@onready var planning_close_button: IconButton = get_node("../UI/PlanningMenu/CloseButton")
|
||||
|
||||
@onready var skill_menu: Control = get_node("../UI/SkillMenu")
|
||||
@onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree")
|
||||
@onready var skill_detail: Label = get_node("../UI/SkillMenu/DetailLabel")
|
||||
@onready var skill_cost: NeonNumber = get_node("../UI/SkillMenu/CostNumber")
|
||||
@onready var skill_cost: Control = get_node("../UI/SkillMenu/CostNumber")
|
||||
@onready var skill_status: Label = get_node("../UI/SkillMenu/StatusLabel")
|
||||
@onready var skill_close_button: IconButton = get_node("../UI/SkillMenu/CloseButton")
|
||||
|
||||
|
|
@ -249,8 +272,15 @@ func _seat_hud_columns() -> void:
|
|||
func _ready() -> void:
|
||||
_seat_hud_columns()
|
||||
_layout_hangar()
|
||||
_build_craft_price_strips()
|
||||
_build_lab_price_tags()
|
||||
_layout_craft_menu()
|
||||
build_button.pressed.connect(_on_build_pressed)
|
||||
build_close_button.pressed.connect(_close_build_menu)
|
||||
garrison_close_button.pressed.connect(_close_garrison_menu)
|
||||
garrison_panel.changed.connect(_refresh_garrison)
|
||||
planning_close_button.pressed.connect(_close_planning_menu)
|
||||
planning_panel.changed.connect(_refresh_planning)
|
||||
_configure_build_menu_entries()
|
||||
lab_close_button.pressed.connect(func(): lab_menu.visible = false)
|
||||
lab_buy_button.pressed.connect(_on_lab_buy)
|
||||
|
|
@ -301,6 +331,13 @@ func _ready() -> void:
|
|||
transfer_button.visible = false
|
||||
hangar_button.visible = false
|
||||
hangar_menu.visible = false
|
||||
# #128: the prologue's "movement and nothing else" used to be applied by
|
||||
# _process on the first frame. A dialog now stops the tree, and the
|
||||
# prologue opens ON one - so that first frame never came and every action
|
||||
# button was left on screen behind the opening line. Applied here as well,
|
||||
# where it does not depend on the game running.
|
||||
if GameState.intro_active:
|
||||
_hide_all_actions()
|
||||
_layout_context_buttons()
|
||||
|
||||
|
||||
|
|
@ -311,12 +348,12 @@ func _configure_build_menu_entries() -> void:
|
|||
continue
|
||||
var button: IconButton = get_node("../UI/BuildMenu/%s" % definition.menu_button_name)
|
||||
var label: Label = get_node("../UI/BuildMenu/%s" % definition.menu_label_name)
|
||||
var cost_number: NeonNumber = get_node("../UI/BuildMenu/%s" % definition.menu_cost_name)
|
||||
var cost_tag: Control = get_node("../UI/BuildMenu/%s" % definition.menu_cost_name)
|
||||
button.icon = definition.menu_icon
|
||||
button.pressed.connect(_show_building_info.bind(String(definition.definition_id)))
|
||||
label.text = definition.display_name
|
||||
cost_number.amount = definition.cost
|
||||
build_menu_entries.append([definition.definition_id, button, label, cost_number, definition])
|
||||
cost_tag.show_price(GameState.building_costs(String(definition.definition_id)))
|
||||
build_menu_entries.append([definition.definition_id, button, label, cost_tag, definition])
|
||||
|
||||
|
||||
# True while a modal panel owns the shared context controls. A plain building
|
||||
|
|
@ -343,6 +380,14 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
skill_tree.handle_touch(event)
|
||||
elif event is InputEventScreenDrag:
|
||||
skill_tree.handle_drag(event)
|
||||
elif event is InputEventMouseButton:
|
||||
# #93: a click is not a touch. The project no longer emulates one
|
||||
# from the other, so without this branch the whole menu is inert
|
||||
# under a mouse - nothing selects, nothing scrolls, and there is
|
||||
# no other way to research anything on a desktop.
|
||||
skill_tree.handle_mouse_button(event)
|
||||
elif event is InputEventMouseMotion:
|
||||
skill_tree.handle_mouse_motion(event)
|
||||
# Handled either way. A touch that lands on the panel but misses a
|
||||
# node is still a touch ON THE PANEL - letting it through moved the
|
||||
# fleet and pinch-zoomed the world behind the menu, because
|
||||
|
|
@ -355,6 +400,14 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
formation_editor.handle_touch(event)
|
||||
elif event is InputEventScreenDrag:
|
||||
formation_editor.handle_drag(event)
|
||||
elif event is InputEventMouseButton:
|
||||
# #121: a click is not a touch. Same routing gap as #93's
|
||||
# research tree - the panel is transparent to GUI input so the
|
||||
# raw handlers can see the drag, which means nothing turns a
|
||||
# click into one on the way.
|
||||
formation_editor.handle_mouse_button(event)
|
||||
elif event is InputEventMouseMotion:
|
||||
formation_editor.handle_mouse_motion(event)
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if build_menu.visible or craft_menu.visible or world_map.visible or lab_menu.visible \
|
||||
|
|
@ -367,6 +420,19 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
func _process(delta: float) -> void:
|
||||
_check_fleet_wipe()
|
||||
|
||||
# #122: the prologue leaves the player movement and nothing else. Handled
|
||||
# at the top, before any of the context detection below, so nothing can
|
||||
# turn a button back on further down.
|
||||
if GameState.intro_active:
|
||||
_hide_all_actions()
|
||||
return
|
||||
# Build is permanently under the resource counter (see
|
||||
# _layout_context_buttons) - it is the one action button nothing else
|
||||
# ever hides, so the prologue taking it away is the only thing that has
|
||||
# to give it back.
|
||||
if not build_button.visible:
|
||||
build_button.visible = true
|
||||
|
||||
if build_menu.visible:
|
||||
# Keep the greyed state live while the menu is open, so flying onto
|
||||
# a planet lights the option up without reopening it.
|
||||
|
|
@ -578,6 +644,14 @@ func _on_action_pressed() -> void:
|
|||
elif action == "formation":
|
||||
formation_menu.visible = true
|
||||
formation_editor.queue_redraw()
|
||||
elif action == "planning":
|
||||
planning_menu.visible = true
|
||||
_refresh_planning()
|
||||
elif action == "garrison":
|
||||
garrison_building_id = current_building.building_id
|
||||
garrison_panel.building_id = garrison_building_id
|
||||
garrison_menu.visible = true
|
||||
_refresh_garrison()
|
||||
|
||||
|
||||
func _on_build_pressed() -> void:
|
||||
|
|
@ -592,24 +666,158 @@ func _planet_under_fleet() -> Node2D:
|
|||
return planet_field.get_planet_fleet_is_on()
|
||||
|
||||
|
||||
# Grid geometry, shared with the layout below. Rows of two, and the menu is
|
||||
# rebuilt from whatever is currently researched (#21) rather than being a
|
||||
# fixed arrangement with holes punched in it.
|
||||
const MENU_ROW_TOPS: Array = [-300.0, -77.0, 146.0, 369.0]
|
||||
const MENU_COL_X: float = 140.0
|
||||
# Grid geometry, shared with the layout below. The menu is rebuilt from
|
||||
# whatever is currently researched (#21) rather than being a fixed
|
||||
# arrangement with holes punched in it.
|
||||
#
|
||||
# Two profiles, because the grid ran out of room. #54 already tightened the
|
||||
# rows to 223px to fit a fourth, explicitly to avoid a scrollable menu, and a
|
||||
# ninth structure (#106's planning centre) needs a FIFTH row that will not
|
||||
# fit at any spacing a button, a label and a cost can share. So past eight
|
||||
# entries the grid goes to three columns instead, which takes nine into three
|
||||
# rows - fewer than it uses today, not more. Narrower columns mean narrower
|
||||
# labels, so those get a taller box to wrap into.
|
||||
# The build menu's grid.
|
||||
#
|
||||
# #136: rows used to be a written-down list of tops, read with
|
||||
# `tops[mini(row, tops.size() - 1)]`. That clamp silently stacked whatever
|
||||
# would not fit - the gas burner drawn exactly on top of the transport hub.
|
||||
#
|
||||
# #144: and the band the rows live in was written down too, as a pair of
|
||||
# constants measured against a 1280-tall canvas. The header above it is
|
||||
# anchored to the TOP of the panel and the grid is anchored to its CENTRE,
|
||||
# so the gap between them is half the canvas height - which means the
|
||||
# constants were only ever right at one window shape. At 1080 tall the grid
|
||||
# started 4px ABOVE the status line and drew straight through it, which is
|
||||
# what the ticket reported.
|
||||
#
|
||||
# So nothing about the geometry is written down against a screen size any
|
||||
# more. The band is measured from the header that is actually there, the row
|
||||
# pitch closes up to fit it (never below a row's own height, which is what
|
||||
# makes overlapping impossible rather than unlikely), and the column count
|
||||
# rises until the rows fit - a short panel gets a wide grid instead of one
|
||||
# that runs off the bottom of it.
|
||||
const MENU_BUTTON_HALF: float = 64.0
|
||||
const MENU_BUTTON_H: float = 100.0
|
||||
const MENU_LABEL_HALF: float = 150.0
|
||||
const MENU_LABEL_H: float = 52.0
|
||||
const MENU_COST_HALF: float = 60.0
|
||||
const MENU_COST_H: float = 44.0
|
||||
# Gaps from each row's own top to where the label/cost start, sized to the
|
||||
# shrunk MENU_*_H values above (#54: fitting a 4th row meant tightening
|
||||
# MENU_ROW_TOPS' spacing to 223px, less than the original 256px a row's
|
||||
# button+label+cost needed - shrinking those closed the gap without
|
||||
# resorting to a scrollable menu).
|
||||
const MENU_COST_HALF: float = 74.0
|
||||
# #147: a price is one resource per line, so a row has to leave room for the
|
||||
# most any structure asks for - three, at the workshop.
|
||||
const MENU_COST_H: float = 86.0
|
||||
const MENU_LABEL_OFFSET: float = 106.0
|
||||
const MENU_COST_OFFSET: float = 162.0
|
||||
const MENU_LABEL_GAP: float = 4.0
|
||||
|
||||
# The widest a column is ever given, however much room there is: past this
|
||||
# the entries stop reading as a grid and start reading as a scattering.
|
||||
const MENU_NATURAL_SPACING: float = 280.0
|
||||
# The narrowest a column may be squeezed to - a button plus a little air.
|
||||
const MENU_MIN_SPACING: float = MENU_BUTTON_HALF * 2.0 + 12.0
|
||||
# A caption gets most of its column, not all of it.
|
||||
const MENU_LABEL_SHARE: float = 0.46
|
||||
# Below this a caption is assumed to need two lines.
|
||||
const MENU_ONE_LINE_HALF: float = 120.0
|
||||
const MENU_LABEL_H_ONE: float = 52.0
|
||||
const MENU_LABEL_H_TWO: float = 68.0
|
||||
|
||||
const MENU_SIDE_MARGIN: float = 24.0
|
||||
const MENU_HEADER_GAP: float = 16.0
|
||||
const MENU_BOTTOM_MARGIN: float = 28.0
|
||||
const MENU_MAX_COLUMNS: int = 6
|
||||
|
||||
|
||||
# A centre-anchored grid living under a top-anchored header, which is the
|
||||
# shape both the build menu and the craft menu have (#144, #147). The gap
|
||||
# between the two depends on the panel's HEIGHT, so it can never be written
|
||||
# down as a constant - it has to be measured from the header that is
|
||||
# actually there.
|
||||
func grid_band(header_bottom: float, panel_height: float) -> Vector2:
|
||||
var half: float = panel_height * 0.5
|
||||
if half <= 0.0:
|
||||
half = 640.0
|
||||
return Vector2(header_bottom + MENU_HEADER_GAP - half, half - MENU_BOTTOM_MARGIN)
|
||||
|
||||
|
||||
# Where one row of such a grid starts. The pitch closes up to fit the band
|
||||
# and is floored at the row's own height, so rows can tighten but never land
|
||||
# on each other.
|
||||
func grid_row_top(row: int, rows: int, row_height: float, band: Vector2,
|
||||
natural_pitch: float) -> float:
|
||||
var pitch: float = natural_pitch
|
||||
if rows > 1:
|
||||
pitch = minf(pitch, (band.y - band.x - row_height) / float(rows - 1))
|
||||
return band.x + maxf(pitch, row_height) * float(row)
|
||||
|
||||
|
||||
# The vertical band the grid may occupy, in the menu's own centre-anchored
|
||||
# space: (top, bottom).
|
||||
#
|
||||
# MEASURED, not written down (#144). The header is anchored to the top of the
|
||||
# panel and the grid to its centre, so the two only agree at one canvas
|
||||
# height - and the pair of constants this replaces happened to be the ones
|
||||
# for 1280. Reading the status line's own bottom edge and the panel's own
|
||||
# height makes the answer right at every window shape, including the one the
|
||||
# player is actually running.
|
||||
func menu_band() -> Vector2:
|
||||
return grid_band(build_status.offset_bottom, build_menu.size.y)
|
||||
|
||||
|
||||
# How wide one column is given. Never wider than looks like a grid, never
|
||||
# narrower than a button, and otherwise whatever the panel has room for.
|
||||
func menu_column_spacing(columns: int) -> float:
|
||||
var room: float = build_menu.size.x - MENU_SIDE_MARGIN * 2.0
|
||||
if room <= 0.0:
|
||||
room = 672.0
|
||||
return maxf(MENU_MIN_SPACING, minf(MENU_NATURAL_SPACING, room / float(columns)))
|
||||
|
||||
|
||||
func menu_label_half(columns: int) -> float:
|
||||
return menu_column_spacing(columns) * MENU_LABEL_SHARE
|
||||
|
||||
|
||||
func menu_label_height(columns: int) -> float:
|
||||
return MENU_LABEL_H_ONE if menu_label_half(columns) >= MENU_ONE_LINE_HALF \
|
||||
else MENU_LABEL_H_TWO
|
||||
|
||||
|
||||
func menu_cost_offset(columns: int) -> float:
|
||||
return MENU_LABEL_OFFSET + menu_label_height(columns) + MENU_LABEL_GAP
|
||||
|
||||
|
||||
# How tall one row is, end to end - the cost strip is the last thing in it.
|
||||
func menu_row_height(columns: int) -> float:
|
||||
return menu_cost_offset(columns) + MENU_COST_H
|
||||
|
||||
|
||||
# The fewest columns whose rows fit the band. Two is the shape the menu is
|
||||
# meant to have; wider is what a full catalog - or a short window - forces.
|
||||
func menu_columns(count: int) -> int:
|
||||
var widest: int = maxi(mini(MENU_MAX_COLUMNS,
|
||||
int(floor((build_menu.size.x - MENU_SIDE_MARGIN * 2.0) / MENU_MIN_SPACING))), 2)
|
||||
for columns in range(2, widest + 1):
|
||||
if _rows_fit(int(ceil(float(count) / float(columns))), columns):
|
||||
return columns
|
||||
return widest
|
||||
|
||||
|
||||
func menu_rows(count: int) -> int:
|
||||
var columns: int = menu_columns(count)
|
||||
return maxi(int(ceil(float(count) / float(columns))), 1)
|
||||
|
||||
|
||||
func _rows_fit(rows: int, columns: int) -> bool:
|
||||
var height: float = menu_row_height(columns)
|
||||
var band: Vector2 = menu_band()
|
||||
if rows <= 1:
|
||||
return band.y - band.x >= height
|
||||
return (band.y - band.x - height) / float(rows - 1) >= height
|
||||
|
||||
|
||||
# Where one row starts. Top of the band, with the pitch closed up when it is
|
||||
# tight - but never below the row height, so two rows cannot land on each
|
||||
# other however many structures there are, or however short the window (#136,
|
||||
# #144).
|
||||
func menu_row_top(row: int, rows: int, columns: int) -> float:
|
||||
return grid_row_top(row, rows, menu_row_height(columns), menu_band(),
|
||||
MENU_NATURAL_SPACING)
|
||||
|
||||
|
||||
func _place_entry(node: Control, cx: float, top: float, half: float, height: float) -> void:
|
||||
|
|
@ -634,16 +842,24 @@ func _layout_build_menu() -> void:
|
|||
if visible:
|
||||
shown.append(entry)
|
||||
|
||||
var columns: int = menu_columns(shown.size())
|
||||
var rows: int = menu_rows(shown.size())
|
||||
var spacing: float = menu_column_spacing(columns)
|
||||
var label_half: float = menu_label_half(columns)
|
||||
var label_h: float = menu_label_height(columns)
|
||||
var cost_offset: float = menu_cost_offset(columns)
|
||||
|
||||
for i in range(shown.size()):
|
||||
var row: int = i / 2
|
||||
# Centred when it is the only one on its row, rather than hanging in
|
||||
# the left column looking like something is missing beside it.
|
||||
var alone: bool = (i == shown.size() - 1) and (i % 2 == 0)
|
||||
var cx: float = 0.0 if alone else (-MENU_COL_X if i % 2 == 0 else MENU_COL_X)
|
||||
var top: float = MENU_ROW_TOPS[mini(row, MENU_ROW_TOPS.size() - 1)]
|
||||
var row: int = i / columns
|
||||
# A short last row is CENTRED rather than left-packed, so it does not
|
||||
# read as a row with something missing beside it.
|
||||
var in_row: int = mini(shown.size() - row * columns, columns)
|
||||
var col: int = i % columns
|
||||
var cx: float = (float(col) - (float(in_row) - 1.0) * 0.5) * spacing
|
||||
var top: float = menu_row_top(row, rows, columns)
|
||||
_place_entry(shown[i][1], cx, top, MENU_BUTTON_HALF, MENU_BUTTON_H)
|
||||
_place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, MENU_LABEL_HALF, MENU_LABEL_H)
|
||||
_place_entry(shown[i][3], cx, top + MENU_COST_OFFSET, MENU_COST_HALF, MENU_COST_H)
|
||||
_place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, label_half, label_h)
|
||||
_place_entry(shown[i][3], cx, top + cost_offset, MENU_COST_HALF, MENU_COST_H)
|
||||
|
||||
|
||||
func _refresh_build_status() -> void:
|
||||
|
|
@ -667,6 +883,11 @@ func _refresh_build_status() -> void:
|
|||
func _refresh_build_option_states() -> void:
|
||||
for entry in build_menu_entries:
|
||||
var definition: Resource = entry[4]
|
||||
# #138: a price now names several resources, and what the player
|
||||
# holds of each is on the tag - so it is re-stated here, live, the
|
||||
# way the craft menu's already was. Re-stating the same price is
|
||||
# cheap; PriceTag only rebuilds when the price itself changes.
|
||||
entry[3].show_price(GameState.building_costs(String(definition.definition_id)))
|
||||
var enabled: bool = GameState.is_building_unlocked(definition.definition_id)
|
||||
if definition.placement_mode == "planet":
|
||||
var planet: Node2D = _planet_under_fleet()
|
||||
|
|
@ -675,6 +896,21 @@ func _refresh_build_option_states() -> void:
|
|||
entry[1].queue_redraw()
|
||||
|
||||
|
||||
# Everything the player can PRESS, as opposed to steer with. Used by the
|
||||
# prologue (#122), which takes them all away at once.
|
||||
func _hide_all_actions() -> void:
|
||||
current_building = null
|
||||
pending_building = null
|
||||
current_extractor_index = -1
|
||||
pending_extractor_index = -1
|
||||
current_landing_planet = null
|
||||
context_actions_expanded = false
|
||||
for control in [build_button, action_button, building_action_button,
|
||||
landing_button, transfer_button, hangar_button]:
|
||||
if control != null:
|
||||
control.visible = false
|
||||
|
||||
|
||||
func _close_build_menu() -> void:
|
||||
# The popup is a child panel of this menu; leaving it "open" while its
|
||||
# parent is hidden is what blocked every button in the game.
|
||||
|
|
@ -683,16 +919,63 @@ func _close_build_menu() -> void:
|
|||
build_menu.visible = false
|
||||
|
||||
|
||||
# --- the outpost's garrison panel (#103) -----------------------------------
|
||||
|
||||
func _close_garrison_menu() -> void:
|
||||
garrison_menu.visible = false
|
||||
garrison_building_id = 0
|
||||
|
||||
|
||||
func _refresh_garrison() -> void:
|
||||
if garrison_building_id == 0:
|
||||
return
|
||||
garrison_panel.building_id = garrison_building_id
|
||||
garrison_panel.refresh()
|
||||
var ordered: int = GameState.garrison_desired_total(garrison_building_id)
|
||||
var crewed: int = GameState.garrison_crew(garrison_building_id).size()
|
||||
if ordered == 0:
|
||||
garrison_status.text = "No vessels ordered. Only armed vessels can hold a position."
|
||||
elif crewed >= ordered:
|
||||
garrison_status.text = "Garrison at full strength"
|
||||
else:
|
||||
garrison_status.text = "%d of %d berths crewed - vessels are sent from the mothership as they become available" \
|
||||
% [crewed, ordered]
|
||||
|
||||
|
||||
# --- the planning centre's standing orders (#106) ---------------------------
|
||||
|
||||
func _close_planning_menu() -> void:
|
||||
planning_menu.visible = false
|
||||
|
||||
|
||||
func _refresh_planning() -> void:
|
||||
planning_panel.refresh()
|
||||
var short: PackedStringArray = PackedStringArray()
|
||||
for vessel_type in GameState.planned_vessel_types():
|
||||
var missing: int = GameState.vessel_shortfall(String(vessel_type))
|
||||
if missing > 0:
|
||||
short.append("%d %s" % [missing, GameState.vessel_name(String(vessel_type))])
|
||||
if short.is_empty():
|
||||
planning_status.text = "Every standing order is met or already being built"
|
||||
else:
|
||||
planning_status.text = "Short: %s" % ", ".join(short)
|
||||
|
||||
|
||||
# The ORE half of a price, which is all some callers want (the info popup's
|
||||
# headline, the tests). costs_of() below is the whole of it.
|
||||
func cost_of(type: String) -> int:
|
||||
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
|
||||
return int(definition.cost) if definition != null else 0
|
||||
return int(costs_of(type).get(GameState.RESOURCE_ORE, 0))
|
||||
|
||||
|
||||
func costs_of(type: String) -> Dictionary:
|
||||
return GameState.building_costs(type)
|
||||
|
||||
|
||||
func _show_building_info(type: String) -> void:
|
||||
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
|
||||
if definition == null:
|
||||
return
|
||||
var cost: int = int(definition.cost)
|
||||
var costs: Dictionary = GameState.building_costs(type)
|
||||
var lines: Array = [definition.description]
|
||||
var can_build: bool = true
|
||||
|
||||
|
|
@ -723,13 +1006,15 @@ func _show_building_info(type: String) -> void:
|
|||
# The same pooled test the build itself uses. Asking the bank here while
|
||||
# charging bank-plus-hold below would hide the build button for something
|
||||
# the player can in fact afford.
|
||||
if can_build and not GameState.can_afford(cost):
|
||||
lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.available_ore()])
|
||||
if can_build and not GameState.can_afford_resources(costs):
|
||||
# #137: no sentence about the shortfall. The price tag below carries
|
||||
# what is held over what is wanted, in red - saying it twice, in two
|
||||
# different shapes, is what the ticket is about.
|
||||
can_build = false
|
||||
|
||||
info_title.text = definition.display_name
|
||||
info_message.text = "\n\n".join(lines)
|
||||
info_cost.amount = cost
|
||||
info_cost.show_price(costs)
|
||||
info_build_button.visible = can_build
|
||||
pending_build_type = type if can_build else ""
|
||||
info_popup.visible = true
|
||||
|
|
@ -771,30 +1056,44 @@ func _confirm_build() -> void:
|
|||
# glyph/HUD rendering the moment it produced anything.
|
||||
build_status.text = "%s has nothing to extract" % planet.planet_name
|
||||
return
|
||||
if not GameState.spend(definition.cost):
|
||||
build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.available_ore()]
|
||||
if not GameState.spend_resources(GameState.building_costs(type)):
|
||||
build_status.text = _cannot_afford(type)
|
||||
return
|
||||
GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type, planet.planet_id)
|
||||
build_menu.visible = false
|
||||
return
|
||||
|
||||
if definition.unique and GameState.has_building_of_type(type):
|
||||
if not GameState.can_afford(definition.cost):
|
||||
build_status.text = "Not enough rocks (need %d, have %d)" % [
|
||||
definition.cost, GameState.available_ore()
|
||||
]
|
||||
if not GameState.can_afford_resources(GameState.building_costs(type)):
|
||||
build_status.text = _cannot_afford(type)
|
||||
return
|
||||
# Only now is the old one destroyed - never before the new one is
|
||||
# known to be affordable, or the player would be left with none.
|
||||
GameState.remove_buildings_of_type(type)
|
||||
building_field.rebuild_all()
|
||||
|
||||
_try_place(type, cost_of(type))
|
||||
_try_place(type)
|
||||
|
||||
|
||||
func _try_place(type: String, cost: int) -> void:
|
||||
if not GameState.spend(cost):
|
||||
build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.available_ore()]
|
||||
# #138: a structure can want more than rocks, so the refusal has to name
|
||||
# which of them is short rather than always saying "rocks". Built from the
|
||||
# price itself, so a cost gaining a resource needs nothing here.
|
||||
func _cannot_afford(type: String) -> String:
|
||||
var missing: Array = []
|
||||
for resource_type in GameState.building_costs(type):
|
||||
var id: String = String(resource_type)
|
||||
var want: int = int(GameState.building_costs(type)[resource_type])
|
||||
var have: int = GameState.available_resource(id)
|
||||
if have < want:
|
||||
missing.append("%s (need %d, have %d)" % [GameState.resource_name(id), want, have])
|
||||
if missing.is_empty():
|
||||
return ""
|
||||
return "Not enough %s" % " and ".join(missing)
|
||||
|
||||
|
||||
func _try_place(type: String) -> void:
|
||||
if not GameState.spend_resources(GameState.building_costs(type)):
|
||||
build_status.text = _cannot_afford(type)
|
||||
return
|
||||
building_field.place(type, fleet.get_centroid())
|
||||
build_menu.visible = false
|
||||
|
|
@ -820,7 +1119,7 @@ func _refresh_skill_menu(id: String = "") -> void:
|
|||
# Nothing chosen yet: point the player at the tree rather than
|
||||
# leaving the panel blank, which reads as broken.
|
||||
skill_detail.text = "Select a node to see what it unlocks."
|
||||
skill_cost.amount = 0
|
||||
skill_cost.show_price({})
|
||||
skill_status.text = "Fleet capacity %d · Research points %d" \
|
||||
% [GameState.max_fleet_size(), GameState.research_points]
|
||||
return
|
||||
|
|
@ -829,25 +1128,26 @@ func _refresh_skill_menu(id: String = "") -> void:
|
|||
if node.is_empty():
|
||||
return
|
||||
var points_needed: int = GameState.skill_research_point_cost(id)
|
||||
# #134: the explanation and the price no longer share a column. The text
|
||||
# is the left half of the band and the tag is the right half, so a long
|
||||
# description can grow without ending up on top of the number - which is
|
||||
# what it used to do, the two being centred over each other.
|
||||
#
|
||||
# Both shortfalls it used to spell out - ore and research points - are
|
||||
# now entries on the tag, held-over-needed, in red when short. One place,
|
||||
# and it says WHICH one is missing without a sentence per case.
|
||||
skill_detail.text = "%s\n%s" % [node.name, node.desc]
|
||||
if points_needed > 0:
|
||||
skill_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, points_needed]
|
||||
skill_cost.amount = int(node.cost)
|
||||
skill_cost.show_price(GameState.skill_costs(id), points_needed)
|
||||
|
||||
if GameState.is_unlocked(id):
|
||||
skill_status.text = "Researched"
|
||||
elif not GameState.prerequisite_met(id):
|
||||
skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name
|
||||
elif not GameState.can_afford(int(node.cost)):
|
||||
# available_ore(), not the bank alone: research is paid for the same
|
||||
# pooled way as everything else, so quoting the bank here would tell
|
||||
# the player they cannot afford something they can.
|
||||
skill_status.text = "Not enough rocks - you have %d" % GameState.available_ore()
|
||||
elif GameState.research_points < points_needed:
|
||||
# #63: a distinct reason from the ore one above - conflating them
|
||||
# would leave the player trying to fix the wrong shortage.
|
||||
skill_status.text = "Not enough research points - you have %d, need %d" \
|
||||
% [GameState.research_points, points_needed]
|
||||
elif not skill_cost.is_affordable():
|
||||
# Deliberately blank. The red on the tag is the whole message; the
|
||||
# line here used to repeat it in words and, being centred, repeat it
|
||||
# on top of the number as well.
|
||||
skill_status.text = ""
|
||||
else:
|
||||
# The button that used to appear here was the only thing saying "this
|
||||
# is buyable". With it gone the line has to say so itself, or an
|
||||
|
|
@ -897,6 +1197,114 @@ func _format_missing_costs(costs: Dictionary) -> String:
|
|||
return " · ".join(parts)
|
||||
|
||||
|
||||
# --- the craft menu's own layout (#111) ------------------------------------
|
||||
#
|
||||
# Six vessels were authored as one row of 65px buttons with 105px captions
|
||||
# overlapping each other, which left "Energy Propulser" clipped and the price
|
||||
# crushed onto a second line under it. Laid out here instead, in two rows of
|
||||
# three, so a cell is wide enough for the name to breathe - and the price is
|
||||
# glyphs rather than words (see PriceTag, which every menu now shares).
|
||||
#
|
||||
# #147: "prices are far from the button they are about, and when many
|
||||
# different resources are needed the costs overlap." Both were true, and
|
||||
# both were the same cause - a cell laid out with more slack than the row
|
||||
# pitch had room for. The caption box was 62px for a one-line name, which
|
||||
# pushed the price 70px clear of the button it belonged to; and the price
|
||||
# was given 184px of width in a 190px column, so a two-resource price ran
|
||||
# out of its own box and into its neighbour's.
|
||||
#
|
||||
# The caption is one line tall now and the price sits directly under it, and
|
||||
# PriceTag puts each resource on its own line when they will not fit across
|
||||
# (which in a column this narrow means whenever there is more than one).
|
||||
# Rows are placed off the measured band rather than a written-down pair of
|
||||
# tops, for the same reason the build menu's are - see grid_band().
|
||||
const CRAFT_COLUMNS: int = 3
|
||||
const CRAFT_ROW_PITCH: float = 260.0
|
||||
const CRAFT_COL_X: float = 190.0
|
||||
const CRAFT_BUTTON_HALF: float = 56.0
|
||||
const CRAFT_BUTTON_H: float = 100.0
|
||||
const CRAFT_LABEL_HALF: float = 92.0
|
||||
const CRAFT_LABEL_H: float = 44.0
|
||||
const CRAFT_LABEL_OFFSET: float = 106.0
|
||||
const CRAFT_PRICE_HALF: float = 92.0
|
||||
# Room for the tallest price any vessel has, stacked. Read from the tag
|
||||
# rather than assumed, so a vessel priced in three things still fits.
|
||||
const CRAFT_PRICE_H: float = 86.0
|
||||
const CRAFT_PRICE_OFFSET: float = 154.0
|
||||
|
||||
var craft_price_strips: Array = []
|
||||
# #137: the ship lab's own price tags, built here rather than authored in the
|
||||
# scene for the same reason the craft menu's are - four identical widgets
|
||||
# differing only in where they sit.
|
||||
var lab_price_tags: Array = []
|
||||
|
||||
|
||||
func _build_craft_price_strips() -> void:
|
||||
var strip_script: Script = load("res://ui/PriceTag.gd")
|
||||
for i in range(craft_vessel_buttons.size()):
|
||||
var strip: Control = strip_script.new()
|
||||
strip.name = "CraftPrice%d" % i
|
||||
# Centre anchors, matching the buttons and captions authored in the
|
||||
# scene - _place_entry writes offsets, and an offset means something
|
||||
# different against a top-left anchor.
|
||||
strip.anchor_left = 0.5
|
||||
strip.anchor_right = 0.5
|
||||
strip.anchor_top = 0.5
|
||||
strip.anchor_bottom = 0.5
|
||||
craft_menu.add_child(strip)
|
||||
craft_price_strips.append(strip)
|
||||
|
||||
|
||||
# One tag per upgrade slot, seated in the gap the caption's second line used
|
||||
# to occupy. The captions above them shrink to one line to make room, so the
|
||||
# option block is the same height it was and the row below it does not move.
|
||||
const LAB_TAG_H: float = 38.0
|
||||
const LAB_LABEL_H: float = 40.0
|
||||
|
||||
|
||||
func _build_lab_price_tags() -> void:
|
||||
var tag_script: Script = load("res://ui/PriceTag.gd")
|
||||
for i in range(lab_upgrade_labels.size()):
|
||||
var label: Control = lab_upgrade_labels[i]
|
||||
var tag: Control = tag_script.new()
|
||||
tag.name = "LabPrice%d" % i
|
||||
tag.anchor_left = label.anchor_left
|
||||
tag.anchor_right = label.anchor_right
|
||||
tag.anchor_top = label.anchor_top
|
||||
tag.anchor_bottom = label.anchor_bottom
|
||||
tag.offset_left = label.offset_left
|
||||
tag.offset_right = label.offset_right
|
||||
# Under the caption, which is cut to a single line here so the two
|
||||
# together occupy what the two-line caption used to.
|
||||
label.offset_bottom = label.offset_top + LAB_LABEL_H
|
||||
tag.offset_top = label.offset_bottom + 2.0
|
||||
tag.offset_bottom = tag.offset_top + LAB_TAG_H
|
||||
lab_menu.add_child(tag)
|
||||
lab_price_tags.append(tag)
|
||||
|
||||
|
||||
func craft_row_height() -> float:
|
||||
return CRAFT_PRICE_OFFSET + CRAFT_PRICE_H
|
||||
|
||||
|
||||
func _layout_craft_menu() -> void:
|
||||
var rows: int = maxi(int(ceil(float(craft_vessel_buttons.size())
|
||||
/ float(CRAFT_COLUMNS))), 1)
|
||||
var band: Vector2 = grid_band(craft_status.offset_bottom, craft_menu.size.y)
|
||||
for i in range(craft_vessel_buttons.size()):
|
||||
var row: int = i / CRAFT_COLUMNS
|
||||
var column: int = i % CRAFT_COLUMNS
|
||||
var cx: float = (float(column) - 1.0) * CRAFT_COL_X
|
||||
var top: float = grid_row_top(row, rows, craft_row_height(), band,
|
||||
CRAFT_ROW_PITCH)
|
||||
_place_entry(craft_vessel_buttons[i], cx, top, CRAFT_BUTTON_HALF, CRAFT_BUTTON_H)
|
||||
_place_entry(craft_vessel_labels[i], cx, top + CRAFT_LABEL_OFFSET,
|
||||
CRAFT_LABEL_HALF, CRAFT_LABEL_H)
|
||||
if i < craft_price_strips.size():
|
||||
_place_entry(craft_price_strips[i], cx, top + CRAFT_PRICE_OFFSET,
|
||||
CRAFT_PRICE_HALF, CRAFT_PRICE_H)
|
||||
|
||||
|
||||
func _refresh_craft_status() -> void:
|
||||
# Prices live with the buttons they describe. The top of the screen is
|
||||
# intentionally reserved for the shared resource bar and a short action
|
||||
|
|
@ -906,8 +1314,11 @@ func _refresh_craft_status() -> void:
|
|||
for i in range(mini(definitions.size(), craft_vessel_buttons.size())):
|
||||
var definition: Resource = definitions[i]
|
||||
var button: IconButton = craft_vessel_buttons[i]
|
||||
craft_vessel_labels[i].text = "%s\n%s" % [definition.display_name,
|
||||
_format_costs(GameState.product_costs(definition.definition_id))]
|
||||
# #111: the name alone. The price is the strip under it, in glyphs.
|
||||
craft_vessel_labels[i].text = String(definition.display_name)
|
||||
if i < craft_price_strips.size():
|
||||
craft_price_strips[i].set_costs(
|
||||
GameState.product_costs(definition.definition_id))
|
||||
button.icon = definition.icon
|
||||
button.glow_color = definition.glow_color
|
||||
button.enabled = GameState.vessel_is_unlocked(definition.definition_id)
|
||||
|
|
@ -1018,19 +1429,28 @@ func _refresh_lab() -> void:
|
|||
var visible: bool = i < options.size()
|
||||
lab_upgrade_buttons[i].visible = visible
|
||||
lab_upgrade_labels[i].visible = visible
|
||||
if i < lab_price_tags.size():
|
||||
lab_price_tags[i].visible = visible
|
||||
if not visible:
|
||||
continue
|
||||
var option: Dictionary = options[i]
|
||||
var price_text: String = _format_costs(option.costs if bool(option.unlock) \
|
||||
else GameState.research_costs(option.id))
|
||||
var point_cost: int = GameState.research_point_cost(String(option.id))
|
||||
if point_cost > 0:
|
||||
price_text += " · %d RP" % point_cost
|
||||
if not bool(option.unlock) and GameState.upgrade_maxed(option.id):
|
||||
price_text = "MAX"
|
||||
# #137: the price is a tag under the option, the way it is under a
|
||||
# build option and under a vessel in the craft menu. It used to be a
|
||||
# second line of the caption - "25 ore · 10 gas · 2 RP" - which is
|
||||
# the same information in a shape no other menu used.
|
||||
var maxed: bool = not bool(option.unlock) and GameState.upgrade_maxed(option.id)
|
||||
var level_note: String = "" if bool(option.unlock) else " %d/%d" % [
|
||||
GameState.upgrade_level(option.id), int(GameState.upgrade_def(option.id).max_level)]
|
||||
lab_upgrade_labels[i].text = "%s%s\n%s" % [option.name, level_note, price_text]
|
||||
lab_upgrade_labels[i].text = "%s%s" % [option.name, level_note]
|
||||
if i < lab_price_tags.size():
|
||||
var tag: Control = lab_price_tags[i]
|
||||
tag.visible = not maxed
|
||||
if maxed:
|
||||
tag.show_price({})
|
||||
else:
|
||||
tag.show_price(option.costs if bool(option.unlock) \
|
||||
else GameState.research_costs(option.id),
|
||||
GameState.research_point_cost(String(option.id)))
|
||||
lab_upgrade_buttons[i].icon = _upgrade_icon(option)
|
||||
lab_upgrade_buttons[i].glow_color = _vessel_colour(lab_vessel)
|
||||
lab_upgrade_buttons[i].highlighted = i == lab_choice
|
||||
|
|
@ -1262,6 +1682,15 @@ func _hangar_types() -> Array:
|
|||
# each cell room, and the second row costs nothing: the lower half of the
|
||||
# menu was empty.
|
||||
const HANGAR_COLUMNS: int = 3
|
||||
# #120: room for a two-line caption under each icon, and air around the rule
|
||||
# that separates the two halves.
|
||||
const HANGAR_LABEL_GAP: float = 2.0
|
||||
const HANGAR_DIVIDER_GAP: float = 10.0
|
||||
|
||||
# Where the rule between "available" and "in the fleet" is drawn. Computed by
|
||||
# the layout and read by the menu's own _draw, so the line cannot drift away
|
||||
# from the sections it divides.
|
||||
var hangar_divider_y: float = 0.0
|
||||
|
||||
|
||||
# Arranges both hangar sections as grids sized from the space actually
|
||||
|
|
@ -1286,10 +1715,18 @@ func _layout_hangar() -> void:
|
|||
|
||||
var cell_width: float = width / float(columns)
|
||||
var cell_height: float = section_height / float(rows)
|
||||
# Squarer than the authored 75x120: a grid cell is wider than it is tall
|
||||
# here, so height is what constrains the icon.
|
||||
var slot_size: float = minf(cell_height * 0.58, cell_width * 0.42)
|
||||
var label_height: float = maxf(cell_height - slot_size - 6.0, 1.0)
|
||||
# #120: the icon gives up height so the caption under it has somewhere to
|
||||
# go. Vessel names are two and three words long and were wrapping into a
|
||||
# box barely tall enough for one line, so the second line was clipped and
|
||||
# a "Gas Bomber" read as "Gas".
|
||||
var slot_size: float = minf(cell_height * 0.46, cell_width * 0.40)
|
||||
# #143: TWO gaps, not one - above the caption and below it. With a single
|
||||
# gap the box ended exactly HANGAR_LABEL_GAP past the bottom of its own
|
||||
# cell, which is inside the next row's icon. Two pixels of overhang is
|
||||
# not much on its own, but it is the difference between "the caption
|
||||
# cannot reach the row below" and "the caption starts in it".
|
||||
var label_height: float = maxf(
|
||||
cell_height - slot_size - HANGAR_LABEL_GAP * 2.0, 1.0)
|
||||
|
||||
_layout_hangar_section(hangar_available_slots, hangar_available_labels,
|
||||
top, columns, cell_width, cell_height, slot_size, label_height, width)
|
||||
|
|
@ -1297,10 +1734,15 @@ func _layout_hangar() -> void:
|
|||
# by subtracting from the second section's top put it back inside the
|
||||
# first one, straight over its bottom row.
|
||||
var available_bottom: float = top + section_height
|
||||
hangar_fleet_title.position.y = available_bottom + 4.0
|
||||
var fleet_top: float = available_bottom + title_height + 8.0
|
||||
# #120: a rule between the two halves. They are different questions -
|
||||
# what is parked here, and what is flying - and with only a heading
|
||||
# between them the grids read as one long list.
|
||||
hangar_divider_y = available_bottom + HANGAR_DIVIDER_GAP
|
||||
hangar_fleet_title.position.y = hangar_divider_y + HANGAR_DIVIDER_GAP
|
||||
var fleet_top: float = hangar_fleet_title.position.y + title_height + 8.0
|
||||
_layout_hangar_section(hangar_fleet_slots, hangar_fleet_labels,
|
||||
fleet_top, columns, cell_width, cell_height, slot_size, label_height, width)
|
||||
hangar_menu.queue_redraw()
|
||||
|
||||
|
||||
func _layout_hangar_section(slots: Array, labels: Array, top: float, columns: int,
|
||||
|
|
@ -1324,11 +1766,40 @@ func _layout_hangar_section(slots: Array, labels: Array, top: float, columns: in
|
|||
slot.position = Vector2(cell_x + (cell_width - slot_size) * 0.5, cell_y)
|
||||
slot.size = Vector2(slot_size, slot_size)
|
||||
var label: Label = labels[i]
|
||||
label.position = Vector2(cell_x, cell_y + slot_size + 4.0)
|
||||
label.position = Vector2(cell_x, cell_y + slot_size + HANGAR_LABEL_GAP)
|
||||
label.size = Vector2(cell_width, label_height)
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_TOP
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
# #143: a caption is "Gas Bomber\n2 available" - two lines before
|
||||
# autowrap, three after it in a narrow column - at a font size fixed
|
||||
# in the scene. A Label does not clip by default, so the third line
|
||||
# was drawn straight over the icon of the row below it.
|
||||
#
|
||||
# Two things, and both are wanted. The size is fitted to the box the
|
||||
# layout above worked out, so the text has somewhere to go; and the
|
||||
# box clips, so however the catalog grows - a longer vessel name, a
|
||||
# fourth line - nothing can ever reach the next row again. The first
|
||||
# is what keeps it legible, the second is what keeps it correct.
|
||||
label.add_theme_font_size_override("font_size",
|
||||
_hangar_caption_size(label_height))
|
||||
label.clip_text = true
|
||||
|
||||
|
||||
# The largest caption that still fits HANGAR_CAPTION_LINES lines into the
|
||||
# height the grid could spare, within what is readable at arm's length.
|
||||
const HANGAR_CAPTION_LINES: float = 3.0
|
||||
const HANGAR_CAPTION_MIN: int = 13
|
||||
const HANGAR_CAPTION_MAX: int = 22
|
||||
# Godot lays a line out taller than its nominal font size; measured against
|
||||
# the project font rather than guessed, this is the ratio that keeps three
|
||||
# lines inside the box.
|
||||
const HANGAR_LINE_RATIO: float = 1.25
|
||||
|
||||
|
||||
func _hangar_caption_size(label_height: float) -> int:
|
||||
return clampi(int(label_height / (HANGAR_CAPTION_LINES * HANGAR_LINE_RATIO)),
|
||||
HANGAR_CAPTION_MIN, HANGAR_CAPTION_MAX)
|
||||
|
||||
|
||||
func _refresh_hangar() -> void:
|
||||
|
|
|
|||
|
|
@ -102,6 +102,10 @@ var keyboard_steering: bool = false
|
|||
|
||||
|
||||
func _ready() -> void:
|
||||
# #148: the main theme, which outlives this scene - see Music.ensure().
|
||||
# This script is the root of BOTH the space scene and the planet
|
||||
# surface, so one call here covers the whole game.
|
||||
Music.ensure(get_tree())
|
||||
if joystick != null:
|
||||
joystick.direction_changed.connect(_on_joystick_direction)
|
||||
|
||||
|
|
@ -119,6 +123,30 @@ func _held_pivot_direction() -> float:
|
|||
return clampf(dir, -1.0, 1.0)
|
||||
|
||||
|
||||
# #153: "after a dialog popup the godot input system is lost on keypress. If
|
||||
# I had a key held it keeps moving in the key direction."
|
||||
#
|
||||
# It was not lost - it was never delivered. A dialog stops the tree (#128),
|
||||
# a paused node receives no input, and this controller is paused along with
|
||||
# everything else. So a key RELEASED while the line was up never reached
|
||||
# _handle_key, keys_down kept it, and the fleet flew on in that direction
|
||||
# once the game restarted.
|
||||
#
|
||||
# NOTIFICATION_UNPAUSED arrives whatever a node's process mode is, which
|
||||
# makes it the one moment where the question can be asked. The physical
|
||||
# keyboard is the authority on what is actually held down; keys_down is only
|
||||
# a record of what we were told, and it is the record that went stale.
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_UNPAUSED:
|
||||
_forget_released_keys()
|
||||
|
||||
|
||||
func _forget_released_keys() -> void:
|
||||
for key in keys_down.keys():
|
||||
if not Input.is_key_pressed(key):
|
||||
keys_down.erase(key)
|
||||
|
||||
|
||||
func keyboard_direction() -> Vector2:
|
||||
var dir: Vector2 = Vector2.ZERO
|
||||
for key in KEY_DIRECTIONS:
|
||||
|
|
@ -250,15 +278,13 @@ func _process(delta: float) -> void:
|
|||
# anchor in _process, so this only needs to report the direction. The
|
||||
# keyboard takes precedence, and releasing the keys has to stop the fleet
|
||||
# itself - unlike the stick, there is no centred-again signal to hook.
|
||||
var keyed: Vector2 = keyboard_direction()
|
||||
if keyed != Vector2.ZERO:
|
||||
keyboard_steering = true
|
||||
fleet.move_in_direction(keyed)
|
||||
elif keyboard_steering:
|
||||
# #124: the breach drives. One check here covers the stick, the keyboard
|
||||
# and the released-again path together - three separate gates would be
|
||||
# three chances for one of them to keep steering.
|
||||
if GameState.intro_locks_movement:
|
||||
keyboard_steering = false
|
||||
fleet.stop()
|
||||
elif joystick != null and joystick.direction != Vector2.ZERO:
|
||||
fleet.move_in_direction(joystick.direction)
|
||||
else:
|
||||
_apply_steering()
|
||||
|
||||
var target: Vector2 = fleet.get_centroid()
|
||||
if camera.global_position.distance_to(target) <= camera_snap_distance:
|
||||
|
|
@ -272,6 +298,18 @@ func _process(delta: float) -> void:
|
|||
camera.global_position = camera.global_position.lerp(target, t)
|
||||
|
||||
|
||||
func _apply_steering() -> void:
|
||||
var keyed: Vector2 = keyboard_direction()
|
||||
if keyed != Vector2.ZERO:
|
||||
keyboard_steering = true
|
||||
fleet.move_in_direction(keyed)
|
||||
elif keyboard_steering:
|
||||
keyboard_steering = false
|
||||
fleet.stop()
|
||||
elif joystick != null and joystick.direction != Vector2.ZERO:
|
||||
fleet.move_in_direction(joystick.direction)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventScreenTouch:
|
||||
_handle_touch(event)
|
||||
|
|
@ -283,8 +321,16 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
_handle_key(event)
|
||||
elif event is InputEventMouseMotion:
|
||||
_note_keyboard()
|
||||
elif event is InputEventMouseButton and event.pressed:
|
||||
if speed_fader != null:
|
||||
speed_fader.handle_mouse_motion(event)
|
||||
elif event is InputEventMouseButton:
|
||||
_note_keyboard()
|
||||
# #118: the fader gets first refusal, so dragging the boost does not
|
||||
# also pan or zoom the world behind it.
|
||||
if speed_fader != null and speed_fader.handle_mouse_button(event):
|
||||
return
|
||||
if not event.pressed:
|
||||
return
|
||||
# Wheel zoom, the desktop equivalent of the pinch.
|
||||
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||
_set_zoom(camera.zoom.x * WHEEL_ZOOM_STEP)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,26 @@
|
|||
class_name GasBombController
|
||||
extends "res://controllers/AimedFleetAbilityController.gd"
|
||||
|
||||
const BOMB_COLOR := Color(0.96, 0.22, 0.72)
|
||||
const TARGET_COLOR := Color(0.78, 0.32, 1.0)
|
||||
const MAX_RANGE := 950.0
|
||||
const AOE_RADIUS := 180.0
|
||||
const DRAG_RADIUS := 260.0
|
||||
const PROJECTILE_SPEED := 720.0
|
||||
const DAMAGE := 3
|
||||
const COOLDOWN := 5.0
|
||||
const GAS_PER_SHOT: int = 5
|
||||
# The player's end of the gas bomb: the button, the aim, the fleet's
|
||||
# magazine. The SHOT itself is a GasBombEffect in the world (see that file),
|
||||
# lifted out of here for the same reason the beam was lifted out of
|
||||
# LaserController - a garrisoned bomber fires the same bomb from a different
|
||||
# trigger, and none of this controller is involved in that.
|
||||
|
||||
const BOMB_EFFECT = preload("res://vfx/GasBombEffect.gd")
|
||||
const BOMB_COLOR := BOMB_EFFECT.BOMB_COLOR
|
||||
const TARGET_COLOR := Color(0.78, 0.32, 1.0)
|
||||
const MAX_RANGE := VesselGasBomberComponent.MAX_RANGE
|
||||
const AOE_RADIUS := BOMB_EFFECT.AOE_RADIUS
|
||||
const DRAG_RADIUS := 260.0
|
||||
const DAMAGE := VesselGasBomberComponent.BLAST_DAMAGE
|
||||
const COOLDOWN := VesselGasBomberComponent.SHOT_COOLDOWN
|
||||
const GAS_PER_SHOT: int = VesselGasBomberComponent.GAS_PER_SHOT
|
||||
|
||||
# The bombs this controller has thrown that are still alive - in flight or
|
||||
# still blooming. Split into the two lists the rest of the game already asks
|
||||
# about rather than one, so "is a bomb still travelling" stays a question
|
||||
# with a cheap answer.
|
||||
var projectiles: Array = []
|
||||
var explosions: Array = []
|
||||
var last_hit_count: int = 0
|
||||
|
|
@ -22,7 +32,8 @@ var explosion_time: float:
|
|||
get:
|
||||
var longest: float = 0.0
|
||||
for explosion in explosions:
|
||||
longest = maxf(longest, float(explosion.time))
|
||||
if is_instance_valid(explosion):
|
||||
longest = maxf(longest, float(explosion.explosion_time))
|
||||
return longest
|
||||
|
||||
|
||||
|
|
@ -48,78 +59,46 @@ func _consume_shot(component: Node) -> bool:
|
|||
|
||||
|
||||
func _perform_shot(ship: Node2D, _component: Node) -> void:
|
||||
projectiles.append({
|
||||
"position": ship.global_position,
|
||||
"target": aim_target,
|
||||
"trail": PackedVector2Array([ship.global_position]),
|
||||
})
|
||||
# The cooldown was already spent by _consume_shot() above, so this
|
||||
# launches the effect directly rather than going through fire_ability()
|
||||
# and spending it twice.
|
||||
var effect: Node2D = BOMB_EFFECT.launch(get_tree().root, ship.global_position,
|
||||
aim_target, DAMAGE)
|
||||
effect.struck.connect(_on_bomb_struck)
|
||||
projectiles.append(effect)
|
||||
|
||||
|
||||
func _on_bomb_struck(hit_count: int) -> void:
|
||||
last_hit_count = hit_count
|
||||
|
||||
|
||||
func _next_ready_bomber() -> Node2D:
|
||||
return _next_ready_ship()
|
||||
|
||||
|
||||
func _advance_effects(delta: float) -> void:
|
||||
for i in range(projectiles.size() - 1, -1, -1):
|
||||
var projectile: Dictionary = projectiles[i]
|
||||
var distance: float = (projectile.position as Vector2).distance_to(projectile.target)
|
||||
if distance <= PROJECTILE_SPEED * delta:
|
||||
projectile.position = projectile.target
|
||||
_explode(projectile.target)
|
||||
projectiles.remove_at(i)
|
||||
func _advance_effects(_delta: float) -> void:
|
||||
# The effects fly, explode and free themselves. This only re-sorts them
|
||||
# into "still travelling" and "still blooming", and forgets the gone.
|
||||
var flying: Array = []
|
||||
var blooming: Array = []
|
||||
for effect in projectiles + explosions:
|
||||
if not is_instance_valid(effect):
|
||||
continue
|
||||
if effect.in_flight():
|
||||
flying.append(effect)
|
||||
else:
|
||||
projectile.position = (projectile.position as Vector2).move_toward(
|
||||
projectile.target, PROJECTILE_SPEED * delta)
|
||||
var trail: PackedVector2Array = projectile.trail
|
||||
trail.append(projectile.position)
|
||||
while trail.size() > 12:
|
||||
trail.remove_at(0)
|
||||
projectile.trail = trail
|
||||
for i in range(explosions.size() - 1, -1, -1):
|
||||
var explosion: Dictionary = explosions[i]
|
||||
explosion.time = maxf(float(explosion.time) - delta, 0.0)
|
||||
if float(explosion.time) <= 0.0:
|
||||
explosions.remove_at(i)
|
||||
|
||||
|
||||
func _explode(at: Vector2) -> void:
|
||||
explosions.append({"position": at, "time": 0.8})
|
||||
last_hit_count = 0
|
||||
# Asteroids too - a blast that clears everything around it but leaves the
|
||||
# rocks untouched would need explaining, and they take damage the same way.
|
||||
for enemy in get_tree().get_nodes_in_group("enemies") \
|
||||
+ get_tree().get_nodes_in_group("asteroids"):
|
||||
if is_instance_valid(enemy) and enemy is Node2D \
|
||||
and enemy.global_position.distance_to(at) <= AOE_RADIUS:
|
||||
enemy.take_damage(DAMAGE)
|
||||
last_hit_count += 1
|
||||
blooming.append(effect)
|
||||
projectiles = flying
|
||||
explosions = blooming
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if aiming:
|
||||
# Only the aim guide. Each bomb draws itself, in its own node.
|
||||
if not aiming:
|
||||
return
|
||||
NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.8)
|
||||
_draw_ring(aim_target, AOE_RADIUS, TARGET_COLOR, 0.8)
|
||||
_draw_ring(aim_target, 18.0, BOMB_COLOR, 1.0)
|
||||
for projectile in projectiles:
|
||||
var trail: PackedVector2Array = projectile.trail
|
||||
if trail.size() >= 2:
|
||||
for i in range(1, trail.size()):
|
||||
var alpha: float = float(i) / trail.size()
|
||||
draw_line(trail[i - 1], trail[i], Color(BOMB_COLOR, alpha * 0.65),
|
||||
4.0 + alpha * 4.0, true)
|
||||
draw_circle(projectile.position, 12.0, Color(BOMB_COLOR, 0.35))
|
||||
NeonDraw.draw_glowing_circle(self, projectile.position, 7.0, BOMB_COLOR, 1.0)
|
||||
for explosion in explosions:
|
||||
var progress: float = 1.0 - float(explosion.time) / 0.8
|
||||
var radius: float = lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0))
|
||||
draw_circle(explosion.position, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16))
|
||||
_draw_ring(explosion.position, radius, BOMB_COLOR, 1.0 - progress)
|
||||
for i in range(7):
|
||||
var angle: float = TAU * float(i) / 7.0 + progress
|
||||
var cloud_at: Vector2 = explosion.position \
|
||||
+ Vector2(cos(angle), sin(angle)) * radius * 0.55
|
||||
draw_circle(cloud_at, 18.0 + 14.0 * progress,
|
||||
Color(BOMB_COLOR, (1.0 - progress) * 0.22))
|
||||
|
||||
|
||||
func _draw_ring(center: Vector2, radius: float, color: Color, alpha: float) -> void:
|
||||
|
|
|
|||
|
|
@ -1,18 +1,26 @@
|
|||
class_name LaserController
|
||||
extends "res://controllers/AimedFleetAbilityController.gd"
|
||||
|
||||
const BEAM_COLOR := Color(0.3, 0.9, 1.0)
|
||||
const TARGET_COLOR := Color(0.6, 0.95, 1.0)
|
||||
const BEAM_HALF_WIDTH := 38.0
|
||||
# The DRAWN beam is scaled from the hit width rather than tuned separately.
|
||||
# The two were independent numbers and had drifted apart: the beam struck
|
||||
# anything within 22 units of the line while its outer glow spanned about 11,
|
||||
# so it killed things it visibly missed. NeonDraw's widest layer is 18px at
|
||||
# scale 1, so this makes the glow span exactly what the beam hits.
|
||||
const BEAM_WIDTH_SCALE := BEAM_HALF_WIDTH * 2.0 / 18.0
|
||||
const FLASH_DURATION := 0.22
|
||||
const ENERGY_PER_SHOT: int = 8
|
||||
# The player's end of the laser: the button, the aim, the fleet's magazine.
|
||||
# The SHOT itself is a LaserBeamEffect in the world (see that file) - it was
|
||||
# lifted out of here so a garrisoned laser vessel could fire the same beam
|
||||
# from a different trigger without this controller being involved at all.
|
||||
#
|
||||
# What is left here is everything that is genuinely about the player firing:
|
||||
# where they aimed, what it costs the fleet hold, and the guide line drawn
|
||||
# while they aim it.
|
||||
|
||||
const BEAM_EFFECT = preload("res://vfx/LaserBeamEffect.gd")
|
||||
const BEAM_COLOR := BEAM_EFFECT.BEAM_COLOR
|
||||
const TARGET_COLOR := Color(0.6, 0.95, 1.0)
|
||||
const BEAM_HALF_WIDTH := BEAM_EFFECT.BEAM_HALF_WIDTH
|
||||
const FLASH_DURATION := BEAM_EFFECT.FLASH_DURATION
|
||||
const ENERGY_PER_SHOT: int = VesselLaserComponent.ENERGY_PER_SHOT
|
||||
|
||||
# The beams this controller has fired and that are still alive. Kept so the
|
||||
# fleet's own shots can be reasoned about - "is anything still on screen" for
|
||||
# the redraw, and the tests' view of what a shot did - without this becoming
|
||||
# the owner of them again.
|
||||
var beams: Array = []
|
||||
var last_hit_count: int = 0
|
||||
|
||||
|
|
@ -51,51 +59,37 @@ func _consume_shot(component: Node) -> bool:
|
|||
|
||||
|
||||
func _perform_shot(ship: Node2D, component: Node) -> void:
|
||||
# The cooldown was already spent by _consume_shot() above, so this fires
|
||||
# the effect directly rather than going through fire_ability() and
|
||||
# spending it twice.
|
||||
var direction: Vector2 = aim_target - _aim_origin()
|
||||
direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP
|
||||
var beam_to: Vector2 = ship.global_position + direction * component.beam_range
|
||||
_strike(ship.global_position, beam_to, direction, int(component.damage))
|
||||
var effect: Node2D = BEAM_EFFECT.fire(get_tree().root, ship.global_position,
|
||||
direction, component.beam_range, int(component.damage))
|
||||
beams.append(effect)
|
||||
last_hit_count = int(effect.hit_count)
|
||||
|
||||
|
||||
func _next_ready_laser_ship() -> Node2D:
|
||||
return _next_ready_ship()
|
||||
|
||||
|
||||
func _advance_effects(delta: float) -> void:
|
||||
for i in range(beams.size() - 1, -1, -1):
|
||||
var beam: Dictionary = beams[i]
|
||||
beam.time = maxf(float(beam.time) - delta, 0.0)
|
||||
if float(beam.time) <= 0.0:
|
||||
beams.remove_at(i)
|
||||
func _advance_effects(_delta: float) -> void:
|
||||
# The effects animate and free themselves; this only forgets the ones that
|
||||
# have gone.
|
||||
beams = beams.filter(func(beam): return is_instance_valid(beam))
|
||||
|
||||
|
||||
func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void:
|
||||
beams.append({"from": from, "to": to, "time": FLASH_DURATION})
|
||||
last_hit_count = 0
|
||||
var range_along: float = from.distance_to(to)
|
||||
# Asteroids share the enemies' take_damage() contract, so the beam sweeps
|
||||
# both groups in one pass. Shooting a path clear through a field is a
|
||||
# perfectly good use of a laser, and the ore it leaves behind is the point.
|
||||
for enemy in get_tree().get_nodes_in_group("enemies") \
|
||||
+ get_tree().get_nodes_in_group("asteroids"):
|
||||
if not is_instance_valid(enemy) or not (enemy is Node2D):
|
||||
continue
|
||||
var to_enemy: Vector2 = enemy.global_position - from
|
||||
var along: float = to_enemy.dot(direction)
|
||||
if along < 0.0 or along > range_along:
|
||||
continue
|
||||
var perpendicular: float = (to_enemy - direction * along).length()
|
||||
if perpendicular <= BEAM_HALF_WIDTH:
|
||||
enemy.take_damage(damage)
|
||||
last_hit_count += 1
|
||||
func beam_extension(beam: Node2D) -> float:
|
||||
return beam.extension()
|
||||
|
||||
|
||||
func beam_tip(beam: Node2D) -> Vector2:
|
||||
return beam.tip()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
# Only the aim guide. Each beam draws itself, in its own node.
|
||||
if aiming:
|
||||
NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target,
|
||||
TARGET_COLOR, 0.7, 0.6)
|
||||
for beam in beams:
|
||||
var progress: float = 1.0 - float(beam.time) / FLASH_DURATION
|
||||
var alpha: float = 1.0 - progress
|
||||
NeonDraw.draw_glowing_line(self, beam.from, beam.to,
|
||||
BEAM_COLOR, alpha, BEAM_WIDTH_SCALE * (1.0 - progress * 0.45))
|
||||
|
|
|
|||
1316
core/GameState.gd
1316
core/GameState.gd
File diff suppressed because it is too large
Load diff
235
core/Quests.gd
Normal file
235
core/Quests.gd
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
extends RefCounted
|
||||
|
||||
# Forgejo #129 / #133: the quest chain.
|
||||
#
|
||||
# One place, in order, and nothing but data. GameState answers each goal and
|
||||
# QuestDirector reads each line out; neither of them knows what the quests
|
||||
# ARE, so adding one here is the whole job of adding one.
|
||||
#
|
||||
# Two rules the chain obeys, both learned from the game rather than chosen:
|
||||
#
|
||||
# 1. Nothing here may ask for something the player cannot yet reach. The
|
||||
# sharp edge is RESEARCH POINTS - they come only from conquering a
|
||||
# planet (GameState.mark_point_captured), so every node with a
|
||||
# research_points cost sits after `conquest` below. Ore-only nodes -
|
||||
# Shipyards, Logistics - may come before it.
|
||||
# 2. A quest names ONE goal, and the brief says what it will take. Asking
|
||||
# for a gas burner is asking for the Power Systems research too; that
|
||||
# belongs in the sentence, not in a second entry, or the chain turns
|
||||
# into a list of errands.
|
||||
#
|
||||
# The whole story turns on `mothership`: the fleet builds a transmitter,
|
||||
# calls the World Ship, and hears nothing - not because the array is weak
|
||||
# but because the breach put them further out than anyone has been. Every
|
||||
# quest after it is a search.
|
||||
|
||||
# There is nobody left to talk to, so the quests are the ship talking to
|
||||
# itself - the same voice the prologue ends on (IntroDirector.SHIP_LOG).
|
||||
const SPEAKER: String = "KESTREL - LOG"
|
||||
|
||||
# Goal kinds. Each is one line in GameState.quest_progress().
|
||||
const GOAL_RESOURCE: String = "resource"
|
||||
const GOAL_BUILDING: String = "building"
|
||||
const GOAL_FLEET_VESSEL: String = "fleet_vessel"
|
||||
const GOAL_VESSEL_UNLOCKED: String = "vessel_unlocked"
|
||||
const GOAL_EXPLORE: String = "explore"
|
||||
const GOAL_PLANET_VISITS: String = "planet_visits"
|
||||
const GOAL_PLANET_KINDS: String = "planet_kinds"
|
||||
const GOAL_CONQUERED: String = "conquered"
|
||||
# #141: researching a doctrine, as a step of its own. The chain used to fold
|
||||
# it into the brief of the thing it unlocks ("spend ore on Shipyards, then
|
||||
# put a crafter down"), which asks the player to do two unrelated things -
|
||||
# open a menu they have never opened, and place a structure - off one line.
|
||||
const GOAL_RESEARCH: String = "research"
|
||||
# #156: what is in the BANK, which is a different question from what the
|
||||
# player can lay hands on. GOAL_RESOURCE counts the hold as well, and the
|
||||
# hold is exactly what this quest is about being too small.
|
||||
const GOAL_BANKED: String = "banked"
|
||||
|
||||
const CHAIN: Array = [
|
||||
{
|
||||
"id": "first_ore",
|
||||
"title": "SALVAGE",
|
||||
"objective": "Break asteroids for 5 ore",
|
||||
"goal": {"kind": GOAL_RESOURCE, "resource": "ore", "amount": 5},
|
||||
"brief": "Hull intact. Reactor holding. Everything else we had is on the other side of that hole. There is ore in the rocks out here - break some, and we can start again.",
|
||||
"done": "Five ore in the hold. It is not much. It is a beginning.",
|
||||
},
|
||||
{
|
||||
# #142: the gap this fills was a real one. "Collect 5 ore" was
|
||||
# followed straight by "build the mothership", which costs twenty -
|
||||
# so the first thing the chain ever asked for was four times what it
|
||||
# had just taught, with nothing saying so.
|
||||
"id": "stockpile",
|
||||
"title": "THE PILE",
|
||||
"objective": "Break asteroids for 20 ore",
|
||||
"goal": {"kind": GOAL_RESOURCE, "resource": "ore", "amount": 20},
|
||||
"brief": "Now do that four times over. Nothing permanent gets built out of five ore, and there is nothing else out here to spend it on yet.",
|
||||
"done": "Twenty in the hold. That is a foundation.",
|
||||
},
|
||||
{
|
||||
"id": "mothership",
|
||||
"title": "A PLACE TO STAND",
|
||||
"objective": "Build the mothership",
|
||||
"goal": {"kind": GOAL_BUILDING, "building": "mothership"},
|
||||
"brief": "A hull is not a base. Build the mothership - it is the only thing in the plans with a transmitter big enough to call the World Ship.",
|
||||
# The hinge of the whole chain (#129): the answer to "we built the
|
||||
# transmitter" is that the transmitter was never the problem.
|
||||
"done": "Mothership online. Calling the World Ship. Calling. Nothing comes back - and the array is not at fault. Wherever the breach put us, it is further out than that signal will ever carry. We find their trail ourselves, or we do not find them.",
|
||||
},
|
||||
{
|
||||
"id": "chart",
|
||||
"title": "DEAD RECKONING",
|
||||
"objective": "Chart 30 sectors",
|
||||
"goal": {"kind": GOAL_EXPLORE, "sectors": 30},
|
||||
"brief": "So we search. Fly, and let the map fill in behind us - whatever is left of their course is somewhere in it.",
|
||||
"done": "Thirty sectors charted. No sign of the World Ship, but the sky behind us is no longer blank.",
|
||||
},
|
||||
{
|
||||
# #141: its own step, and the first time the chain sends anyone into
|
||||
# the research tree at all - so the brief says where the tree IS.
|
||||
# Every later quest that needs a doctrine only mentions it in
|
||||
# passing, which is enough once the player has been there once.
|
||||
"id": "shipyards",
|
||||
"title": "SHIPYARDS",
|
||||
"objective": "Research the Shipyards doctrine",
|
||||
"goal": {"kind": GOAL_RESEARCH, "node": "shipyards"},
|
||||
"brief": "One hull will not chart a sky, and we cannot build what we have not remembered how to build. The mothership holds the archive - go aboard it and research Shipyards.",
|
||||
"done": "Shipyard plans recovered. Everything else worth having is in that archive too.",
|
||||
},
|
||||
{
|
||||
"id": "crafter",
|
||||
"title": "A YARD OF OUR OWN",
|
||||
"objective": "Build a ship crafter",
|
||||
"goal": {"kind": GOAL_BUILDING, "building": "ship_crafter"},
|
||||
"brief": "Now put a ship crafter down. Ore goes in, vessels come out - it is the only way this fleet grows past the one hull that came through the breach.",
|
||||
"done": "The yard is live. We can build our own now.",
|
||||
},
|
||||
{
|
||||
"id": "second_hull",
|
||||
"title": "A SECOND HULL",
|
||||
"objective": "Fly two vessels at once",
|
||||
"goal": {"kind": GOAL_FLEET_VESSEL, "vessel": "interceptor", "count": 2},
|
||||
"brief": "Order an interceptor at the yard and crew it. Two guns is not a fleet, but it is twice what we flew out of the breach with.",
|
||||
"done": "Two hulls in formation. It has been a while since this ship flew with company.",
|
||||
},
|
||||
{
|
||||
"id": "planetfall",
|
||||
"title": "PLANETFALL",
|
||||
"objective": "Land on a planet",
|
||||
"goal": {"kind": GOAL_PLANET_VISITS, "count": 1},
|
||||
"brief": "There are worlds out here. Bring the fleet in close to one and take it down - there is more on a surface than there is in all this dark.",
|
||||
"done": "Down on solid ground. Whatever else this place is, it is somewhere.",
|
||||
},
|
||||
{
|
||||
"id": "conquest",
|
||||
"title": "GROUND HELD",
|
||||
"objective": "Take every capture point on one planet",
|
||||
"goal": {"kind": GOAL_CONQUERED, "count": 1},
|
||||
"brief": "Something got to this surface before us and it is still holding it. Take every point on the planet - what we pull out of their positions is worth more than the ore is.",
|
||||
# Says out loud where research points come from, because nothing
|
||||
# else in the game does.
|
||||
"done": "Planet secured. Their equipment reads out as research data - the first we have had. Every doctrine worth having is going to cost some.",
|
||||
},
|
||||
{
|
||||
# #156: the extractor is the first thing the chain asks for that
|
||||
# cannot be paid for out of a single hold. An interceptor carries 20
|
||||
# and the fleet is small; the mothership is where anything larger
|
||||
# than one trip has to accumulate, and nothing had ever said so.
|
||||
"id": "deposit",
|
||||
"title": "THE HOLD IS SMALL",
|
||||
"objective": "Bank 60 ore at the mothership",
|
||||
"goal": {"kind": GOAL_BANKED, "resource": "ore", "amount": 60},
|
||||
"brief": "Every hull out here carries twenty and no more, and we are going to need more than twenty. Fly what we have back to the mothership and unload it - what is stored there is not carried by anyone.",
|
||||
"done": "Sixty banked. The hold empties, the vault does not - that is the difference, and everything from here on depends on it.",
|
||||
},
|
||||
{
|
||||
"id": "extractor",
|
||||
"title": "STANDING MINE",
|
||||
"objective": "Build an extractor on a planet",
|
||||
"goal": {"kind": GOAL_BUILDING, "building": "extractor"},
|
||||
"brief": "Research Logistics and put an extractor down on a surface. It keeps digging whether we are standing over it or not.",
|
||||
"done": "The extractor is cutting. Ore will be there when we come back for it.",
|
||||
},
|
||||
{
|
||||
"id": "gas",
|
||||
"title": "THE HEAVY AIR",
|
||||
"objective": "Bring back 25 gas",
|
||||
"goal": {"kind": GOAL_RESOURCE, "resource": "gas", "amount": 25},
|
||||
"brief": "The banded worlds are full of gas, and gas is the only thing out here that burns. Take the fleet into one and fill the tanks.",
|
||||
"done": "Twenty-five units of gas aboard. Now we need something that can burn it.",
|
||||
},
|
||||
{
|
||||
"id": "hub",
|
||||
"title": "FREIGHT",
|
||||
"objective": "Build a transport hub",
|
||||
"goal": {"kind": GOAL_BUILDING, "building": "transport_hub"},
|
||||
"brief": "Carrying every load by hand does not scale. Research Freight Handling and build a hub - its transporters run the routes without us.",
|
||||
"done": "The hub is running its own traffic. That is our hands back.",
|
||||
},
|
||||
{
|
||||
"id": "burner",
|
||||
"title": "POWER",
|
||||
"objective": "Build a gas burner",
|
||||
"goal": {"kind": GOAL_BUILDING, "building": "gas_burner"},
|
||||
"brief": "Research Power Systems and build a gas burner. Gas in, energy out - and energy is what the better weapons run on.",
|
||||
"done": "The burner is lit. There is power on this fleet for the first time since the breach.",
|
||||
},
|
||||
{
|
||||
"id": "lab",
|
||||
"title": "SHIP SCIENCE",
|
||||
"objective": "Build a ship lab",
|
||||
"goal": {"kind": GOAL_BUILDING, "building": "ship_lab"},
|
||||
"brief": "Research Vessel Science and build a ship lab. Every hull we fly can be made better there, and there are hulls in the archive we have never built.",
|
||||
"done": "The lab is open.",
|
||||
},
|
||||
{
|
||||
"id": "shield",
|
||||
"title": "A NEW HULL",
|
||||
"objective": "Unlock the shield vessel",
|
||||
"goal": {"kind": GOAL_VESSEL_UNLOCKED, "vessel": "shield"},
|
||||
"brief": "The archive has a shield hull in it. Unlock it at the lab - the fleet has been taking every hit on the paint so far.",
|
||||
"done": "Shield hull unlocked. There is more than one design in that archive.",
|
||||
},
|
||||
{
|
||||
"id": "outpost",
|
||||
"title": "FORWARD BASES",
|
||||
"objective": "Build an outpost",
|
||||
"goal": {"kind": GOAL_BUILDING, "building": "outpost"},
|
||||
"brief": "We cannot be everywhere. Research Forward Bases and build an outpost - vessels stationed there hold ground we are not standing on.",
|
||||
"done": "The outpost is crewed. That is one place we no longer have to fly back to.",
|
||||
},
|
||||
{
|
||||
"id": "worlds",
|
||||
"title": "THE SURVEY",
|
||||
"objective": "Land on three different kinds of world",
|
||||
"goal": {"kind": GOAL_PLANET_KINDS, "count": 3},
|
||||
"brief": "Rock, gas, and whatever else is out here. Set down on three different kinds of world - if the World Ship passed this way, it passed something.",
|
||||
"done": "Three kinds of world surveyed. Every one of them empty of anything that came through with us.",
|
||||
},
|
||||
{
|
||||
"id": "trail",
|
||||
"title": "THE TRAIL",
|
||||
"objective": "Chart 150 sectors",
|
||||
"goal": {"kind": GOAL_EXPLORE, "sectors": 150},
|
||||
"brief": "Then we widen it. A hundred and fifty sectors, and every one of them looked at properly. They are out there.",
|
||||
"done": "A hundred and fifty sectors charted, and no trail. The search does not stop because the list ran out.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
static func count() -> int:
|
||||
return CHAIN.size()
|
||||
|
||||
|
||||
static func at(index: int) -> Dictionary:
|
||||
if index < 0 or index >= CHAIN.size():
|
||||
return {}
|
||||
return CHAIN[index]
|
||||
|
||||
|
||||
static func by_id(id: String) -> Dictionary:
|
||||
for quest in CHAIN:
|
||||
if String(quest.id) == id:
|
||||
return quest
|
||||
return {}
|
||||
1
core/Quests.gd.uid
Normal file
1
core/Quests.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cv5jwd8f74a2f
|
||||
|
|
@ -8,11 +8,12 @@ script = ExtResource("1")
|
|||
enemy_id = "artillery"
|
||||
display_name = "Artillery"
|
||||
max_hp = 8
|
||||
speed = 90.0
|
||||
acceleration = 180.0
|
||||
rotation_speed = 3.0
|
||||
hull_points = PackedVector2Array(0, -30, 14, -22, 16, -6, 26, 2, 22, 18, 10, 26, -10, 26, -22, 18, -26, 2, -16, -6, -14, -22)
|
||||
speed = 215.0
|
||||
acceleration = 390.0
|
||||
rotation_speed = 4.2
|
||||
hull_points = PackedVector2Array(0, -34, 7, -18, 34, -4, 40, 8, 20, 6, 12, 20, 4, 28, -4, 28, -12, 20, -20, 6, -40, 8, -34, -4, -7, -18)
|
||||
glow_color = Color(1.0, 0.5, 0.2, 1)
|
||||
detail_lines = PackedVector2Array(-14, -20, 14, -20, -12, 6, -20, 16, 12, 6, 20, 16, 0, -22, 0, 6)
|
||||
detail_rings = PackedVector2Array(0, -20, 8, 0)
|
||||
detail_lines = PackedVector2Array(0, -30, 0, 22, -20, 4, 20, 4, -30, 0, -30, 6, 30, 0, 30, 6, -9, 12, 9, 12)
|
||||
detail_rings = PackedVector2Array(0, 14, 9, 0, 0, -16, 5, 0)
|
||||
component_scenes = Array[PackedScene]([ExtResource("2")])
|
||||
contact_damage = 6
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ signal finished
|
|||
# SEE coming and the cross marking where it will land. There used to be a
|
||||
# 1.8s charge-up before anything left the barrel, which arrived long before
|
||||
# the threat was legible and mostly read as the tower being stuck.
|
||||
@export var shell_speed: float = 180.0
|
||||
@export var shell_speed: float = 460.0
|
||||
@export var aoe_radius: float = 220.0
|
||||
@export var damage: int = 3
|
||||
@export var color: Color = Color(1.0, 0.5, 0.2)
|
||||
|
|
@ -30,6 +30,13 @@ const EXPLOSION_DURATION: float = 0.8
|
|||
# already says that something is coming. A full blast-radius ring here read
|
||||
# as an attack that had already happened.
|
||||
const IMPACT_CROSS_RADIUS: float = 20.0
|
||||
# #114: the blast throws real debris now. The ring alone read as a decal
|
||||
# being stamped on the map; a burst gives the moment some weight, and it is
|
||||
# the same LightBurstVFX every other explosion in the game is built from.
|
||||
const BLAST_VFX = preload("res://vfx/BombBlastVFX.gd")
|
||||
# Fins on the falling bomb, so it reads as an object with a direction rather
|
||||
# than a travelling dot.
|
||||
const FIN_LENGTH: float = 13.0
|
||||
|
||||
var phase: int = Phase.TRAVELING
|
||||
var target: Vector2 = Vector2.ZERO
|
||||
|
|
@ -78,9 +85,16 @@ func _explode() -> void:
|
|||
phase = Phase.EXPLODING
|
||||
explosion_time = EXPLOSION_DURATION
|
||||
position = target
|
||||
BLAST_VFX.spawn(get_tree().root, target, color)
|
||||
for ship in get_tree().get_nodes_in_group("player_ships"):
|
||||
if is_instance_valid(ship) and ship.global_position.distance_to(target) <= aoe_radius:
|
||||
ship.take_damage(damage)
|
||||
# #101: a bomb does not care whether what it landed on flies. The hitbox
|
||||
# is a child Area2D of the Building, which is a plain Node2D and would
|
||||
# otherwise be unhittable.
|
||||
for hitbox in get_tree().get_nodes_in_group("player_buildings"):
|
||||
if is_instance_valid(hitbox) and hitbox.global_position.distance_to(target) <= aoe_radius:
|
||||
hitbox.take_damage(damage)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
|
|
@ -91,14 +105,34 @@ func _draw() -> void:
|
|||
_draw_impact_cross(target - shell_pos)
|
||||
for i in range(1, trail.size()):
|
||||
var alpha: float = float(i) / trail.size()
|
||||
draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, Color(color, alpha * 0.6), 5.0, true)
|
||||
draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos,
|
||||
Color(color, alpha * 0.6), 5.0, true)
|
||||
# A hot core down the middle of the smoke, brightest at the
|
||||
# bomb itself.
|
||||
draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos,
|
||||
Color(1.0, 0.94, 0.86, alpha * 0.5), 1.6, true)
|
||||
draw_circle(Vector2.ZERO, 14.0, Color(color, 0.35))
|
||||
# Fins, laid across the direction of travel.
|
||||
var heading: Vector2 = (target - shell_pos)
|
||||
heading = heading.normalized() if heading.length() > 0.001 else Vector2.DOWN
|
||||
var across: Vector2 = Vector2(-heading.y, heading.x) * FIN_LENGTH * 0.55
|
||||
var tail: Vector2 = -heading * FIN_LENGTH
|
||||
NeonDraw.draw_glowing_line(self, tail - across, tail + across, color, 0.9, 0.4)
|
||||
NeonDraw.draw_glowing_line(self, tail, heading * 6.0, color, 0.9, 0.45)
|
||||
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 8.0, color, 1.0)
|
||||
Phase.EXPLODING:
|
||||
var eprogress: float = 1.0 - explosion_time / EXPLOSION_DURATION
|
||||
var eradius: float = lerpf(30.0, aoe_radius, minf(eprogress * 1.5, 1.0))
|
||||
draw_circle(Vector2.ZERO, eradius, Color(color, (1.0 - eprogress) * 0.18))
|
||||
_draw_ring(eradius, 1.0 - eprogress)
|
||||
# A second ring chasing the first outwards, and a white flash
|
||||
# that is gone almost at once - the two together are what make a
|
||||
# blast read as a shock rather than a circle growing.
|
||||
if eprogress < 0.55:
|
||||
_draw_ring(eradius * 0.62, (0.55 - eprogress) * 1.6)
|
||||
if eprogress < 0.18:
|
||||
draw_circle(Vector2.ZERO, eradius * 0.7,
|
||||
Color(1.0, 0.95, 0.88, (0.18 - eprogress) * 3.0))
|
||||
|
||||
|
||||
# A small cross where the shell will land, plus a dot at the exact point.
|
||||
|
|
|
|||
|
|
@ -15,3 +15,4 @@ hull_points = PackedVector2Array(0, -16, 12, 0, 0, 16, -12, 0)
|
|||
glow_color = Color(1, 0.15, 0.2, 1)
|
||||
dies_on_contact = true
|
||||
component_scenes = Array[PackedScene]([ExtResource("2")])
|
||||
contact_damage = 16
|
||||
|
|
|
|||
|
|
@ -5,6 +5,16 @@ signal destroyed
|
|||
const ENEMY_DEFINITIONS = preload("res://enemies/EnemyDefinitions.gd")
|
||||
|
||||
@export var enemy_id: String = "chaser"
|
||||
# #113: this one came in with a WAVE, and a wave is an attack on what the
|
||||
# player has built rather than a hazard sitting in space. It converges on the
|
||||
# nearest structure from wherever it spawned, ignoring the detection range
|
||||
# every component otherwise respects.
|
||||
#
|
||||
# Per instance rather than per definition: the same chaser is a wave when the
|
||||
# director launches it and a garrison when a map author places it, and a
|
||||
# planet garrison that chased the player across the system would be a very
|
||||
# different thing from what the editor was used to place.
|
||||
@export var relentless: bool = false
|
||||
|
||||
var definition: Resource
|
||||
var hp: int
|
||||
|
|
@ -57,10 +67,27 @@ func _physics_process(delta: float) -> void:
|
|||
# explosion, same `destroyed` signal, same bookkeeping for whatever is
|
||||
# listening.
|
||||
func collide_with_player_vessel() -> void:
|
||||
_resolve_ram()
|
||||
|
||||
|
||||
# #101: a structure resolves an impact by the same rule - whether it costs
|
||||
# the enemy its life is the definition's call, not the target's type.
|
||||
func collide_with_player_building() -> void:
|
||||
_resolve_ram()
|
||||
|
||||
|
||||
func _resolve_ram() -> void:
|
||||
if definition != null and bool(definition.dies_on_contact):
|
||||
take_damage(hp)
|
||||
|
||||
|
||||
# What one strike against a structure takes off it. Data on the definition,
|
||||
# for the same reason dies_on_contact is: a lancer arriving at 950px/s is not
|
||||
# a sniper drifting into a wall.
|
||||
func contact_damage() -> int:
|
||||
return 6 if definition == null else int(definition.contact_damage)
|
||||
|
||||
|
||||
func take_damage(amount: int) -> void:
|
||||
hp -= amount
|
||||
if hp <= 0:
|
||||
|
|
|
|||
|
|
@ -1,20 +1,32 @@
|
|||
class_name EnemyArtilleryComponent
|
||||
extends Node
|
||||
|
||||
# Stationary siege piece: drifts slowly toward the fleet (deliberately no
|
||||
# pursuit component - closing distance is incidental, not its purpose) and
|
||||
# fires a slow, clearly visible AOE shell on a long cooldown via a
|
||||
# standalone ArtilleryShell, so an already-launched shot survives this
|
||||
# enemy dying mid-flight, same as EnemyProjectile/Projectile already do.
|
||||
# The bomber: it moves to where its bombs will land and drops them there.
|
||||
#
|
||||
# #114 turned it from a siege piece into an aircraft. It used to creep at
|
||||
# 20px/s and lob a shell that took five seconds to arrive, which read as a
|
||||
# thing that was stuck rather than a thing that was dangerous - by the time
|
||||
# a shell landed the fleet had wandered off, and the tower was still
|
||||
# approaching. It now flies at its own speed and its bombs travel at 460.
|
||||
#
|
||||
# The shell is still a standalone ArtilleryShell added to the root, so an
|
||||
# already-dropped bomb survives this enemy dying mid-flight, same as
|
||||
# EnemyProjectile/Projectile already do.
|
||||
|
||||
var capability_id: String = "artillery"
|
||||
|
||||
@export var fire_cooldown: float = 6.0
|
||||
@export var fire_cooldown: float = 4.0
|
||||
@export var fire_range: float = 900.0
|
||||
@export var shell_speed: float = 180.0
|
||||
# #114: fast enough that the bomb arrives while the target is still roughly
|
||||
# where it was aimed, and slow enough that the impact cross is a warning
|
||||
# rather than a formality.
|
||||
@export var shell_speed: float = 460.0
|
||||
@export var aoe_radius: float = 220.0
|
||||
@export var damage: int = 3
|
||||
@export var drift_speed: float = 20.0
|
||||
# It flies now rather than creeping. Kept as its own number rather than the
|
||||
# definition's `speed` so a run-in can be deliberately slower than a chaser's
|
||||
# top speed without making the hull slow.
|
||||
@export var drift_speed: float = 150.0
|
||||
@export var color: Color = Color(1.0, 0.5, 0.2)
|
||||
|
||||
var cooldown_left: float = 0.0
|
||||
|
|
@ -29,10 +41,12 @@ func tick(delta: float) -> void:
|
|||
if cooldown_left > 0.0:
|
||||
return
|
||||
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null or fleet.ships.is_empty():
|
||||
# #101/#113: it bombs what the player has BUILT as readily as what they
|
||||
# are flying, and asks the shared targeting rather than the fleet alone.
|
||||
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
|
||||
if target == null:
|
||||
return
|
||||
var centroid: Vector2 = fleet.get_centroid()
|
||||
var centroid: Vector2 = target.global_position
|
||||
if enemy.global_position.distance_to(centroid) > fire_range:
|
||||
return
|
||||
|
||||
|
|
@ -47,18 +61,22 @@ func tick(delta: float) -> void:
|
|||
|
||||
|
||||
func _drift(enemy: Node2D, delta: float) -> void:
|
||||
# Local position throughout, same convention as EnemyPursuitComponent/
|
||||
# EnemyStandoffComponent: valid as long as the enemy's container and
|
||||
# Fleet share a common origin, which every scene in this project does.
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null or fleet.ships.is_empty():
|
||||
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
|
||||
if target == null:
|
||||
return
|
||||
var to_fleet: Vector2 = fleet.get_centroid() - enemy.position
|
||||
# #69: gated by the same fire_range it already shells within, rather
|
||||
# than a second tunable that could drift out of sync - outside its own
|
||||
# firing envelope it has nothing to be drifting toward yet.
|
||||
if to_fleet.length() > fire_range:
|
||||
var to_target: Vector2 = target.global_position - enemy.global_position
|
||||
var distance: float = to_target.length()
|
||||
if distance < 1.0:
|
||||
return
|
||||
if to_fleet.length() < 1.0:
|
||||
# #69: gated by the same fire_range it already bombs within, rather than
|
||||
# a second tunable that could drift out of sync - outside its own firing
|
||||
# envelope it has nothing to be flying toward yet. #113: unless it came
|
||||
# in with a wave, in which case it has somewhere to be.
|
||||
if distance > fire_range and not EnemyTargets.ignores_range(enemy):
|
||||
return
|
||||
enemy.position += to_fleet.normalized() * drift_speed * delta
|
||||
var heading: Vector2 = to_target / distance
|
||||
enemy.global_position += heading * drift_speed * delta
|
||||
# #114: it faces where it is going. A bomber that flew sideways was most
|
||||
# of why the old one read as scenery being nudged along.
|
||||
enemy.rotation = lerp_angle(enemy.rotation, heading.angle() + PI / 2.0,
|
||||
enemy.rotation_speed * delta)
|
||||
|
|
|
|||
|
|
@ -31,5 +31,9 @@ extends Resource
|
|||
# check: the chaser rams because ramming is its whole behaviour, while a
|
||||
# sniper that happens to drift into a ship has not spent itself doing it.
|
||||
@export var dies_on_contact: bool = false
|
||||
# #101: what one strike against a player structure takes off it. A rammer
|
||||
# spends itself on the hit and so hits hardest; a stand-off enemy that drifts
|
||||
# into a wall barely scratches it, its real threat being its weapon.
|
||||
@export var contact_damage: int = 6
|
||||
@export var core_color: Color = Color(0.06, 0.02, 0.03)
|
||||
@export var component_scenes: Array[PackedScene] = []
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ class_name EnemyDefinitions
|
|||
extends RefCounted
|
||||
|
||||
const DEFAULT_ID: String = "chaser"
|
||||
const ORDER: PackedStringArray = ["chaser", "sniper", "artillery", "turret_spawner", "enemy_mothership"]
|
||||
const ORDER: PackedStringArray = ["chaser", "lancer", "sniper", "artillery", "turret_spawner", "enemy_mothership"]
|
||||
const DEFINITIONS: Dictionary = {
|
||||
"chaser": preload("res://enemies/ChaserEnemyDefinition.tres"),
|
||||
"lancer": preload("res://enemies/LancerEnemyDefinition.tres"),
|
||||
"sniper": preload("res://enemies/SniperEnemyDefinition.tres"),
|
||||
"artillery": preload("res://enemies/ArtilleryEnemyDefinition.tres"),
|
||||
"turret_spawner": preload("res://enemies/TurretSpawnerEnemyDefinition.tres"),
|
||||
|
|
|
|||
198
enemies/EnemyLungeComponent.gd
Normal file
198
enemies/EnemyLungeComponent.gd
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
class_name EnemyLungeComponent
|
||||
extends Node2D
|
||||
|
||||
# Forgejo #96. A ramming enemy that does not simply beeline the way
|
||||
# EnemyPursuitComponent does: it drifts in from a distance, LOCKS a point,
|
||||
# rocks back and forth on the spot while it winds up, shows the line it is
|
||||
# about to fly down, and only then crosses the gap in one fast straight run.
|
||||
#
|
||||
# The lock is the whole point. Once WINDUP starts, nothing re-reads the
|
||||
# fleet: the charge goes to where the target WAS, so the wind-up and the
|
||||
# trajectory line are a real warning the player can act on, rather than
|
||||
# decoration on an unavoidable hit. Same bargain as EnemyBigLaserBeam, which
|
||||
# locks its aim in launch() for exactly this reason.
|
||||
#
|
||||
# A Node2D rather than a plain Node because the trajectory line has to be
|
||||
# drawn somewhere, and top_level keeps that drawing in world space - the
|
||||
# parent enemy spins to face its target, and a line that spun with it would
|
||||
# not point where the charge is going.
|
||||
#
|
||||
# Never pair with a pursuit or standoff component: all three write the
|
||||
# enemy's position every tick and would fight over it.
|
||||
|
||||
var capability_id: String = "lunge"
|
||||
|
||||
enum Phase { HUNT, WINDUP, CHARGE, RECOVER }
|
||||
|
||||
# Far enough that it is already on screen, and visibly waiting, before it
|
||||
# commits - "starts a bit far from the target" is the behaviour, so a lancer
|
||||
# that locked the instant it could see anything would not read as one.
|
||||
@export var detection_range: float = 1500.0
|
||||
@export var lock_range: float = 820.0
|
||||
# The drift in HUNT is deliberately slower than the charge by an order of
|
||||
# magnitude: the approach is a stalk, the charge is the attack.
|
||||
@export var approach_speed_factor: float = 0.5
|
||||
# Shortened with the cycle count (#115): one back-and-forth in the time two
|
||||
# and a half used to take would be a slow-motion tell.
|
||||
@export var windup_time: float = 0.85
|
||||
# #96 to the letter: the trajectory line appears 300ms before it leaves.
|
||||
@export var telegraph_time: float = 0.3
|
||||
# The "small back and forth" - it recoils along the charge axis, so the
|
||||
# motion reads as winding up to spring rather than as drifting.
|
||||
@export var windup_sway: float = 34.0
|
||||
# #115: exactly ONE back and forth, not two and a half. The tell is a recoil
|
||||
# and a spring; repeating it turned a wind-up into a fidget, and the player
|
||||
# stopped reading it as "about to happen".
|
||||
@export var windup_cycles: float = 1.0
|
||||
# #115: and it leaves hard. At 950 the charge read as a fast drift rather
|
||||
# than as the thing the whole wind-up was promising.
|
||||
@export var charge_speed: float = 1650.0
|
||||
# It flies THROUGH the locked point rather than stopping on it. A charge
|
||||
# that halted exactly where the ship used to be would look like it was
|
||||
# aiming at empty space on purpose.
|
||||
@export var charge_overshoot: float = 300.0
|
||||
@export var recover_time: float = 1.3
|
||||
@export var telegraph_color: Color = Color(1.0, 0.45, 0.15)
|
||||
|
||||
var phase: int = Phase.HUNT
|
||||
var velocity: Vector2 = Vector2.ZERO
|
||||
# Where the charge is going, and where it started - both frozen at lock.
|
||||
var locked_point: Vector2 = Vector2.ZERO
|
||||
var charge_direction: Vector2 = Vector2.RIGHT
|
||||
var charge_anchor: Vector2 = Vector2.ZERO
|
||||
var charge_distance: float = 0.0
|
||||
var travelled: float = 0.0
|
||||
var windup_left: float = 0.0
|
||||
var recover_left: float = 0.0
|
||||
|
||||
|
||||
func setup() -> void:
|
||||
# World space, not the enemy's: see the class comment.
|
||||
top_level = true
|
||||
position = Vector2.ZERO
|
||||
rotation = 0.0
|
||||
|
||||
|
||||
# True while the trajectory line is up, i.e. the last telegraph_time of the
|
||||
# wind-up. Public because it is the one piece of state a test can check that
|
||||
# says "the warning is showing" without reaching into a _draw().
|
||||
func is_telegraphing() -> bool:
|
||||
return phase == Phase.WINDUP and windup_left <= telegraph_time
|
||||
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
var enemy: Node2D = get_parent()
|
||||
match phase:
|
||||
Phase.HUNT:
|
||||
_tick_hunt(enemy, delta)
|
||||
Phase.WINDUP:
|
||||
_tick_windup(enemy, delta)
|
||||
Phase.CHARGE:
|
||||
_tick_charge(enemy, delta)
|
||||
Phase.RECOVER:
|
||||
_tick_recover(enemy, delta)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _tick_hunt(enemy: Node2D, delta: float) -> void:
|
||||
var target: Node2D = _nearest_ship(enemy)
|
||||
if target == null:
|
||||
return
|
||||
var to_target: Vector2 = target.global_position - enemy.global_position
|
||||
var distance: float = to_target.length()
|
||||
if distance < 0.001:
|
||||
return
|
||||
# #113: see EnemyPursuitComponent.
|
||||
if not EnemyTargets.ignores_range(enemy) and distance > detection_range:
|
||||
return
|
||||
var direction: Vector2 = to_target / distance
|
||||
if distance <= lock_range:
|
||||
_lock(enemy, target)
|
||||
return
|
||||
var desired: Vector2 = direction * enemy.speed * approach_speed_factor
|
||||
velocity = velocity.move_toward(desired, enemy.acceleration * delta)
|
||||
enemy.global_position += velocity * delta
|
||||
_face(enemy, direction, delta)
|
||||
|
||||
|
||||
func _lock(enemy: Node2D, target: Node2D) -> void:
|
||||
locked_point = target.global_position
|
||||
charge_anchor = enemy.global_position
|
||||
var to_target: Vector2 = locked_point - charge_anchor
|
||||
charge_distance = to_target.length()
|
||||
charge_direction = to_target.normalized() if charge_distance > 0.001 else Vector2.RIGHT
|
||||
windup_left = windup_time
|
||||
travelled = 0.0
|
||||
velocity = Vector2.ZERO
|
||||
phase = Phase.WINDUP
|
||||
|
||||
|
||||
func _tick_windup(enemy: Node2D, delta: float) -> void:
|
||||
windup_left -= delta
|
||||
# Snap to the locked heading rather than easing into it: the nose is part
|
||||
# of the warning, and a lancer still turning when the line appears would
|
||||
# be lying about where it is going.
|
||||
enemy.rotation = charge_direction.angle() + PI / 2.0
|
||||
if windup_left <= 0.0:
|
||||
phase = Phase.CHARGE
|
||||
enemy.global_position = charge_anchor
|
||||
return
|
||||
# One full back-and-forth per cycle, starting and ending at the anchor,
|
||||
# leaning BACKWARDS first (-sin) so the recoil comes before the spring.
|
||||
var progress: float = 1.0 - windup_left / maxf(windup_time, 0.001)
|
||||
var sway: float = -sin(progress * TAU * windup_cycles) * windup_sway
|
||||
enemy.global_position = charge_anchor + charge_direction * sway
|
||||
|
||||
|
||||
func _tick_charge(enemy: Node2D, delta: float) -> void:
|
||||
var step: float = charge_speed * delta
|
||||
travelled += step
|
||||
enemy.global_position += charge_direction * step
|
||||
enemy.rotation = charge_direction.angle() + PI / 2.0
|
||||
if travelled >= charge_distance + charge_overshoot:
|
||||
phase = Phase.RECOVER
|
||||
recover_left = recover_time
|
||||
velocity = charge_direction * charge_speed
|
||||
|
||||
|
||||
func _tick_recover(enemy: Node2D, delta: float) -> void:
|
||||
# Coasts to a stop instead of stopping dead, then hunts again. Without
|
||||
# this it would re-lock the frame it finished and the wind-up would stop
|
||||
# being a rhythm the player can read.
|
||||
recover_left -= delta
|
||||
velocity = velocity.move_toward(Vector2.ZERO, enemy.acceleration * delta)
|
||||
enemy.global_position += velocity * delta
|
||||
if recover_left <= 0.0:
|
||||
phase = Phase.HUNT
|
||||
velocity = Vector2.ZERO
|
||||
|
||||
|
||||
func _face(enemy: Node2D, direction: Vector2, delta: float) -> void:
|
||||
var target_angle: float = direction.angle() + PI / 2.0
|
||||
enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta)
|
||||
|
||||
|
||||
# #101: a lancer will lock a structure exactly as it locks a vessel - the
|
||||
# wind-up, the line and the charge are the same, and so is what happens when
|
||||
# it arrives.
|
||||
func _nearest_ship(enemy: Node2D) -> Node2D:
|
||||
return EnemyTargets.nearest_target(self, enemy.global_position)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not is_telegraphing():
|
||||
return
|
||||
var enemy: Node2D = get_parent()
|
||||
if enemy == null:
|
||||
return
|
||||
# Thin, and brightening as the launch approaches, so it reads as a sight
|
||||
# line being drawn rather than as a beam already firing.
|
||||
var urgency: float = clampf(1.0 - windup_left / maxf(telegraph_time, 0.001), 0.0, 1.0)
|
||||
var from: Vector2 = enemy.global_position
|
||||
var to: Vector2 = locked_point + charge_direction * charge_overshoot
|
||||
draw_line(from, to, Color(telegraph_color, 0.18 + 0.30 * urgency), 2.0, true)
|
||||
draw_line(from, to, Color(1.0, 0.92, 0.82, 0.25 + 0.45 * urgency), 1.0, true)
|
||||
# A tick across the locked point: the line says WHERE, this says how far.
|
||||
var across: Vector2 = Vector2(-charge_direction.y, charge_direction.x) * 14.0
|
||||
draw_line(locked_point - across, locked_point + across,
|
||||
Color(telegraph_color, 0.35 + 0.5 * urgency), 2.0, true)
|
||||
1
enemies/EnemyLungeComponent.gd.uid
Normal file
1
enemies/EnemyLungeComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bfl6w8gq2ht0g
|
||||
6
enemies/EnemyLungeComponent.tscn
Normal file
6
enemies/EnemyLungeComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyLungeComponent.gd" id="1"]
|
||||
|
||||
[node name="EnemyLungeComponent" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
|
@ -37,6 +37,12 @@ func _on_area_entered(area: Area2D) -> void:
|
|||
if area.is_in_group("player_ships"):
|
||||
area.take_damage(damage)
|
||||
queue_free()
|
||||
return
|
||||
# #101: structures are targets too. The hitbox is a child Area2D of the
|
||||
# Building, which is a plain Node2D and could not be hit otherwise.
|
||||
if area.is_in_group("player_buildings"):
|
||||
area.take_damage(damage)
|
||||
queue_free()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
|
|
|
|||
|
|
@ -17,15 +17,23 @@ var velocity: Vector2 = Vector2.ZERO
|
|||
|
||||
func tick(delta: float) -> void:
|
||||
var enemy: Node2D = get_parent()
|
||||
var target: Node2D = _nearest_ship(enemy)
|
||||
if target == null or enemy.position.distance_to(target.position) > detection_range:
|
||||
# #101: a structure is a target now, not scenery. EnemyTargets keeps a
|
||||
# ship in the enemy's face ahead of a building further off.
|
||||
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
|
||||
if target == null:
|
||||
return
|
||||
var to_target: Vector2 = target.position - enemy.position
|
||||
# #113: a wave enemy closes on what the player built from wherever it
|
||||
# arrived. Everything else keeps the radius that stops it reading as
|
||||
# omniscient (#69).
|
||||
if not EnemyTargets.ignores_range(enemy) \
|
||||
and enemy.global_position.distance_to(target.global_position) > detection_range:
|
||||
return
|
||||
var to_target: Vector2 = target.global_position - enemy.global_position
|
||||
|
||||
if to_target.length() > 1.0:
|
||||
var desired_velocity: Vector2 = to_target.normalized() * enemy.speed
|
||||
velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta)
|
||||
enemy.position += velocity * delta
|
||||
enemy.global_position += velocity * delta
|
||||
|
||||
if velocity.length() > 5.0:
|
||||
var target_angle: float = velocity.angle() + PI / 2.0
|
||||
|
|
@ -33,15 +41,4 @@ func tick(delta: float) -> void:
|
|||
|
||||
|
||||
func _nearest_ship(enemy: Node2D) -> Node2D:
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null or fleet.ships.is_empty():
|
||||
return null
|
||||
|
||||
var nearest: Node2D = fleet.ships[0]
|
||||
var best_dist: float = enemy.position.distance_to(nearest.position)
|
||||
for ship in fleet.ships:
|
||||
var d: float = enemy.position.distance_to(ship.position)
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
nearest = ship
|
||||
return nearest
|
||||
return EnemyTargets.nearest_ship(self, enemy.global_position)
|
||||
|
|
|
|||
|
|
@ -38,17 +38,12 @@ func tick(delta: float) -> void:
|
|||
fire_timer = fire_cooldown
|
||||
|
||||
|
||||
# #101: shoots at whatever EnemyTargets says it is coming for, so a sniper
|
||||
# shells a depot rather than sitting in range of one doing nothing.
|
||||
func _nearest_ship_in_range(enemy: Node2D) -> Node2D:
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null:
|
||||
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
|
||||
if target == null:
|
||||
return null
|
||||
var nearest: Node2D = null
|
||||
var best_distance: float = fire_range
|
||||
for ship in fleet.ships:
|
||||
if not is_instance_valid(ship):
|
||||
continue
|
||||
var distance: float = enemy.global_position.distance_to(ship.global_position)
|
||||
if distance <= best_distance:
|
||||
best_distance = distance
|
||||
nearest = ship
|
||||
return nearest
|
||||
if enemy.global_position.distance_to(target.global_position) > fire_range:
|
||||
return null
|
||||
return target
|
||||
|
|
|
|||
|
|
@ -1,42 +1,66 @@
|
|||
extends Node2D
|
||||
|
||||
@export var min_spawn_interval: float = 3.0
|
||||
@export var max_spawn_interval: float = 6.0
|
||||
# The wave director (#100 / #97). Keeps its old node name on purpose: it is
|
||||
# still "the thing that puts enemies in the world", and every test that
|
||||
# wanted a quiet world already says
|
||||
# `main.get_node("EnemySpawner").set_process(false)`. Renaming it would have
|
||||
# changed 88 test files to say the same thing differently.
|
||||
#
|
||||
# What it no longer does is trickle. It used to spawn one enemy every 3-6
|
||||
# seconds for ever, so there was no rhythm to the danger and nothing to
|
||||
# prepare for. Now the CLOCK lives in GameState (which survives the trip to
|
||||
# a planet surface) and this node only reacts to it: when a wave turns
|
||||
# active, it puts the wave in the world; when the wave is dead, it says so
|
||||
# and the quiet starts again.
|
||||
|
||||
@export var spawn_margin: float = 150.0
|
||||
# Waves arrive as a ring around the fleet rather than from one bearing: a
|
||||
# wave that all came from the left would be dodged by flying right.
|
||||
@export var wave_spread: float = 0.55
|
||||
|
||||
@onready var fleet: Node2D = get_node("../Fleet")
|
||||
|
||||
var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn")
|
||||
var timer: float = 0.0
|
||||
var next_spawn_time: float = 0.0
|
||||
# The enemies this director launched, so "the wave is dead" is a question
|
||||
# about THIS wave and not about every enemy on the map - a planet garrison
|
||||
# or an enemy mothership's own spawns must not hold the next wave back.
|
||||
var wave_enemies: Array = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
randomize()
|
||||
_reset_timer()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
timer += delta
|
||||
if timer >= next_spawn_time:
|
||||
timer = 0.0
|
||||
_reset_timer()
|
||||
_spawn_enemy()
|
||||
|
||||
|
||||
func _reset_timer() -> void:
|
||||
next_spawn_time = randf_range(min_spawn_interval, max_spawn_interval)
|
||||
|
||||
|
||||
func _spawn_enemy() -> void:
|
||||
if fleet.ships.is_empty():
|
||||
func _process(_delta: float) -> void:
|
||||
wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e))
|
||||
if GameState.wave_pending:
|
||||
_launch_wave()
|
||||
return
|
||||
if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE and wave_enemies.is_empty():
|
||||
GameState.wave_cleared()
|
||||
|
||||
|
||||
# Public so a test can put a wave in the world without waiting out the peace.
|
||||
func _launch_wave() -> void:
|
||||
if fleet == null or fleet.ships.is_empty():
|
||||
# Nothing to attack. Leave it pending rather than spending the wave
|
||||
# on an empty map - the fleet-wipe safety net rebuilds within a frame
|
||||
# and the wave arrives then.
|
||||
return
|
||||
GameState.wave_pending = false
|
||||
var composition: PackedStringArray = GameState.wave_composition(fleet.ships.size())
|
||||
var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(self)
|
||||
var radius: float = visible_rect.size.length() / 2.0 + spawn_margin
|
||||
var angle: float = randf() * TAU
|
||||
var spawn_pos: Vector2 = fleet.get_centroid() + Vector2(cos(angle), sin(angle)) * radius
|
||||
|
||||
var centre: Vector2 = fleet.get_centroid()
|
||||
var bearing: float = randf() * TAU
|
||||
for i in range(composition.size()):
|
||||
# Spread around one bearing rather than scattered at random: a wave
|
||||
# should read as an attack arriving, not as the map filling up.
|
||||
var angle: float = bearing + randf_range(-wave_spread, wave_spread) \
|
||||
+ TAU * float(i) / float(maxi(composition.size(), 1)) * wave_spread
|
||||
var enemy: Node2D = enemy_scene.instantiate()
|
||||
enemy.position = spawn_pos
|
||||
# Before add_child: Enemy._ready() resolves enemy_id into a definition.
|
||||
enemy.enemy_id = composition[i]
|
||||
# #113: a wave is an attack on what the player has built. It converges
|
||||
# on the nearest structure from wherever it spawned rather than
|
||||
# waiting to be flown near.
|
||||
enemy.relentless = true
|
||||
enemy.position = centre + Vector2(cos(angle), sin(angle)) * radius
|
||||
get_parent().add_child(enemy)
|
||||
wave_enemies.append(enemy)
|
||||
|
|
|
|||
|
|
@ -21,12 +21,16 @@ var velocity: Vector2 = Vector2.ZERO
|
|||
|
||||
func tick(delta: float) -> void:
|
||||
var enemy: Node2D = get_parent()
|
||||
var target: Node2D = _nearest_ship(enemy)
|
||||
# #101: holds its preferred range off a building just as off a ship.
|
||||
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
|
||||
if target == null:
|
||||
return
|
||||
var to_target: Vector2 = target.position - enemy.position
|
||||
var to_target: Vector2 = target.global_position - enemy.global_position
|
||||
var distance: float = to_target.length()
|
||||
if distance < 1.0 or distance > detection_range:
|
||||
if distance < 1.0:
|
||||
return
|
||||
# #113: see EnemyPursuitComponent - a wave arrives with somewhere to be.
|
||||
if not EnemyTargets.ignores_range(enemy) and distance > detection_range:
|
||||
return
|
||||
var direction: Vector2 = to_target / distance
|
||||
var retreat_range: float = preferred_range * retreat_range_factor
|
||||
|
|
@ -41,7 +45,7 @@ func tick(delta: float) -> void:
|
|||
desired_velocity = lateral * enemy.speed * 0.5
|
||||
|
||||
velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta)
|
||||
enemy.position += velocity * delta
|
||||
enemy.global_position += velocity * delta
|
||||
|
||||
if velocity.length() > 5.0:
|
||||
var target_angle: float = velocity.angle() + PI / 2.0
|
||||
|
|
@ -49,14 +53,4 @@ func tick(delta: float) -> void:
|
|||
|
||||
|
||||
func _nearest_ship(enemy: Node2D) -> Node2D:
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null or fleet.ships.is_empty():
|
||||
return null
|
||||
var nearest: Node2D = fleet.ships[0]
|
||||
var best_dist: float = enemy.position.distance_to(nearest.position)
|
||||
for ship in fleet.ships:
|
||||
var d: float = enemy.position.distance_to(ship.position)
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
nearest = ship
|
||||
return nearest
|
||||
return EnemyTargets.nearest_ship(self, enemy.global_position)
|
||||
|
|
|
|||
73
enemies/EnemyTargets.gd
Normal file
73
enemies/EnemyTargets.gd
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
class_name EnemyTargets
|
||||
extends RefCounted
|
||||
|
||||
# #101 (#97c): who a wave enemy is coming for.
|
||||
#
|
||||
# Before this, every enemy component asked the same question - "which player
|
||||
# SHIP is nearest?" - and a structure was scenery they flew straight past.
|
||||
# #97 makes waves go after what the player has built, so the question
|
||||
# becomes "which player thing is nearest", asked in one place so pursuit,
|
||||
# standoff, the lancer's lunge and the ranged weapon cannot drift into
|
||||
# disagreeing about it.
|
||||
#
|
||||
# There is one thumb on the scale. A ship inside SHIP_PRIORITY_RANGE always
|
||||
# wins, even if a building is nearer: an enemy that ignored the vessels
|
||||
# shooting it in order to reach a depot two hundred pixels closer would read
|
||||
# as broken, and would make the fleet unable to defend anything by
|
||||
# interposing itself.
|
||||
|
||||
const SHIP_PRIORITY_RANGE: float = 520.0
|
||||
|
||||
|
||||
# The GROUP, not Fleet.ships. #105 puts vessels at outposts that are not in
|
||||
# the fleet at all, and an enemy that ignored the guns actually shooting at
|
||||
# it - because they were not in the player's formation - would let a
|
||||
# garrison hold a position without ever being fought back.
|
||||
static func nearest_ship(node: Node, from: Vector2) -> Node2D:
|
||||
var nearest: Node2D = null
|
||||
var best: float = INF
|
||||
for ship in node.get_tree().get_nodes_in_group("player_ships"):
|
||||
if not is_instance_valid(ship) or ship.is_queued_for_deletion():
|
||||
continue
|
||||
var d: float = from.distance_to((ship as Node2D).global_position)
|
||||
if d < best:
|
||||
best = d
|
||||
nearest = ship
|
||||
return nearest
|
||||
|
||||
|
||||
static func nearest_building(node: Node, from: Vector2) -> Node2D:
|
||||
var nearest: Node2D = null
|
||||
var best: float = INF
|
||||
for building in node.get_tree().get_nodes_in_group("buildings"):
|
||||
if not is_instance_valid(building) or building.is_queued_for_deletion():
|
||||
continue
|
||||
var d: float = from.distance_to(building.global_position)
|
||||
if d < best:
|
||||
best = d
|
||||
nearest = building
|
||||
return nearest
|
||||
|
||||
|
||||
static func nearest_target(node: Node, from: Vector2) -> Node2D:
|
||||
var ship: Node2D = nearest_ship(node, from)
|
||||
if ship != null and from.distance_to(ship.global_position) <= SHIP_PRIORITY_RANGE:
|
||||
return ship
|
||||
var building: Node2D = nearest_building(node, from)
|
||||
if building == null:
|
||||
return ship
|
||||
if ship == null:
|
||||
return building
|
||||
return ship if from.distance_to(ship.global_position) \
|
||||
<= from.distance_to(building.global_position) else building
|
||||
|
||||
|
||||
static func is_building(target: Node) -> bool:
|
||||
return target != null and target.is_in_group("buildings")
|
||||
|
||||
|
||||
# #113: whether this enemy may act on a target however far away it is.
|
||||
# Asked here rather than read directly, so the three movement components
|
||||
# cannot drift into disagreeing about what "relentless" means.
|
||||
static func ignores_range(enemy: Node) -> bool:
|
||||
return enemy != null and bool(enemy.get("relentless"))
|
||||
1
enemies/EnemyTargets.gd.uid
Normal file
1
enemies/EnemyTargets.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://i44mdrpr6fg
|
||||
20
enemies/LancerEnemyDefinition.tres
Normal file
20
enemies/LancerEnemyDefinition.tres
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyDefinition.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://enemies/EnemyLungeComponent.tscn" id="2"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
enemy_id = "lancer"
|
||||
display_name = "Lancer"
|
||||
max_hp = 2
|
||||
speed = 220.0
|
||||
acceleration = 420.0
|
||||
rotation_speed = 7.0
|
||||
hull_points = PackedVector2Array(0, -32, 6, -8, 11, 14, 4, 10, 0, 20, -4, 10, -11, 14, -6, -8)
|
||||
glow_color = Color(1, 0.45, 0.15, 1)
|
||||
detail_lines = PackedVector2Array(0, -28, 0, 6, -7, -2, 7, -2)
|
||||
detail_rings = PackedVector2Array(0, 8, 4, 0)
|
||||
dies_on_contact = true
|
||||
component_scenes = Array[PackedScene]([ExtResource("2")])
|
||||
contact_damage = 22
|
||||
|
|
@ -19,3 +19,4 @@ glow_color = Color(0.55, 0.05, 0.85, 1)
|
|||
detail_lines = PackedVector2Array(0, -44, 0, 40, -28, -18, 28, -18, -34, 4, 34, 4, -20, 28, 20, 28, -12, -40, 12, -40)
|
||||
detail_rings = PackedVector2Array(0, -28, 9, 0, 0, 18, 6, 0)
|
||||
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3"), ExtResource("4"), ExtResource("5")])
|
||||
contact_damage = 34
|
||||
|
|
|
|||
|
|
@ -17,3 +17,4 @@ glow_color = Color(0.42, 0.72, 1.0, 1)
|
|||
detail_lines = PackedVector2Array(0, -30, 0, 10, -6, 12, 6, 12)
|
||||
detail_rings = PackedVector2Array(0, -2, 5, 0)
|
||||
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")])
|
||||
contact_damage = 4
|
||||
|
|
|
|||
|
|
@ -16,3 +16,4 @@ glow_color = Color(0.68, 0.92, 0.18, 1)
|
|||
detail_lines = PackedVector2Array(-16, -16, 16, 16, 16, -16, -16, 16)
|
||||
detail_rings = PackedVector2Array(0, 0, 13, 0, 0, -22, 5, 0, 0, 22, 5, 0, -22, 0, 5, 0, 22, 0, 5, 0)
|
||||
component_scenes = Array[PackedScene]([ExtResource("2")])
|
||||
contact_damage = 5
|
||||
|
|
|
|||
40
logistics/RepairDrone.gd
Normal file
40
logistics/RepairDrone.gd
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
extends Node2D
|
||||
|
||||
# #116: purely visual - a repair drone on its way out to a damaged structure
|
||||
# or back to its workshop. Carries no state of its own; GameState owns the
|
||||
# simulation and RepairDroneField creates, positions and frees this to match.
|
||||
|
||||
const HULL_COLOR: Color = Color(0.5, 0.95, 0.8)
|
||||
|
||||
var laden: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("repair_drones")
|
||||
# #91: behind the fleet, like every other piece of ground furniture.
|
||||
z_index = -1
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_laden(value: bool) -> void:
|
||||
if laden == value:
|
||||
return
|
||||
laden = value
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
# Small and blunt, with a tool arm out front, in the workshop's own
|
||||
# green-teal: it should read as a tender belonging to a building rather
|
||||
# than as a fleet vessel or a hub's magenta hauler.
|
||||
var hull: PackedVector2Array = PackedVector2Array([
|
||||
Vector2(0, -11), Vector2(8, -3), Vector2(6, 10), Vector2(-6, 10), Vector2(-8, -3),
|
||||
])
|
||||
NeonDraw.draw_glowing_polygon(self, hull, HULL_COLOR, 1.0)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(0, -11), Vector2(0, -18), HULL_COLOR, 0.9, 0.35)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(-5, -18), Vector2(5, -18), HULL_COLOR, 0.9, 0.35)
|
||||
if laden:
|
||||
# The load it is carrying out, in ore's own colour so it reads as
|
||||
# material rather than as a light on the hull.
|
||||
NeonDraw.draw_glowing_circle(self, Vector2(0, 3), 5.0,
|
||||
GameState.resource_color(GameState.RESOURCE_ORE), 1.0)
|
||||
1
logistics/RepairDrone.gd.uid
Normal file
1
logistics/RepairDrone.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bmul8vo7ddm7l
|
||||
6
logistics/RepairDrone.tscn
Normal file
6
logistics/RepairDrone.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://logistics/RepairDrone.gd" id="1"]
|
||||
|
||||
[node name="RepairDrone" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
59
logistics/RepairDroneField.gd
Normal file
59
logistics/RepairDroneField.gd
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
extends Node2D
|
||||
|
||||
# #116: draws the repair drones GameState is simulating, and only while they
|
||||
# are somewhere the player could see them.
|
||||
#
|
||||
# Same arrangement as TransporterField, and for the same reason: the
|
||||
# simulation is authoritative and the visuals are optional, so a repair run
|
||||
# happening far away costs nothing but still puts the building back together.
|
||||
# Position comes from the very same `progress` that decides when the repair
|
||||
# actually lands.
|
||||
|
||||
@export var visible_radius: float = 2600.0
|
||||
|
||||
@onready var fleet: Node2D = get_node("../Fleet")
|
||||
|
||||
var drone_scene: PackedScene = preload("res://logistics/RepairDrone.tscn")
|
||||
# index into GameState.repair_drones -> live node
|
||||
var active: Dictionary = {}
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
var centre: Vector2 = fleet.get_centroid()
|
||||
|
||||
for i in range(GameState.repair_drones.size()):
|
||||
var drone: Dictionary = GameState.repair_drones[i]
|
||||
# An idle drone is parked inside its workshop's own art; drawing it
|
||||
# there adds a dot sitting on a building rather than a vessel doing
|
||||
# something.
|
||||
if String(drone.state) == GameState.DRONE_IDLE:
|
||||
_despawn(i)
|
||||
continue
|
||||
var pos: Vector2 = GameState.repair_drone_position(drone)
|
||||
if pos.distance_to(centre) > visible_radius:
|
||||
_despawn(i)
|
||||
continue
|
||||
|
||||
var node: Node2D = active.get(i)
|
||||
if node == null or not is_instance_valid(node):
|
||||
node = drone_scene.instantiate()
|
||||
get_parent().add_child(node)
|
||||
active[i] = node
|
||||
|
||||
var previous: Vector2 = node.position
|
||||
node.position = pos
|
||||
node.set_laden(int(drone.carrying) > 0)
|
||||
var travel: Vector2 = pos - previous
|
||||
if travel.length() > 0.5:
|
||||
node.rotation = travel.angle() + PI / 2.0
|
||||
|
||||
for key in active.keys():
|
||||
if key >= GameState.repair_drones.size():
|
||||
_despawn(key)
|
||||
|
||||
|
||||
func _despawn(index: int) -> void:
|
||||
var node: Node2D = active.get(index)
|
||||
if node != null and is_instance_valid(node):
|
||||
node.queue_free()
|
||||
active.erase(index)
|
||||
1
logistics/RepairDroneField.gd.uid
Normal file
1
logistics/RepairDroneField.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://def6v28yy6qeo
|
||||
|
|
@ -39,6 +39,12 @@ func _ready() -> void:
|
|||
camera.position = GameState.return_world_position
|
||||
camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom)
|
||||
GameState.has_return_position = false
|
||||
elif GameState.intro_active:
|
||||
# #108: the prologue opens with a convoy, and crews it itself. This
|
||||
# node still runs first, so spawning the ordinary opening fleet here
|
||||
# would be immediately thrown away - and worse, would be the fleet
|
||||
# for the one frame before the director replaced it.
|
||||
pass
|
||||
else:
|
||||
# A brand new game. STARTING_SHIP_COUNT rather than a literal: this
|
||||
# said 5, which was the old BASE_FLEET_SIZE written out by hand, and
|
||||
|
|
@ -123,6 +129,11 @@ func _on_enter_pressed() -> void:
|
|||
GameState.planet_name = current_planet.planet_name
|
||||
GameState.planet_id = current_planet.planet_id
|
||||
GameState.planet_definition_id = current_planet.definition_id
|
||||
# #133: quests ask about worlds LANDED ON, which is a different question
|
||||
# from the ones flown past that explored_chunks answers. Recorded at the
|
||||
# moment of committing to the descent - the same moment the three lines
|
||||
# above are written - so the two can never disagree.
|
||||
GameState.note_planet_visited(current_planet.planet_id, current_planet.definition_id)
|
||||
|
||||
var tween: Tween = create_tween()
|
||||
tween.tween_property(fade_rect, "color:a", 1.0, fade_duration)
|
||||
|
|
|
|||
72
shaders/breach.gdshader
Normal file
72
shaders/breach.gdshader
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
shader_type canvas_item;
|
||||
|
||||
// #125: the dimensional breach, as a screen-space lens.
|
||||
//
|
||||
// The prologue's warp used to be a handful of lines turning around a point.
|
||||
// This bends the ACTUAL SCENE around the breach instead - the ships, the
|
||||
// rocks and the starfield all get dragged into it - which is most of what
|
||||
// makes a hole in space read as a hole rather than as a decal drawn on top
|
||||
// of one.
|
||||
//
|
||||
// Two effects, both radial and both fading to nothing at the edge of their
|
||||
// reach, so the shader is invisible outside its own radius and costs a
|
||||
// clamped texture fetch everywhere else:
|
||||
//
|
||||
// swirl angle offset that grows toward the centre, so the scene winds
|
||||
// around the hole rather than merely sliding toward it
|
||||
// pull inward displacement, strongest just outside the horizon, which
|
||||
// is the lensing itself
|
||||
//
|
||||
// Inside the horizon nothing is sampled at all: the core is drawn as empty,
|
||||
// the same decision BlackHoleVisual makes for the black-hole planet, and
|
||||
// for the same reason - a "black hole" with something visible in the middle
|
||||
// stops reading as one.
|
||||
|
||||
uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_linear;
|
||||
|
||||
// Where the breach is, in SCREEN pixels, and how big it has grown.
|
||||
uniform vec2 breach_center = vec2(360.0, 640.0);
|
||||
uniform vec2 viewport_size = vec2(720.0, 1280.0);
|
||||
// The event horizon: everything inside is void.
|
||||
uniform float horizon_radius = 0.0;
|
||||
// How far the lens reaches past the horizon. Beyond this the scene is
|
||||
// untouched.
|
||||
uniform float lens_radius = 1.0;
|
||||
// 0 while the breach is opening, 1 at full strength. Scales both effects
|
||||
// together so one knob fades the whole thing in.
|
||||
uniform float strength = 0.0;
|
||||
uniform float swirl_turns = 2.4;
|
||||
uniform vec4 rim_color : source_color = vec4(0.72, 0.45, 1.0, 1.0);
|
||||
|
||||
void fragment() {
|
||||
vec2 pixel = UV * viewport_size;
|
||||
vec2 offset = pixel - breach_center;
|
||||
float distance_to_centre = length(offset);
|
||||
|
||||
if (distance_to_centre >= lens_radius || strength <= 0.001) {
|
||||
COLOR = texture(screen_texture, SCREEN_UV);
|
||||
} else if (distance_to_centre <= horizon_radius) {
|
||||
// The core. Not black-with-alpha: genuinely nothing, so whatever is
|
||||
// behind the breach is gone rather than dimmed.
|
||||
COLOR = vec4(0.0, 0.0, 0.0, 1.0);
|
||||
} else {
|
||||
// 1 at the horizon, 0 at the edge of the lens. Squared so the bend
|
||||
// is concentrated near the hole instead of smeared evenly outward.
|
||||
float falloff = 1.0 - clamp(
|
||||
(distance_to_centre - horizon_radius) / max(lens_radius - horizon_radius, 1.0),
|
||||
0.0, 1.0);
|
||||
float bend = falloff * falloff * strength;
|
||||
|
||||
float angle = atan(offset.y, offset.x) + bend * swirl_turns;
|
||||
float pulled = distance_to_centre - bend * (lens_radius - horizon_radius) * 0.55;
|
||||
pulled = max(pulled, horizon_radius);
|
||||
vec2 sampled = breach_center + vec2(cos(angle), sin(angle)) * pulled;
|
||||
|
||||
COLOR = texture(screen_texture, sampled / viewport_size);
|
||||
|
||||
// A hot rim where the lensing is fiercest, which is what gives the
|
||||
// horizon an edge rather than a fade.
|
||||
float rim = smoothstep(0.72, 1.0, falloff) * strength;
|
||||
COLOR.rgb = mix(COLOR.rgb, rim_color.rgb, rim * 0.65);
|
||||
}
|
||||
}
|
||||
1
shaders/breach.gdshader.uid
Normal file
1
shaders/breach.gdshader.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c6eeucdbj3nmi
|
||||
|
|
@ -13,6 +13,14 @@ var vents: Array[Vector2] = []
|
|||
var progress: float = 0.0
|
||||
var emission_phase: float = 0.0
|
||||
func _ready() -> void:
|
||||
# #91: the vents are ground features and must never cover the fleet.
|
||||
# SurfaceFeatures is added AFTER Fleet in PlanetSurface.tscn, so at the
|
||||
# default z_index of 0 the later sibling simply wins and the player flies
|
||||
# UNDER their own gas source. Same fix, and the same reason, as #50's
|
||||
# capture rings and the rocks on a rocky surface (Rock.gd) - the
|
||||
# difference is that this field draws in its own _draw() rather than
|
||||
# spawning child nodes, so the depth belongs on the field itself.
|
||||
z_index = -1
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = hash(GameState.planet_id if GameState.planet_id != "" else GameState.planet_name)
|
||||
for i in range(6): vents.append(Vector2(rng.randf_range(-800,800), rng.randf_range(-650,650)))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
[gd_scene load_steps=16 format=3]
|
||||
[gd_scene load_steps=19 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://controllers/GameController.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://surface/SurfaceBackground.tscn" id="3"]
|
||||
|
|
@ -13,6 +13,9 @@
|
|||
[ext_resource type="Script" path="res://controllers/LaserController.gd" id="16"]
|
||||
[ext_resource type="Script" path="res://ui/SpeedFader.gd" id="17"]
|
||||
[ext_resource type="Script" path="res://surface/CaptureLabel.gd" id="15"]
|
||||
[ext_resource type="Script" path="res://ui/NarrationBanner.gd" id="banner"]
|
||||
[ext_resource type="Script" path="res://world/QuestDirector.gd" id="quest_dir"]
|
||||
[ext_resource type="Script" path="res://ui/QuestPanel.gd" id="quest_panel"]
|
||||
|
||||
[node name="PlanetSurface" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
|
@ -45,6 +48,9 @@ zoom = Vector2(1, 1)
|
|||
[node name="SurfaceController" type="Node2D" parent="."]
|
||||
script = ExtResource("5")
|
||||
|
||||
[node name="QuestDirector" type="Node" parent="."]
|
||||
script = ExtResource("quest_dir")
|
||||
|
||||
[node name="UI" type="CanvasLayer" parent="."]
|
||||
layer = 1
|
||||
|
||||
|
|
@ -123,6 +129,37 @@ icon = 4
|
|||
glow_color = Color(0.06, 0.32, 0.5, 1)
|
||||
core_color = Color(0.02, 0.09, 0.15, 1)
|
||||
|
||||
[node name="QuestButton" type="Control" parent="UI"]
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -172.0
|
||||
offset_right = -24.0
|
||||
offset_top = 160.0
|
||||
offset_bottom = 308.0
|
||||
script = ExtResource("6")
|
||||
icon = 44
|
||||
|
||||
[node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]]
|
||||
visible = false
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 24.0
|
||||
offset_top = 24.0
|
||||
offset_right = -24.0
|
||||
offset_bottom = -24.0
|
||||
script = ExtResource("quest_panel")
|
||||
|
||||
[node name="CloseButton" type="Control" parent="UI/QuestPanel"]
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -140.0
|
||||
offset_right = -24.0
|
||||
offset_top = 20.0
|
||||
offset_bottom = 136.0
|
||||
script = ExtResource("6")
|
||||
icon = 1
|
||||
|
||||
[node name="Joystick" type="Control" parent="UI"]
|
||||
mouse_filter = 2
|
||||
script = ExtResource("7")
|
||||
|
|
@ -273,6 +310,16 @@ offset_right = -252
|
|||
offset_top = -130
|
||||
offset_bottom = -26
|
||||
|
||||
[node name="NarrationBanner" type="Control" parent="UI"]
|
||||
visible = false
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
offset_left = 60.0
|
||||
offset_right = -60.0
|
||||
offset_top = 300.0
|
||||
offset_bottom = 428.0
|
||||
script = ExtResource("banner")
|
||||
|
||||
[node name="FadeOverlay" type="CanvasLayer" parent="."]
|
||||
layer = 10
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
#!/bin/bash
|
||||
# Runs INSIDE the proot-distro Ubuntu container - do not call this directly,
|
||||
# call tests/run_tests.sh from Termux instead.
|
||||
# The half of the runner that actually runs Godot - do not call this
|
||||
# directly, call tests/run_tests.sh instead.
|
||||
#
|
||||
# It exists as its own file so the whole suite runs in ONE `proot-distro
|
||||
# login`: entering the container costs ~0.5s, which used to be paid 38 times
|
||||
# over. Everything below is the container-side half of the runner.
|
||||
# On Termux it runs INSIDE the proot-distro Ubuntu container. It exists as
|
||||
# its own file so the whole suite runs in ONE `proot-distro login`: entering
|
||||
# the container costs ~0.5s, which used to be paid 38 times over. On WSL
|
||||
# there is no container to enter and run_tests.sh calls it directly - the
|
||||
# scheduling, timeouts and verdicts below are the same either way, which is
|
||||
# why the Godot binary arrives as an argument rather than being hardcoded.
|
||||
#
|
||||
# Args: <project_dir> <jobs> <verbose> <test_file_name>...
|
||||
# Args: <godot_bin> <project_dir> <jobs> <verbose> <test_file_name>...
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
GODOT_BIN="$1"; shift
|
||||
PROJECT_DIR="$1"; shift
|
||||
JOBS="$1"; shift
|
||||
VERBOSE="$1"; shift
|
||||
|
|
@ -18,8 +22,35 @@ VERBOSE="$1"; shift
|
|||
# complete in seconds, even on a busy phone, so make that failure explicit
|
||||
# rather than making the suite look hung. Override for deliberate long runs.
|
||||
TEST_TIMEOUT_SECONDS="${TEST_TIMEOUT_SECONDS:-120}"
|
||||
# How many times to start a test whose process never got as far as saying
|
||||
# anything. Under WSL the Godot binary is a Windows executable started across
|
||||
# the interop boundary, and with six of them going at once that start
|
||||
# occasionally just fails. It has been seen two ways: exit 127 with a
|
||||
# completely empty log, and a non-zero exit after the engine banner and not
|
||||
# one line more. Both moved from test to test between runs and both passed
|
||||
# alone, so both are properties of the launch rather than of the test.
|
||||
#
|
||||
# The line that separates them from a real failure is OUTPUT, not the exit
|
||||
# code and not the banner: every test says something before it stops - its
|
||||
# own FAIL verdict, a SCRIPT ERROR, an engine complaint. A log holding
|
||||
# nothing but the banner is a run that did not happen. This can only ever
|
||||
# mask a fault that is both silent AND intermittent; a deterministic crash
|
||||
# still fails all LAUNCH_ATTEMPTS and is reported.
|
||||
LAUNCH_ATTEMPTS="${LAUNCH_ATTEMPTS:-3}"
|
||||
|
||||
|
||||
# Did the process produce any line of its own? The engine banner and blank
|
||||
# lines are what a bare launch prints by itself and do not count.
|
||||
#
|
||||
# Tests the OUTPUT rather than grep's exit status on purpose: `grep -q -v`
|
||||
# does not agree across greps - ugrep, which is what `grep` resolves to on
|
||||
# at least one machine this suite runs on, reports success for an empty file
|
||||
# and failure for a file full of matching lines. A non-empty result is the
|
||||
# same in every implementation.
|
||||
_said_anything() {
|
||||
[[ -n "$(grep -v -e '^[[:space:]]*$' -e '^Godot Engine' "$1")" ]]
|
||||
}
|
||||
|
||||
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
|
||||
cd "$PROJECT_DIR" || exit 2
|
||||
|
||||
tests=("$@")
|
||||
|
|
@ -53,18 +84,37 @@ run_one() {
|
|||
# 3 parallel runs. Pinned, a test simulates the same 60fps whatever else
|
||||
# the phone is doing, and its result no longer depends on how busy the
|
||||
# machine was.
|
||||
local rc=0
|
||||
local attempt=1
|
||||
while true; do
|
||||
timeout "$TEST_TIMEOUT_SECONDS" "$GODOT_BIN" --headless --fixed-fps 60 \
|
||||
-s "res://tests/$name" >"$log" 2>&1
|
||||
local rc=$?
|
||||
rc=$?
|
||||
# "Never started" is rc != 0 with a log that says nothing of its own.
|
||||
# Anything that ran - passed, failed, crashed mid-way - said something.
|
||||
if (( rc == 0 )) || _said_anything "$log" \
|
||||
|| (( attempt >= LAUNCH_ATTEMPTS )); then
|
||||
break
|
||||
fi
|
||||
attempt=$(( attempt + 1 ))
|
||||
sleep 1
|
||||
done
|
||||
end=$(date +%s%N)
|
||||
local ms=$(( (end - start) / 1000000 ))
|
||||
|
||||
# Retries are reported, never hidden: a test that needed one is telling
|
||||
# you the machine is struggling, even where it went on to pass.
|
||||
local retried=""
|
||||
if (( attempt > 1 )); then
|
||||
retried=" (started on attempt $attempt)"
|
||||
fi
|
||||
|
||||
# Two independent criteria, because either one alone can lie: a test that
|
||||
# crashes before reaching its verdict exits non-zero without ever printing
|
||||
# FAIL, and a hypothetical `quit(0)` on a failing path would exit clean
|
||||
# while the log says otherwise.
|
||||
if (( rc == 0 )) && grep -q ": PASS" "$log"; then
|
||||
printf 'PASS %6dms %s\n' "$ms" "$name" > "$outdir/$name.status"
|
||||
printf 'PASS %6dms %s%s\n' "$ms" "$name" "$retried" > "$outdir/$name.status"
|
||||
elif (( rc == 124 )); then
|
||||
printf 'TIMEOUT %4dms %s (limit %ss)\n' \
|
||||
"$ms" "$name" "$TEST_TIMEOUT_SECONDS" > "$outdir/$name.status"
|
||||
|
|
@ -75,8 +125,11 @@ run_one() {
|
|||
# indistinguishable from a hang.
|
||||
printf 'KILLED %4dms %s (signal %d - out of memory? try JOBS=1)\n' \
|
||||
"$ms" "$name" "$(( rc - 128 ))" > "$outdir/$name.status"
|
||||
elif (( rc != 0 )) && ! _said_anything "$log"; then
|
||||
printf 'NOSTART %3dms %s (exit %d after %d attempts - the process never came up)\n' \
|
||||
"$ms" "$name" "$rc" "$attempt" > "$outdir/$name.status"
|
||||
else
|
||||
printf 'FAIL %6dms %s (exit %d)\n' "$ms" "$name" "$rc" > "$outdir/$name.status"
|
||||
printf 'FAIL %6dms %s (exit %d)%s\n' "$ms" "$name" "$rc" "$retried" > "$outdir/$name.status"
|
||||
fi
|
||||
cat "$outdir/$name.status"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,27 @@
|
|||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
# Runs the headless GDScript gameplay tests from Termux, via the Godot
|
||||
# 4.7.1 Linux arm64 build installed inside the proot-distro Ubuntu container.
|
||||
#!/usr/bin/env bash
|
||||
# Runs the headless GDScript gameplay tests.
|
||||
#
|
||||
# bash tests/run_tests.sh # everything
|
||||
# bash tests/run_tests.sh formation # only tests matching a substring
|
||||
# JOBS=1 bash tests/run_tests.sh # serial, for debugging a hang
|
||||
# VERBOSE=1 bash tests/run_tests.sh # print passing tests' output too
|
||||
# IN_PLACE=1 bash tests/run_tests.sh # run from the project dir itself
|
||||
# GODOT_BIN=/path/to/godot bash tests/run_tests.sh # pick the binary
|
||||
#
|
||||
# Three things make this fast, in descending order of how much they matter
|
||||
# (measured, not guessed - see tests/_profile_startup.gd):
|
||||
# There are two ways to get a Godot to run them with, picked automatically:
|
||||
#
|
||||
# Termux the Godot 4.7 Linux arm64 build installed inside the
|
||||
# proot-distro Ubuntu container, which is the only way to run
|
||||
# a desktop Godot on the phone at all.
|
||||
# WSL the Windows Godot build off /mnt/c. A Linux `godot` on PATH
|
||||
# is used first if there is one.
|
||||
#
|
||||
# The platform decides which Godot runs and where the project is copied to.
|
||||
# Everything else - the copy itself, the worker count, and
|
||||
# tests/_run_inside.sh, which does the actual running - is shared.
|
||||
#
|
||||
# On Termux, three things make this fast, in descending order of how much
|
||||
# they matter (measured, not guessed - see tests/_profile_startup.gd):
|
||||
#
|
||||
# 1. It runs from a COPY on Termux's own filesystem, not from the project
|
||||
# directory. The project lives under ~/storage/shared, which is Android's
|
||||
|
|
@ -17,7 +29,15 @@
|
|||
# alone took 8.5s per test, against ~2s from a native path. Nearly every
|
||||
# test loads Main.tscn, and none of that time is gameplay. The copy is
|
||||
# ~1.4s for the whole suite and its .godot import cache is kept between
|
||||
# runs.
|
||||
# runs. WSL copies too, but to a native NTFS path rather than to ext4:
|
||||
# a Windows Godot reads C: at full speed and would read \\wsl$ slowly, so
|
||||
# the copy is for ISOLATION rather than for speed there. Six Godot
|
||||
# processes churning the project directory is six processes writing
|
||||
# .godot, creating and deleting files under PlanetSetups/, and doing it
|
||||
# in a working tree that git, an open editor and the player are also
|
||||
# using. Under that, single tests failed at random - a delete that did
|
||||
# not take, a scene that would not instantiate - never the same one
|
||||
# twice.
|
||||
# 2. Tests run JOBS at a time. Each is its own Godot process - they call
|
||||
# quit() and stamp on the GameState autoload, so they cannot share one -
|
||||
# which makes the suite a pile of independent, single-core-bound jobs on
|
||||
|
|
@ -32,6 +52,32 @@ set -uo pipefail
|
|||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
PLATFORM="termux"
|
||||
if [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
PLATFORM="wsl"
|
||||
fi
|
||||
|
||||
# Finding a Godot on WSL. A Linux build on PATH wins - no interop, no drive
|
||||
# letters - but there usually is not one, and the Windows build the editor
|
||||
# already runs from is right there on /mnt/c. Two things about picking that
|
||||
# one up:
|
||||
# - it must be the *_console.exe. The plain .exe is built as a Windows GUI
|
||||
# subsystem binary: it detaches from the terminal, so every test would
|
||||
# produce an empty log and the runner would call all of them failures.
|
||||
# - sort -V so 4.7.2 beats 4.6.1 when several versions are downloaded.
|
||||
_find_godot_wsl() {
|
||||
local found
|
||||
found="$(command -v godot 2>/dev/null)"
|
||||
if [[ -n "$found" ]]; then
|
||||
echo "$found"
|
||||
return
|
||||
fi
|
||||
found="$(find /mnt/c/Users/*/Downloads /mnt/c/Users/*/AppData/Local/Programs \
|
||||
"/mnt/c/Program Files" -maxdepth 2 -name 'Godot_v*_console.exe' \
|
||||
2>/dev/null | sort -V | tail -1)"
|
||||
echo "$found"
|
||||
}
|
||||
|
||||
# Worker count comes from FREE MEMORY, not core count. Each Godot instance is
|
||||
# ~400MB and this phone shares its RAM with everything else running on it.
|
||||
# Getting it wrong is bad in both directions:
|
||||
|
|
@ -64,7 +110,43 @@ _pick_jobs() {
|
|||
JOBS="${JOBS:-$(_pick_jobs)}"
|
||||
VERBOSE="${VERBOSE:-0}"
|
||||
IN_PLACE="${IN_PLACE:-0}"
|
||||
WORK_DIR="${WORK_DIR:-$HOME/.cache/spaceship-tests}"
|
||||
|
||||
# Where the copy lives. Termux wants its own native filesystem; WSL wants a
|
||||
# WINDOWS one, because the thing reading it is a Windows binary and $HOME is
|
||||
# ext4 behind the \\wsl$ share. %LOCALAPPDATA% is the natural home for it;
|
||||
# if that cannot be read, fall back to a sibling of the project - still on
|
||||
# the same native drive, still outside the repo.
|
||||
_default_work_dir() {
|
||||
if [[ "$PLATFORM" != "wsl" ]]; then
|
||||
echo "$HOME/.cache/spaceship-tests"
|
||||
return
|
||||
fi
|
||||
local win_local
|
||||
win_local="$(cmd.exe /c 'echo %LOCALAPPDATA%' 2>/dev/null | tr -d '\r')"
|
||||
if [[ -n "$win_local" && "$win_local" != *'%'* ]]; then
|
||||
local unix_local
|
||||
unix_local="$(wslpath -u "$win_local" 2>/dev/null)"
|
||||
if [[ -n "$unix_local" ]]; then
|
||||
echo "$unix_local/spaceship-tests"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo "$(dirname "$PROJECT_DIR")/.spaceship-tests"
|
||||
}
|
||||
WORK_DIR="${WORK_DIR:-$(_default_work_dir)}"
|
||||
|
||||
if [[ -z "${GODOT_BIN:-}" ]]; then
|
||||
if [[ "$PLATFORM" == "wsl" ]]; then
|
||||
GODOT_BIN="$(_find_godot_wsl)"
|
||||
if [[ -z "$GODOT_BIN" ]]; then
|
||||
echo "no Godot found. Install one on PATH, or download a Windows" >&2
|
||||
echo "build and point GODOT_BIN at its *_console.exe." >&2
|
||||
exit 2
|
||||
fi
|
||||
else
|
||||
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
|
||||
fi
|
||||
fi
|
||||
|
||||
filter="${1:-}"
|
||||
tests=()
|
||||
|
|
@ -91,14 +173,22 @@ if [[ "$IN_PLACE" != "1" ]]; then
|
|||
# keep being run.
|
||||
find "$run_dir" -mindepth 1 -maxdepth 1 ! -name '.godot' -exec rm -rf {} +
|
||||
# android/ and builds/ are export artefacts no test touches, and the .apk
|
||||
# is 30MB of nothing useful here.
|
||||
# is 30MB of nothing useful here. audio/masters/ is the same argument:
|
||||
# Godot skips it (.gdignore) so no test can reach it, and it is 59MB of
|
||||
# source WAV that would be copied on every single run.
|
||||
tar -C "$PROJECT_DIR" \
|
||||
--exclude=./.godot --exclude=./android --exclude=./builds --exclude='*.apk' \
|
||||
--exclude=./audio/masters \
|
||||
-cf - . 2>/dev/null | tar -C "$run_dir" -xf -
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "wsl" ]]; then
|
||||
bash "$run_dir/tests/_run_inside.sh" \
|
||||
"$GODOT_BIN" "$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}"
|
||||
else
|
||||
proot-distro login ubuntu -- bash "$run_dir/tests/_run_inside.sh" \
|
||||
"$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}"
|
||||
"$GODOT_BIN" "$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}"
|
||||
fi
|
||||
status=$?
|
||||
|
||||
echo "in $(( $(date +%s) - start ))s with JOBS=$JOBS"
|
||||
|
|
|
|||
|
|
@ -10,16 +10,23 @@ extends SceneTree
|
|||
# test_return_position_matches_planet fail intermittently.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. _safe_position never returns a point inside the bubble, for candidates
|
||||
# all around and including the exact centre. This is checked directly
|
||||
# rather than by looking at where asteroids happened to land: with ~54
|
||||
# asteroids spread over 25 chunks, any single run only rarely produces a
|
||||
# near miss, so an observational check passes just as happily with the
|
||||
# guard removed and proves nothing.
|
||||
# 2. candidates already outside the bubble are left exactly where they are,
|
||||
# so the field is not distorted away from the fleet
|
||||
# 3. the field still spawns a full complement, and a stationary fleet loses
|
||||
# no ships
|
||||
# #126 widens the rule from "not on the fleet" to "not IN VIEW": the old
|
||||
# bubble was 420px, smaller than the screen, so a rock could and did
|
||||
# materialise in plain sight several hundred pixels away.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. safe_position never returns a point inside the keep-out area, for
|
||||
# candidates all around it and including its exact centre. Checked
|
||||
# directly rather than by looking at where asteroids happened to land:
|
||||
# with ~54 asteroids over 25 chunks, any single run only rarely
|
||||
# produces a near miss, so an observational check passes just as
|
||||
# happily with the guard removed and proves nothing.
|
||||
# 2. candidates already outside are left exactly where they are, so the
|
||||
# field is not distorted away from the fleet
|
||||
# 3. the keep-out area really is the visible rect and not the old bubble
|
||||
# 4. nothing the field actually placed is on screen
|
||||
# 5. the field still spawns a full complement, and a stationary fleet
|
||||
# loses no ships
|
||||
|
||||
var scene: Node
|
||||
var fleet: Node
|
||||
|
|
@ -53,32 +60,50 @@ func _process(delta: float) -> bool:
|
|||
|
||||
var centre: Vector2 = fleet.get_centroid()
|
||||
var safe: float = field.safe_spawn_radius
|
||||
var keep_out: Rect2 = field.keep_out_rect(centre)
|
||||
|
||||
# --- the guard itself, deterministically ----------------------
|
||||
var worst_inside: float = 1e9
|
||||
# --- 3. the keep-out area is the screen, not the old bubble ---
|
||||
var on_screen: Rect2 = ViewUtils.get_visible_world_rect(field)
|
||||
if not keep_out.encloses(on_screen):
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the keep-out area does not cover the visible rect)")
|
||||
quit(1)
|
||||
return true
|
||||
if keep_out.size.x < safe * 2.0 or keep_out.size.y < safe * 2.0:
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the keep-out area shrank below the old bubble)")
|
||||
quit(1)
|
||||
return true
|
||||
print("keep-out %.0fx%.0f covers a %.0fx%.0f screen (old bubble was %.0f across)" % [
|
||||
keep_out.size.x, keep_out.size.y, on_screen.size.x, on_screen.size.y, safe * 2.0
|
||||
])
|
||||
|
||||
# --- 1/2. the guard itself, deterministically -----------------
|
||||
var half: Vector2 = keep_out.size * 0.5
|
||||
var reach: float = maxf(half.x, half.y)
|
||||
var let_one_through: bool = false
|
||||
var moved_an_outside_one: bool = false
|
||||
for i in range(72):
|
||||
var a: float = (float(i) / 72.0) * TAU
|
||||
var dir: Vector2 = Vector2(cos(a), sin(a))
|
||||
# Candidates well inside, just inside, and exactly on centre.
|
||||
for d in [0.0, 1.0, safe * 0.25, safe * 0.99]:
|
||||
var got: Vector2 = field._safe_position(centre + dir * d, centre)
|
||||
worst_inside = minf(worst_inside, got.distance_to(centre))
|
||||
for d in [0.0, 1.0, reach * 0.25, reach * 0.7]:
|
||||
var candidate: Vector2 = keep_out.get_center() + dir * d
|
||||
if not keep_out.has_point(candidate):
|
||||
continue
|
||||
if keep_out.has_point(field.safe_position(candidate, keep_out)):
|
||||
let_one_through = true
|
||||
# Candidates already outside must be returned untouched.
|
||||
var outside: Vector2 = centre + dir * (safe * 1.5)
|
||||
if field._safe_position(outside, centre).distance_to(outside) > 0.001:
|
||||
var outside: Vector2 = keep_out.get_center() + dir * (reach * 3.0)
|
||||
if field.safe_position(outside, keep_out).distance_to(outside) > 0.001:
|
||||
moved_an_outside_one = true
|
||||
print("closest result for an inside candidate: %.1f (safe radius %.1f)" % [
|
||||
worst_inside, safe
|
||||
])
|
||||
if worst_inside < safe - 0.001:
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard let a spawn land inside the fleet's bubble)")
|
||||
if let_one_through:
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the guard left a spawn inside the visible area)")
|
||||
quit(1)
|
||||
return true
|
||||
if moved_an_outside_one:
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard displaced an asteroid that was already clear)")
|
||||
quit(1)
|
||||
return true
|
||||
print("every one of 288 in-view candidates was pushed out; none outside was touched")
|
||||
|
||||
var asteroids: Array = get_nodes_in_group("asteroids")
|
||||
var closest: float = 1e9
|
||||
|
|
|
|||
|
|
@ -3,9 +3,15 @@ extends SceneTree
|
|||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_asteroid_ship_collision.gd
|
||||
# Verifies that a ship touching an asteroid destroys BOTH the ship (removed
|
||||
# from the fleet) and the asteroid itself (mutual destruction), each
|
||||
# spawning its own coherent explosion VFX: light-burst blue for the ship,
|
||||
# debris brown for the asteroid.
|
||||
# from the fleet) and the asteroid itself (mutual destruction), each leaving
|
||||
# its own coherent VFX behind.
|
||||
#
|
||||
# #130 changed what the ship leaves. Its death is a sequence now - the hull
|
||||
# breaks away from the fleet, drifts clear, and explodes out there - so what
|
||||
# exists on the frame of the collision is the WRECK, and the explosion comes
|
||||
# later, somewhere else. That the wreck really does drift and really does go
|
||||
# up is test_ship_death's job; what matters here is that the collision still
|
||||
# ends the ship, ends the rock, and leaves each of them something to look at.
|
||||
|
||||
const CHECK_DELAY: float = 0.05
|
||||
|
||||
|
|
@ -55,7 +61,7 @@ func _check_results() -> void:
|
|||
var ship_vfx_ok: bool = false
|
||||
var asteroid_vfx_ok: bool = false
|
||||
for child in root.get_children():
|
||||
if child is ShipExplosionVFX:
|
||||
if child is ShipWreckVFX:
|
||||
ship_vfx_ok = true
|
||||
elif child is AsteroidExplosionVFX:
|
||||
asteroid_vfx_ok = true
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ var controller: Node
|
|||
var popup: Control
|
||||
var title: Label
|
||||
var message: Label
|
||||
var cost_number: NeonNumber
|
||||
# Control, not PriceTag: naming a class whose body reads the GameState
|
||||
# autoload does not compile inside an `extends SceneTree` script.
|
||||
var cost_number: Control
|
||||
var build_button: IconButton
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
|
@ -37,6 +39,10 @@ const OPTIONS: Array = [
|
|||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.rocks = 500
|
||||
# #138: some structures want gas as well as rocks, and the popup
|
||||
# only offers a build button for something the player can pay for.
|
||||
game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 500)
|
||||
game_state.bank_inventory.set_amount(GameState.RESOURCE_ENERGY, 500)
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
# Structures are gated behind the mothership's research tree. That gate
|
||||
|
|
@ -89,11 +95,12 @@ func _process(delta: float) -> bool:
|
|||
var informed: bool = popup.visible \
|
||||
and title.text == option.title \
|
||||
and message.text.length() > 40 \
|
||||
and cost_number.amount == expected_cost \
|
||||
and cost_number.priced(GameState.RESOURCE_ORE) == expected_cost \
|
||||
and build_button.visible
|
||||
var untouched: bool = game_state.rocks == rocks_before and _count(type) == built_before
|
||||
print("%s: popup=%s title='%s' cost=%d offered=%s nothing_spent_yet=%s" % [
|
||||
type, popup.visible, title.text, cost_number.amount,
|
||||
type, popup.visible, title.text,
|
||||
cost_number.priced(GameState.RESOURCE_ORE),
|
||||
build_button.visible, untouched
|
||||
])
|
||||
if not (informed and untouched):
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ extends SceneTree
|
|||
# 4. an entry alone on its row is centred, not hanging in the left column
|
||||
# 5. researched-but-not-usable is still shown greyed (an extractor with no
|
||||
# planet under the fleet is "not now", not "not yet")
|
||||
# 6. #136 - with every structure researched, no two entries overlap. The
|
||||
# grid used to be a fixed list of row positions read with a clamp, so
|
||||
# the first row that would not fit was drawn on top of another one
|
||||
# 7. #144 - and that holds at any window shape. The header is anchored to
|
||||
# the top of the panel and the grid to its centre, so the gap between
|
||||
# them is half the panel height - it was a constant measured at 1280,
|
||||
# and at 1080 the grid started above the status line and drew through it
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
|
|
@ -45,6 +52,12 @@ func _shown() -> Array:
|
|||
return out
|
||||
|
||||
|
||||
func _rect(node: Control) -> Rect2:
|
||||
return Rect2(Vector2(node.offset_left, node.offset_top),
|
||||
Vector2(node.offset_right - node.offset_left,
|
||||
node.offset_bottom - node.offset_top))
|
||||
|
||||
|
||||
func _slots() -> Array:
|
||||
# (row, x) of every visible button, in menu order.
|
||||
var out: Array = []
|
||||
|
|
@ -100,21 +113,94 @@ func _process(delta: float) -> bool:
|
|||
|
||||
# --- 3. consecutive slots, no holes -------------------------
|
||||
var slots: Array = _slots()
|
||||
var rows: Array = controller.MENU_ROW_TOPS
|
||||
var columns: int = controller.menu_columns(slots.size())
|
||||
var rows: int = controller.menu_rows(slots.size())
|
||||
if columns != 2:
|
||||
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (4 entries laid out in %d columns)" % columns)
|
||||
quit(1)
|
||||
return true
|
||||
for i in range(slots.size()):
|
||||
var want_row: float = rows[i / 2]
|
||||
var want_row: float = controller.menu_row_top(i / columns, rows, columns)
|
||||
if absf(slots[i][0] - want_row) > 0.01:
|
||||
print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], i / 2, want_row])
|
||||
print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], i / columns, want_row])
|
||||
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (a gap was left where a hidden entry used to be)")
|
||||
quit(1)
|
||||
return true
|
||||
var want_x: float = -controller.MENU_COL_X if i % 2 == 0 else controller.MENU_COL_X
|
||||
var spacing: float = controller.menu_column_spacing(columns)
|
||||
var want_x: float = -spacing * 0.5 if i % 2 == 0 else spacing * 0.5
|
||||
if absf(slots[i][1] - want_x) > 0.01:
|
||||
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (entry %d at x=%.1f, expected %.1f)" % [i, slots[i][1], want_x])
|
||||
quit(1)
|
||||
return true
|
||||
print("4 entries packed into consecutive slots, no gaps")
|
||||
|
||||
# --- 6. #136: nothing overlaps, with EVERY structure on offer -
|
||||
# The bug the ticket reported: the row list ran out and the last
|
||||
# entry was placed on top of one already there.
|
||||
for node in GameState.SKILL_TREE:
|
||||
game_state.unlocked.append(String(node.id))
|
||||
controller._refresh_build_status()
|
||||
var all_shown: Array = _shown()
|
||||
if all_shown.size() < 9:
|
||||
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (only %d structures unlocked - not the crowded case)" % all_shown.size())
|
||||
quit(1)
|
||||
return true
|
||||
for i in range(all_shown.size()):
|
||||
for j in range(i + 1, all_shown.size()):
|
||||
for part in [1, 2, 3]:
|
||||
var a: Control = all_shown[i][part]
|
||||
var b: Control = all_shown[j][part]
|
||||
if _rect(a).intersects(_rect(b)):
|
||||
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (%s and %s overlap at %s / %s)" \
|
||||
% [all_shown[i][0], all_shown[j][0], _rect(a), _rect(b)])
|
||||
quit(1)
|
||||
return true
|
||||
print("%d structures on offer, in %d columns, nothing overlapping" \
|
||||
% [all_shown.size(), controller.menu_columns(all_shown.size())])
|
||||
|
||||
# --- 7. #144: and at any window shape ------------------------
|
||||
# The grid is anchored to the panel's CENTRE and the status line
|
||||
# above it to the panel's TOP, so the gap between them is half
|
||||
# the panel height. It used to be a written-down constant, right
|
||||
# only at 1280 - at 1080 the grid started above the status line
|
||||
# and drew through it, which is what the ticket reported.
|
||||
var menu: Control = scene.get_node("UI/BuildMenu")
|
||||
var status: Control = scene.get_node("UI/BuildMenu/StatusLabel")
|
||||
var restore: float = menu.offset_bottom
|
||||
for panel_height in [900.0, 1080.0, 1280.0, 1600.0]:
|
||||
menu.anchor_bottom = 0.0
|
||||
menu.offset_top = 0.0
|
||||
menu.offset_bottom = panel_height
|
||||
controller._refresh_build_status()
|
||||
var half: float = menu.size.y * 0.5
|
||||
# The status line's own bottom, in the grid's centre-anchored
|
||||
# frame - which is the comparison that was never made.
|
||||
var header_bottom: float = status.offset_bottom - half
|
||||
var placed: Array = _shown()
|
||||
for a in range(placed.size()):
|
||||
for part in [1, 2, 3]:
|
||||
var box: Rect2 = _rect(placed[a][part])
|
||||
if box.position.y < header_bottom:
|
||||
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (at %.0fpx tall, %s is drawn at y=%.0f, above the status line at %.0f)" \
|
||||
% [panel_height, placed[a][0], box.position.y, header_bottom])
|
||||
quit(1)
|
||||
return true
|
||||
for b in range(a + 1, placed.size()):
|
||||
for part2 in [1, 2, 3]:
|
||||
if _rect(placed[a][part2]).intersects(_rect(placed[b][part2])):
|
||||
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (at %.0fpx tall, %s and %s overlap)" \
|
||||
% [panel_height, placed[a][0], placed[b][0]])
|
||||
quit(1)
|
||||
return true
|
||||
print(" %.0fpx tall: %d columns, top row at %.0f, clear of the status line at %.0f" \
|
||||
% [panel_height, controller.menu_columns(placed.size()),
|
||||
_rect(placed[0][1]).position.y, header_bottom])
|
||||
menu.anchor_bottom = 1.0
|
||||
menu.offset_top = 0.0
|
||||
menu.offset_bottom = restore
|
||||
controller._refresh_build_status()
|
||||
print("the grid clears the header at every window shape")
|
||||
|
||||
# --- 5. researched but not usable is shown, greyed ----------
|
||||
var extractor_shown: bool = false
|
||||
for e in shown2:
|
||||
|
|
|
|||
110
tests/test_build_popup_fits_window.gd
Normal file
110
tests/test_build_popup_fits_window.gd
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_build_popup_fits_window.gd
|
||||
# Forgejo #92: the confirm-this-building popup ran off a landscape window.
|
||||
#
|
||||
# The card is authored at a fixed 704x1200 around a portrait phone. On a
|
||||
# window that is wider than it is tall the stretched canvas is ~1080 units
|
||||
# high, so 1200 units of card hung over both edges - and since the confirm
|
||||
# and cancel buttons are anchored to the card's own bottom edge, they went
|
||||
# with it. A player on a desktop could open the popup and then neither buy
|
||||
# nor dismiss the thing.
|
||||
#
|
||||
# Covers:
|
||||
# A. on every window shape the card lies entirely inside the viewport
|
||||
# B. so do the two buttons on it, which is the part that made the popup a
|
||||
# dead end rather than merely an ugly one
|
||||
# C. it is not blown up on a big screen - the layout inside is tuned at
|
||||
# the authored size and only ever shrinks
|
||||
# D. it re-fits when the window changes, rather than only at startup
|
||||
|
||||
# Portrait phone, 16:9 desktop, an ultrawide, and a small laptop.
|
||||
const SHAPES: Array = [
|
||||
Vector2i(720, 1280), Vector2i(1920, 1080), Vector2i(2560, 900), Vector2i(1280, 720),
|
||||
]
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var controller: Node
|
||||
var panel: Control
|
||||
var shapes: Array = []
|
||||
var frames: int = 0
|
||||
var opened: bool = false
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.rocks = 500
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
shapes = SHAPES.duplicate()
|
||||
scene = load("res://world/Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
controller = scene.get_node("BuildController")
|
||||
panel = scene.get_node("UI/BuildMenu/InfoPopup/Panel")
|
||||
|
||||
|
||||
func _inside(rect: Rect2, outer: Rect2) -> bool:
|
||||
# Rect2.encloses() is exclusive at the edges for zero-area cases; this is
|
||||
# the plain reading - all four sides within, touching allowed.
|
||||
return rect.position.x >= outer.position.x - 0.5 \
|
||||
and rect.position.y >= outer.position.y - 0.5 \
|
||||
and rect.end.x <= outer.end.x + 0.5 \
|
||||
and rect.end.y <= outer.end.y + 0.5
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frames += 1
|
||||
if not opened:
|
||||
if frames < 12:
|
||||
return false
|
||||
controller._on_build_pressed()
|
||||
controller._show_building_info(GameState.BUILDING_MOTHERSHIP)
|
||||
if not scene.get_node("UI/BuildMenu/InfoPopup").visible:
|
||||
return _fail("the build popup did not open")
|
||||
opened = true
|
||||
frames = 0
|
||||
root.size = SHAPES[0]
|
||||
return false
|
||||
# Give the resize a few frames to reach the Control layout before reading
|
||||
# any rect off it.
|
||||
if frames < 6:
|
||||
return false
|
||||
frames = 0
|
||||
|
||||
var shape: Vector2i = shapes.pop_front()
|
||||
var screen: Rect2 = Rect2(Vector2.ZERO, panel.get_viewport_rect().size)
|
||||
var card: Rect2 = Rect2(panel.global_position, panel.size)
|
||||
print("%s -> canvas %.0fx%.0f, card %.0fx%.0f at %.0f,%.0f" % [
|
||||
shape, screen.size.x, screen.size.y, card.size.x, card.size.y,
|
||||
card.position.x, card.position.y])
|
||||
|
||||
# A. the card itself
|
||||
if not _inside(card, screen):
|
||||
return _fail("at %s the card %s is not inside the screen %s" % [shape, card, screen])
|
||||
# B. and what the player has to press on it
|
||||
for button_name in ["BuildButton", "CloseButton"]:
|
||||
var button: Control = panel.get_node(button_name)
|
||||
var button_rect: Rect2 = Rect2(button.global_position, button.size)
|
||||
if not _inside(button_rect, screen):
|
||||
return _fail("at %s the %s at %s is off screen" % [shape, button_name, button_rect])
|
||||
# C. never bigger than authored
|
||||
if card.size.x > panel.design_size.x + 0.5 or card.size.y > panel.design_size.y + 0.5:
|
||||
return _fail("at %s the card grew past its authored %s" % [shape, panel.design_size])
|
||||
|
||||
if shapes.is_empty():
|
||||
# D. the last shape was reached by resizing an already-open popup, so
|
||||
# every check above already ran against a re-fit rather than against
|
||||
# a card that happened to be built at the right size.
|
||||
print("TEST_BUILD_POPUP_FITS_WINDOW: PASS (%d window shapes)" % SHAPES.size())
|
||||
quit(0)
|
||||
return true
|
||||
root.size = shapes[0]
|
||||
return false
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_BUILD_POPUP_FITS_WINDOW: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
1
tests/test_build_popup_fits_window.gd.uid
Normal file
1
tests/test_build_popup_fits_window.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bt0yr2oys1usk
|
||||
195
tests/test_building_health.gd
Normal file
195
tests/test_building_health.gd
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_building_health.gd
|
||||
# Forgejo #99 (#97a): structures can be hurt, and can come down.
|
||||
#
|
||||
# Health lives on the RECORD in GameState rather than on the Building node,
|
||||
# because Main.tscn is destroyed and rebuilt on every trip to a planet
|
||||
# surface - damage kept on a node would heal itself the moment the player
|
||||
# landed. Most of this test therefore talks to GameState directly, and only
|
||||
# the last case looks at what the scene does with it.
|
||||
#
|
||||
# Covers:
|
||||
# A. every building definition declares a positive max_hp, and a fresh
|
||||
# record starts at full health
|
||||
# B. damage subtracts, reports survival, and enough of it destroys the
|
||||
# record outright
|
||||
# C. a record written before this existed reads as undamaged, not as
|
||||
# rubble - an existing save must not open on a field of wrecks
|
||||
# D. the mothership cannot be destroyed. The bank, the hangar and research
|
||||
# all live there and a wiped fleet rebuilds there, so losing it would
|
||||
# be a soft-lock; it bottoms out at 1hp instead
|
||||
# E. destroying a transport hub takes its transporters with it, and
|
||||
# nothing else keyed on the id is left pointing at a hole
|
||||
# F. the Building node follows its record out of existence, and shows
|
||||
# damage while it is still standing
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var destroyed_events: Array = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
# GameState is an autoload: a bare GameState.<instance member> does not
|
||||
# compile in a SceneTree script, so the node is fetched by name instead.
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.transporters.clear()
|
||||
game_state.reset_progression()
|
||||
game_state.rocks = 5000
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_BUILDING_HEALTH: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
|
||||
func _run_state_cases() -> bool:
|
||||
var definitions: Script = load("res://buildings/BuildingDefinitions.gd")
|
||||
|
||||
# --- A. every structure declares what it can take -------------------
|
||||
for definition in definitions.all():
|
||||
if int(definition.max_hp) <= 0:
|
||||
return _fail("'%s' has max_hp %d - it would be rubble the moment it was placed" \
|
||||
% [definition.definition_id, definition.max_hp])
|
||||
var crafter_id: int = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(300.0, 0.0))
|
||||
var full: int = game_state.building_max_hp(GameState.BUILDING_SHIP_CRAFTER)
|
||||
if game_state.building_hp(crafter_id) != full:
|
||||
return _fail("a new crafter opened at %d of %d hp" \
|
||||
% [game_state.building_hp(crafter_id), full])
|
||||
if not is_equal_approx(game_state.building_health_fraction(crafter_id), 1.0):
|
||||
return _fail("a new crafter is not at full health")
|
||||
print("%d definitions all declare max_hp; a new crafter starts at %d" \
|
||||
% [definitions.all().size(), full])
|
||||
|
||||
# --- B. damage, then death ------------------------------------------
|
||||
game_state.building_destroyed.connect(func(id: int, type: String):
|
||||
destroyed_events.append({"id": id, "type": type}))
|
||||
if game_state.damage_building(crafter_id, full - 1):
|
||||
return _fail("one hit point short of dead reported the crafter destroyed")
|
||||
if game_state.building_hp(crafter_id) != 1:
|
||||
return _fail("hp is %d after taking max_hp - 1" % game_state.building_hp(crafter_id))
|
||||
if game_state.building_health_fraction(crafter_id) >= 0.5:
|
||||
return _fail("a crafter on its last hit point still reads as healthy")
|
||||
if not game_state.damage_building(crafter_id, 1):
|
||||
return _fail("the last hit point did not destroy the crafter")
|
||||
if not game_state.find_building(crafter_id).is_empty():
|
||||
return _fail("the destroyed crafter is still in GameState.buildings")
|
||||
if destroyed_events.size() != 1 or int(destroyed_events[0].id) != crafter_id:
|
||||
return _fail("building_destroyed did not fire once for the crafter")
|
||||
# Fired AFTER the record went, so a listener sees the world without it.
|
||||
print("a crafter takes %d damage and comes down, announcing itself" % full)
|
||||
|
||||
# --- C. a record from before this existed ---------------------------
|
||||
var legacy_id: int = game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2(-300.0, 0.0))
|
||||
game_state.find_building(legacy_id).erase("hp")
|
||||
var lab_full: int = game_state.building_max_hp(GameState.BUILDING_SHIP_LAB)
|
||||
if game_state.building_hp(legacy_id) != lab_full:
|
||||
return _fail("a record with no hp field reads as %d, not the %d it should" \
|
||||
% [game_state.building_hp(legacy_id), lab_full])
|
||||
if game_state.damage_building(legacy_id, 1):
|
||||
return _fail("one point of damage levelled a building carried over from an old save")
|
||||
print("a pre-#99 record reads as undamaged (%d hp), not as rubble" % lab_full)
|
||||
|
||||
# --- D. the mothership survives anything ----------------------------
|
||||
var mothership_id: int = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
|
||||
if not game_state.building_is_indestructible(game_state.find_building(mothership_id)):
|
||||
return _fail("the mothership is not marked indestructible")
|
||||
if game_state.damage_building(mothership_id, 999999):
|
||||
return _fail("the mothership was destroyed - the bank, hangar and research went with it")
|
||||
if game_state.building_hp(mothership_id) != 1:
|
||||
return _fail("an overwhelmed mothership sits at %d hp, expected to floor at 1" \
|
||||
% game_state.building_hp(mothership_id))
|
||||
if game_state.destroy_building(mothership_id):
|
||||
return _fail("destroy_building demolished the mothership outright")
|
||||
if game_state.repair_building(mothership_id, 999999) <= 0:
|
||||
return _fail("a battered mothership could not be repaired")
|
||||
if game_state.building_hp(mothership_id) != game_state.building_max_hp(GameState.BUILDING_MOTHERSHIP):
|
||||
return _fail("repair did not take the mothership back to full")
|
||||
print("the mothership floors at 1hp and repairs to full")
|
||||
|
||||
# --- E. a hub takes its transporters with it ------------------------
|
||||
var hub_id: int = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(800.0, 0.0))
|
||||
var hub_transporters: int = 0
|
||||
for transporter in game_state.transporters:
|
||||
if int(transporter.get("hub_id", 0)) == hub_id:
|
||||
hub_transporters += 1
|
||||
if hub_transporters <= 0:
|
||||
return _fail("the hub provisioned no transporters, so this case proves nothing")
|
||||
var others: int = game_state.transporters.size() - hub_transporters
|
||||
if not game_state.destroy_building(hub_id):
|
||||
return _fail("the hub could not be destroyed")
|
||||
for transporter in game_state.transporters:
|
||||
if int(transporter.get("hub_id", 0)) == hub_id:
|
||||
return _fail("a transporter is still flying for a hub that no longer exists")
|
||||
if game_state.transporters.size() != others:
|
||||
return _fail("destroying the hub took %d unrelated transporters with it" \
|
||||
% (others - game_state.transporters.size()))
|
||||
print("destroying a hub retires its %d transporters and no others" % hub_transporters)
|
||||
return false
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
if _run_state_cases():
|
||||
return true
|
||||
# --- F. what the scene does with it -------------------------
|
||||
game_state.buildings.clear()
|
||||
game_state.transporters.clear()
|
||||
scene = load("res://world/Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
var field: Node = scene.get_node("BuildingField")
|
||||
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(600.0, 0.0))
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
var field: Node = scene.get_node("BuildingField")
|
||||
var node: Node2D = null
|
||||
for b in field.spawned:
|
||||
if is_instance_valid(b) and b.building_type == GameState.BUILDING_SHIP_CRAFTER:
|
||||
node = b
|
||||
if node == null:
|
||||
return _fail("no crafter node was spawned")
|
||||
if node.is_damaged():
|
||||
return _fail("a freshly placed crafter already shows damage")
|
||||
game_state.damage_building(node.building_id, game_state.building_max_hp(
|
||||
GameState.BUILDING_SHIP_CRAFTER) / 2)
|
||||
node._process(0.0)
|
||||
if not node.is_damaged():
|
||||
return _fail("a crafter at half health does not show it")
|
||||
print("a half-wrecked crafter reads %.0f%%" % (node.health_fraction() * 100.0))
|
||||
game_state.damage_building(node.building_id, 999999)
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
var field: Node = scene.get_node("BuildingField")
|
||||
for b in field.spawned:
|
||||
if is_instance_valid(b) and b.building_type == GameState.BUILDING_SHIP_CRAFTER:
|
||||
return _fail("the crafter node outlived the record it was built from")
|
||||
print("the node follows its record out of existence")
|
||||
|
||||
print("TEST_BUILDING_HEALTH: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_building_health.gd.uid
Normal file
1
tests/test_building_health.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://duqp2s4t83jve
|
||||
|
|
@ -30,10 +30,11 @@ var stage: int = 0
|
|||
var elapsed: float = 0.0
|
||||
var crane_x_start: float = 0.0
|
||||
var satellite_rotation_start: float = 0.0
|
||||
var game_state: Node
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var game_state: Node = root.get_node("GameState")
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
scene = load("res://world/Main.tscn").instantiate()
|
||||
|
|
@ -71,6 +72,10 @@ func _process(delta: float) -> bool:
|
|||
quit(1)
|
||||
return true
|
||||
|
||||
# #117: an idle crafter holds its clock, so there would be no
|
||||
# animation to measure the cost of. The point of this test is
|
||||
# that animating is CHEAP, not that it happens unprompted.
|
||||
game_state.queue_order(int(_crafter_id(field)), GameState.VESSEL_INTERCEPTOR)
|
||||
crane_x_start = crafter._crane.position.x
|
||||
satellite_rotation_start = mothership._satellites.rotation
|
||||
elapsed = 0.0
|
||||
|
|
@ -108,3 +113,10 @@ func _process(delta: float) -> bool:
|
|||
quit(0)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _crafter_id(building_field: Node) -> int:
|
||||
for b in building_field.spawned:
|
||||
if is_instance_valid(b) and b.building_type == GameState.BUILDING_SHIP_CRAFTER:
|
||||
return int(b.building_id)
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ extends SceneTree
|
|||
# that never changes were a measurable framerate cost earlier on
|
||||
# 3. the hull assembles progressively over the cycle rather than popping
|
||||
# in whole, and every piece is on before the assembly phase ends
|
||||
# 4. the cycle loops without anim_time growing without bound
|
||||
# 4. the cycle loops without anim_time growing without bound, WHILE THERE
|
||||
# IS SOMETHING ON THE LINE - #117 stops the clock on an idle crafter,
|
||||
# because a factory whose cranes swing over an empty cradle reads as
|
||||
# working on nothing
|
||||
# 5. and an idle crafter really is still
|
||||
|
||||
var scene: Node
|
||||
var game_state: Node
|
||||
|
|
@ -99,11 +103,35 @@ func _process(delta: float) -> bool:
|
|||
quit(1)
|
||||
return true
|
||||
|
||||
# --- #117: idle means idle --------------------------------
|
||||
if crafter.is_working():
|
||||
print("TEST_CRAFTER_ANIMATION: FAIL (a crafter with an empty queue calls itself working)")
|
||||
quit(1)
|
||||
return true
|
||||
time_at_start = crafter.anim_time
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.4:
|
||||
return false
|
||||
if crafter.anim_time != time_at_start:
|
||||
print("TEST_CRAFTER_ANIMATION: FAIL (an idle crafter kept animating)")
|
||||
quit(1)
|
||||
return true
|
||||
print("idle crafter held its clock at %.3f" % crafter.anim_time)
|
||||
# Put something on the line, and it should run again.
|
||||
game_state.queue_order(int(_find(GameState.BUILDING_SHIP_CRAFTER).building_id),
|
||||
GameState.VESSEL_INTERCEPTOR)
|
||||
if not crafter.is_working():
|
||||
print("TEST_CRAFTER_ANIMATION: FAIL (an order was queued and the crafter is still idle)")
|
||||
quit(1)
|
||||
return true
|
||||
time_at_start = crafter.anim_time
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
if elapsed < 0.4:
|
||||
return false
|
||||
var advanced: bool = crafter.anim_time != time_at_start
|
||||
|
|
|
|||
130
tests/test_definitions_intact.gd
Normal file
130
tests/test_definitions_intact.gd
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_definitions_intact.gd
|
||||
#
|
||||
# A guard against a failure mode that has now cost this project twice, and
|
||||
# both times silently.
|
||||
#
|
||||
# Opening the project in the Godot editor re-saves .tres files, and its
|
||||
# writer DROPS properties it considers default-valued. An empty-looking
|
||||
# PackedStringArray is the usual casualty: GasBurnerBuildingDefinition lost
|
||||
# `capabilities` and `collected_outputs` once (which took the gas burner's
|
||||
# whole conversion with it), and InterceptorVesselDefinition later lost
|
||||
# `capabilities`, `hull_points`, `build_time`, `icon` and `glow_color` -
|
||||
# which cost the starting vessel its cargo hold, so nothing could be mined.
|
||||
#
|
||||
# Neither showed up as an error. The resource still loaded; it was just
|
||||
# quietly less than it had been, and the failure surfaced somewhere else
|
||||
# entirely - a full suite reporting ten unrelated tests broken.
|
||||
#
|
||||
# So this asserts the SHAPE of the catalog rather than any one field: every
|
||||
# definition must carry the things that make it what it is, and a capability
|
||||
# a definition claims must have a component behind it. Anything the editor
|
||||
# eats fails here first, next to the thing that was eaten.
|
||||
#
|
||||
# Covers:
|
||||
# A. every vessel is whole - name, cost, icon, hull, and stats
|
||||
# B. a vessel with cargo declares it and has a capacity to go with it
|
||||
# C. every building is whole, and its menu wiring is complete
|
||||
# D. a converter building keeps the fields its conversion needs
|
||||
# E. no definition has lost its component list
|
||||
|
||||
var failures: Array = []
|
||||
|
||||
|
||||
func _fail(message: String) -> String:
|
||||
failures.append(message)
|
||||
return message
|
||||
|
||||
|
||||
# get() answers null for a property a definition's script never declared,
|
||||
# and null has no is_empty(). These keep a missing field from looking like
|
||||
# an empty one - and from taking the rest of the sweep down with it.
|
||||
func _text(definition: Resource, field: String) -> String:
|
||||
var value: Variant = definition.get(field)
|
||||
return "" if value == null else str(value)
|
||||
|
||||
|
||||
func _list(definition: Resource, field: String) -> Array:
|
||||
var value: Variant = definition.get(field)
|
||||
if value == null:
|
||||
return []
|
||||
return Array(value.keys() if value is Dictionary else value)
|
||||
|
||||
|
||||
func _check_vessels() -> void:
|
||||
for definition in VesselDefinitions.all():
|
||||
var id: String = String(definition.definition_id)
|
||||
# --- A. whole ---------------------------------------------------
|
||||
if String(definition.display_name).strip_edges() == "":
|
||||
_fail("vessel %s has no display name" % id)
|
||||
if definition.base_stats.is_empty():
|
||||
_fail("vessel %s has no stats at all" % id)
|
||||
# Only the hulls that actually join the fleet: a repair drone is a
|
||||
# logistics unit driven by GameState, with nothing bolted to it.
|
||||
if definition.fleet_eligible and definition.component_scenes.is_empty():
|
||||
_fail("vessel %s flies in the fleet with no components - an empty hull" % id)
|
||||
if definition.hull_points.is_empty():
|
||||
_fail("vessel %s has no hull points - it would draw as the fallback triangle" % id)
|
||||
# --- B. cargo --------------------------------------------------
|
||||
var carries: bool = float(definition.base_stats.get("cargo_capacity", 0.0)) > 0.0
|
||||
var claims: bool = "cargo" in definition.capabilities
|
||||
if carries != claims:
|
||||
_fail("vessel %s: cargo_capacity %s but capabilities %s - a hold nothing can fill, or the reverse" \
|
||||
% [id, definition.base_stats.get("cargo_capacity", 0.0), definition.capabilities])
|
||||
if "weapon" in definition.capabilities \
|
||||
and float(definition.base_stats.get("fire_range", 0.0)) <= 0.0:
|
||||
_fail("vessel %s claims a weapon and has no range" % id)
|
||||
|
||||
|
||||
func _check_buildings() -> void:
|
||||
for definition in BuildingDefinitions.all():
|
||||
var id: String = String(definition.definition_id)
|
||||
# --- C. whole --------------------------------------------------
|
||||
if String(definition.display_name).strip_edges() == "":
|
||||
_fail("building %s has no display name" % id)
|
||||
if String(definition.description).strip_edges() == "":
|
||||
_fail("building %s has no description - its info popup would be blank" % id)
|
||||
if int(definition.cost) <= 0:
|
||||
_fail("building %s is free" % id)
|
||||
if int(definition.max_hp) <= 0:
|
||||
_fail("building %s has no hit points" % id)
|
||||
# Space-placed structures only: an extractor is a marker on a planet
|
||||
# rather than a thing standing in the world, and has no scene.
|
||||
if definition.placement_mode == "space" and definition.visual_scene == null:
|
||||
_fail("building %s stands in space with nothing to draw" % id)
|
||||
for field in ["menu_button_name", "menu_label_name", "menu_cost_name"]:
|
||||
if str(definition.get(field)).strip_edges() == "":
|
||||
_fail("building %s has no %s - the build menu could not place it" % [id, field])
|
||||
if definition.capabilities.is_empty():
|
||||
_fail("building %s declares no capabilities - it would do nothing at all" % id)
|
||||
# --- D. converters ---------------------------------------------
|
||||
# These live on the LOGISTICS profile, a sub-resource of the
|
||||
# definition, not on the definition itself. Asking the definition
|
||||
# for them answers null and the whole check quietly never fires -
|
||||
# which is how the first draft of this test passed while the gas
|
||||
# burner sat there with its collected_outputs missing.
|
||||
var logistics: Resource = definition.logistics
|
||||
if logistics != null and _text(logistics, "conversion_input") != "":
|
||||
if _text(logistics, "conversion_output") == "":
|
||||
_fail("building %s converts something into nothing" % id)
|
||||
if _list(logistics, "collected_outputs").is_empty():
|
||||
_fail("building %s converts but lists no collected outputs - what it makes would never be fetched" % id)
|
||||
if _list(logistics, "input_buffers").is_empty():
|
||||
_fail("building %s converts with no input buffer" % id)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_check_vessels()
|
||||
_check_buildings()
|
||||
if not failures.is_empty():
|
||||
for message in failures:
|
||||
print(" - %s" % message)
|
||||
print("TEST_DEFINITIONS_INTACT: FAIL (%d definition(s) came back incomplete)" % failures.size())
|
||||
quit(1)
|
||||
return
|
||||
print("%d vessels and %d buildings, all intact" \
|
||||
% [VesselDefinitions.all().size(), BuildingDefinitions.all().size()])
|
||||
print("TEST_DEFINITIONS_INTACT: PASS")
|
||||
quit(0)
|
||||
1
tests/test_definitions_intact.gd.uid
Normal file
1
tests/test_definitions_intact.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d3ury2lcjwer7
|
||||
184
tests/test_desktop_controls.gd
Normal file
184
tests/test_desktop_controls.gd
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_desktop_controls.gd
|
||||
# Forgejo #118 and #121: two raw-touch controls that a mouse could not
|
||||
# reach.
|
||||
#
|
||||
# Both read InputEventScreenTouch/ScreenDrag, and the project no longer turns
|
||||
# clicks into them - the same routing gap #93 found in the research tree.
|
||||
# The boost was unreachable on a desktop and the formation could not be
|
||||
# rearranged at all.
|
||||
#
|
||||
# Driven through the REAL routing in each case (GameController for the
|
||||
# fader, BuildController for the editor) rather than by calling the controls
|
||||
# directly, because the routing is what was missing - a test that called
|
||||
# handle_mouse_button() itself would have passed against both reports.
|
||||
#
|
||||
# Covers:
|
||||
# A. #118 - a click on the fader takes hold of it, dragging moves the
|
||||
# boost, and releasing lets go
|
||||
# B. the fader claims the press, so dragging the boost does not also zoom
|
||||
# the world behind it
|
||||
# C. #121 - a click grabs a formation slot, moving drags it, and the
|
||||
# formation really changes
|
||||
# D. releasing drops the slot, and moving afterwards does not keep dragging
|
||||
# E. the wheel zooms the formation panel, which is what replaces the pinch
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var controller: Node
|
||||
var fleet: Node
|
||||
var fader: Control
|
||||
var editor: Control
|
||||
var formation_menu: Control
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
# GameState is an autoload: a bare GameState.<instance member> does not
|
||||
# compile in a SceneTree script, so the node is fetched by name instead.
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.reset_progression()
|
||||
game_state.reset_waves()
|
||||
game_state.reset_formation()
|
||||
game_state.intro_active = false
|
||||
game_state.vessel_levels["unlock_distorder"] = 1
|
||||
scene = load("res://world/Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
current_scene = scene
|
||||
fleet = scene.get_node("Fleet")
|
||||
controller = scene.get_node("BuildController")
|
||||
fader = scene.get_node("UI/SpeedFader")
|
||||
editor = scene.get_node("UI/FormationMenu/Editor")
|
||||
formation_menu = scene.get_node("UI/FormationMenu")
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_DESKTOP_CONTROLS: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
|
||||
func _click(at: Vector2, pressed: bool, target: Node) -> void:
|
||||
var event: InputEventMouseButton = InputEventMouseButton.new()
|
||||
event.button_index = MOUSE_BUTTON_LEFT
|
||||
event.pressed = pressed
|
||||
event.position = at
|
||||
target._unhandled_input(event)
|
||||
|
||||
|
||||
func _move(to: Vector2, target: Node) -> void:
|
||||
var event: InputEventMouseMotion = InputEventMouseMotion.new()
|
||||
event.position = to
|
||||
target._unhandled_input(event)
|
||||
|
||||
|
||||
func _wheel(button: int, at: Vector2) -> void:
|
||||
var event: InputEventMouseButton = InputEventMouseButton.new()
|
||||
event.button_index = button
|
||||
event.pressed = true
|
||||
event.position = at
|
||||
controller._unhandled_input(event)
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
# The fader only exists where the fleet can actually boost.
|
||||
fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([
|
||||
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_DISTORDER]))
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
if not fader.visible:
|
||||
return _fail("the fleet has an Energy Propulser and the fader is still hidden")
|
||||
if game_state.max_fleet_speed_multiplier <= 1.0:
|
||||
return _fail("the boost has no range to move through")
|
||||
|
||||
# --- A. click, drag, release -----------------------------
|
||||
var top: Vector2 = fader.global_position + Vector2(fader.size.x * 0.5,
|
||||
fader.track_margin + 2.0)
|
||||
var bottom: Vector2 = fader.global_position + Vector2(fader.size.x * 0.5,
|
||||
fader.size.y - fader.track_margin - 2.0)
|
||||
_click(bottom, true, scene)
|
||||
if not fader.is_active():
|
||||
return _fail("a click on the fader did not take hold of it")
|
||||
var at_bottom: float = game_state.fleet_speed_multiplier
|
||||
_move(top, scene)
|
||||
var at_top: float = game_state.fleet_speed_multiplier
|
||||
if at_top <= at_bottom:
|
||||
return _fail("dragging the fader from bottom to top left the boost at %.2f" % at_top)
|
||||
_click(top, false, scene)
|
||||
if fader.is_active():
|
||||
return _fail("the fader is still held after the button came up")
|
||||
_move(bottom, scene)
|
||||
if not is_equal_approx(game_state.fleet_speed_multiplier, at_top):
|
||||
return _fail("the fader kept following the pointer after release")
|
||||
print("fader: %.2fx at the bottom, %.2fx at the top, released cleanly" \
|
||||
% [at_bottom, at_top])
|
||||
|
||||
# --- B. it claims the press ------------------------------
|
||||
var zoom_before: float = scene.get_node("Camera2D").zoom.x
|
||||
_click(bottom, true, scene)
|
||||
_click(bottom, false, scene)
|
||||
if not is_equal_approx(scene.get_node("Camera2D").zoom.x, zoom_before):
|
||||
return _fail("pressing the fader also moved the camera behind it")
|
||||
print("the press stops at the fader; the world behind it is untouched")
|
||||
|
||||
# --- C/D/E. the formation editor -------------------------
|
||||
formation_menu.visible = true
|
||||
editor.queue_redraw()
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
var slot_before: Vector2 = game_state.formation[0]
|
||||
var grab: Vector2 = editor.global_position + editor._slot_screen_pos(0)
|
||||
_click(grab, true, controller)
|
||||
if editor.dragging_index != 0:
|
||||
return _fail("a click on the first formation slot grabbed %d" % editor.dragging_index)
|
||||
_move(grab + Vector2(70.0, 40.0), controller)
|
||||
if game_state.formation[0] == slot_before:
|
||||
return _fail("dragging the slot did not move it")
|
||||
var dragged: Vector2 = game_state.formation[0]
|
||||
print("slot 0 moved from %s to %s under a mouse" % [str(slot_before), str(dragged)])
|
||||
|
||||
# --- D. release lets go ----------------------------------
|
||||
_click(grab + Vector2(70.0, 40.0), false, controller)
|
||||
if editor.dragging_index != -1:
|
||||
return _fail("the slot is still held after the button came up")
|
||||
_move(grab + Vector2(200.0, 200.0), controller)
|
||||
if game_state.formation[0] != dragged:
|
||||
return _fail("the slot kept following the pointer after release")
|
||||
print("released, and the slot stopped following")
|
||||
|
||||
# --- E. the wheel zooms ----------------------------------
|
||||
var scale_before: float = editor.scale_factor
|
||||
_wheel(MOUSE_BUTTON_WHEEL_UP, editor.global_position + editor.size * 0.5)
|
||||
if editor.scale_factor <= scale_before:
|
||||
return _fail("the wheel did not zoom the formation panel in")
|
||||
_wheel(MOUSE_BUTTON_WHEEL_DOWN, editor.global_position + editor.size * 0.5)
|
||||
_wheel(MOUSE_BUTTON_WHEEL_DOWN, editor.global_position + editor.size * 0.5)
|
||||
if editor.scale_factor >= scale_before:
|
||||
return _fail("the wheel did not zoom back out")
|
||||
print("the wheel zooms the panel both ways (%.2f -> %.2f)" \
|
||||
% [scale_before, editor.scale_factor])
|
||||
|
||||
print("TEST_DESKTOP_CONTROLS: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue