Fix bullet atlas, animation speed, sprite flip, fire rate, silent shots
ci / verify (push) Successful in 46s
ci / verify (push) Successful in 46s
Bullet sprites were wrong because I read the pack's layout backwards. Each of
its 8 PNGs is one ANIMATION FRAME, and a column within a file is a COLOUR
variant -- so walking columns cycled the palette while the shape sat still, and
the rows I picked were in a region of larger multi-cell sprites, which is the
scattered debris that showed on screen. tools/build_local_assets.py now composes
a proper atlas: 8 frames across, one row per SimConfig.KIND_*.
Animations ran at whatever the machine's framerate was, because they counted
_process calls. At 240fps that is four times too fast on its own, before the
requested halving. Both actor and bullet animation are now driven by elapsed
seconds, so they look the same on any machine.
The knight shifted sideways instead of mirroring: a Rect2 with negative width
does not flip, the draw call normalises it, so the sprite kept its orientation
and jumped right by its own width. Mirrored through the canvas transform about
the sprite's centre instead.
Shots were silent when a bullet spawned inside an enemy. The sound rode on
BULLET_SPAWN, which is deliberately suppressed for a bullet resolved and removed
within the same tick -- so no event, no sound, and this got more likely the
closer you stood. Firing is now its own event (PLAYER_FIRED): the shot happened
whether or not a bullet survived to be replicated. Protocol 3 -> 4, because
inserting mid-enum shifts the wire value of every event after it.
Fire cooldown 7 -> 14 ticks (4.3 shots/sec).
Licence question 2, confirmed verbatim: the paid tier DOES grant commercial use
("You may use these assets in personal, commercial or non-commercial
projects"), but its next sentence still forbids redistribution "no matter how
much you modify it". Paying moves these from local-only-and-non-commercial to
local-only-and-commercial; it never makes them committable. Only replacing them
with permissively licensed art does that.
Question 3: CREDITS.md now credits every pack, including the two whose licences
do not require it and the unused ones in considering_dont_use_yet/.
158 tests. check.sh, test.sh and smoke.sh pass.
This commit is contained in:
+43
-22
@@ -1,30 +1,51 @@
|
||||
# Credits
|
||||
|
||||
Third-party assets used in Transcience, with the attribution their licences
|
||||
require.
|
||||
Every third-party asset pack used or bundled during development, credited
|
||||
regardless of whether its licence requires it. Two of them do not — they are
|
||||
here because the work deserves it.
|
||||
|
||||
## Audio
|
||||
|
||||
**Pixel Combat SFX** — [Helton Yan](https://heltonyan.itch.io/pixelcombat)
|
||||
Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
|
||||
Converted to 16-bit / 44.1 kHz mono and trimmed for use in game.
|
||||
|
||||
> Attribution is **required** by this licence, not optional. It has to appear
|
||||
> somewhere a player can reach — this file is not enough on its own once the
|
||||
> game ships. See docs/ROADMAP.md.
|
||||
|
||||
## Art
|
||||
## In the game
|
||||
|
||||
**16x16 DungeonTileset II** — [0x72 (Robert Norenberg)](https://0x72.itch.io/dungeontileset-ii)
|
||||
Released under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/)
|
||||
(public domain). Credit is not required; it is here because the work deserves
|
||||
it.
|
||||
Terrain, player, enemies and boss sprites.
|
||||
[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) — public domain.
|
||||
*Credit not required.*
|
||||
|
||||
## Not distributed with this repository
|
||||
**Pixel Combat SFX** — [Helton Yan](https://heltonyan.itch.io/pixelcombat)
|
||||
All sound effects. Converted to 16-bit / 44.1 kHz mono and trimmed.
|
||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
|
||||
**Credit required by licence.**
|
||||
|
||||
Two packs are used locally but deliberately **excluded** from the repository,
|
||||
because their licences forbid redistribution. They are not part of any build
|
||||
made from this source alone. See [docs/ASSETS.md](docs/ASSETS.md).
|
||||
**Fire Pixel Bullet 16x16** — [bdragon1727](https://bdragon1727.itch.io/fire-pixel-bullet-16x16)
|
||||
Bullet sprites. Custom licence; *credit encouraged, not required.*
|
||||
Not distributed with this repository — see below.
|
||||
|
||||
- Fire Pixel Bullet 16x16 — [bdragon1727](https://bdragon1727.itch.io/fire-pixel-bullet-16x16)
|
||||
- 750+ Effect and FX Pixel All — [bdragon1727](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all)
|
||||
**750+ Effect and FX Pixel All** — [bdragon1727](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all)
|
||||
Impact effect artwork. Custom licence; *credit encouraged, not required.*
|
||||
Not distributed with this repository — see below.
|
||||
|
||||
## Present during development, not used in the game
|
||||
|
||||
Kept in the untracked `assetpacks/considering_dont_use_yet/` and referenced by
|
||||
nothing. Credited for completeness.
|
||||
|
||||
**RF Catacombs v1.0** — Szadi art. Public domain per the pack's bundled
|
||||
`public-license.txt`: free for personal or commercial use, credit appreciated
|
||||
but not required, no reselling of the pack itself.
|
||||
|
||||
**Tiny RPG Character Asset Pack 02** and **neo_zero** (v1.0 and 3.0 demo) —
|
||||
creators not recorded in the packs themselves. If either is ever used, record
|
||||
the source and terms here first.
|
||||
|
||||
## Why two packs are not in this repository
|
||||
|
||||
Both bdragon1727 packs forbid redistribution, in **every** tier:
|
||||
|
||||
> Free tier: "You cannot do: Resell / redistribute this asset."
|
||||
>
|
||||
> Paid tier: "You can NOT re-distribute the file, no matter how much you modify
|
||||
> it you can use it but not share or re-sell it."
|
||||
|
||||
A public repository distributes what it contains, so those files live in the
|
||||
untracked `assets/local/`. The game falls back to generated placeholders
|
||||
without them. See [docs/ASSETS.md](docs/ASSETS.md).
|
||||
|
||||
+17
-7
@@ -28,8 +28,8 @@ be — so they are not in the repository.
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [0x72 DungeonTileset II](https://0x72.itch.io/dungeontileset-ii) | **CC0 1.0** (public domain) | Yes | Not required | **Yes** — `assets/sprites/dungeon_tileset.png` |
|
||||
| [Helton Yan Pixel Combat](https://heltonyan.itch.io/pixelcombat) | **CC BY 4.0** | Yes | **Required** | **Yes** — `assets/audio/sfx/` |
|
||||
| [Fire Pixel Bullet 16x16](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) | Custom | **Non-commercial only** unless you contribute | Encouraged | **No** — local only |
|
||||
| [750+ Effect and FX Pixel All](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) | Custom | **Non-commercial only** unless you contribute | Encouraged | **No** — local only |
|
||||
| [Fire Pixel Bullet 16x16](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) | Custom | Free tier non-commercial; **paid tier allows commercial** | Encouraged | **No, at any tier** — local only |
|
||||
| [750+ Effect and FX Pixel All](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) | Custom | Free tier non-commercial; **paid tier allows commercial** | Encouraged | **No, at any tier** — local only |
|
||||
|
||||
### The blocking clause
|
||||
|
||||
@@ -62,11 +62,21 @@ compile time and would fail the build on every machine that lacks the files.
|
||||
a file in the source tree once the game ships. Players need to be able to
|
||||
see it. [CREDITS.md](../CREDITS.md) records it for now; an in-game credits
|
||||
screen is a todo.
|
||||
2. **Both bdragon packs are non-commercial in their free tier.** The stated
|
||||
goal is a Steam release. Before that happens they need either a paid
|
||||
contribution to bdragon1727 (which permits commercial use but still not
|
||||
redistribution) or replacement with CC0 art. A CC0 replacement solves both
|
||||
problems at once and lets the assets go back in the repo.
|
||||
2. **Both bdragon packs are non-commercial in their free tier.** Buying the
|
||||
full tier *does* fix that — confirmed verbatim from the FX pack's paid
|
||||
licence: "You may use these assets in personal, commercial or
|
||||
non-commercial projects."
|
||||
|
||||
It does **not** fix the repository problem. The paid licence's very next
|
||||
sentence is: "You can NOT re-distribute the file, no matter how much you
|
||||
modify it you can use it but not share or re-sell it." Redistribution is
|
||||
forbidden at every tier, so paying moves these assets from
|
||||
*local-only-and-non-commercial* to *local-only-and-commercial*. They cannot
|
||||
be committed to a public repo at any price.
|
||||
|
||||
The only route that puts bullet and FX art back into the repository is
|
||||
replacing it with something permissive (CC0, or CC BY with attribution),
|
||||
which would also remove the local-asset fallback machinery.
|
||||
|
||||
### Not in use
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ const PLAYER_VISUAL_RADIUS := 13.0
|
||||
## ahead of the server, so this margin is what absorbs that too.
|
||||
const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0
|
||||
const PLAYER_MAX_HP := 100
|
||||
const PLAYER_FIRE_COOLDOWN := 7 # ticks
|
||||
const PLAYER_FIRE_COOLDOWN := 14 # ticks (~4.3 shots/sec)
|
||||
const PLAYER_BULLET_SPEED := 620.0
|
||||
const PLAYER_BULLET_RADIUS := 4.0
|
||||
const PLAYER_BULLET_LIFETIME := 90 # ticks
|
||||
|
||||
@@ -340,8 +340,7 @@ func on_events(data: PackedByteArray) -> void:
|
||||
ev["team"], ev["kind"], ev["accel"], ev["turn"], ev["uid"])
|
||||
if slot >= 0 and catchup > 0:
|
||||
world.pool.advance_slot(slot, catchup)
|
||||
if int(ev["team"]) == SimConfig.TEAM_PLAYER:
|
||||
shot_fired.emit()
|
||||
|
||||
SimEvent.Type.BULLET_DESPAWN:
|
||||
world.apply_event(ev)
|
||||
SimEvent.Type.PLAYER_HIT:
|
||||
@@ -359,6 +358,8 @@ func on_events(data: PackedByteArray) -> void:
|
||||
predicted_pos = ev["pos"]
|
||||
pending.clear()
|
||||
hud_dirty.emit()
|
||||
SimEvent.Type.PLAYER_FIRED:
|
||||
shot_fired.emit()
|
||||
SimEvent.Type.ENEMY_DIED:
|
||||
enemy_died.emit()
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
|
||||
@@ -184,7 +184,7 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed
|
||||
body.put_u16(clampi(int(ev["dmg"]), 0, 65535))
|
||||
body.put_u16(clampi(int(ev["hp"]), 0, 65535))
|
||||
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
|
||||
SimEvent.Type.ESCAPE_CANCELLED:
|
||||
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED:
|
||||
body.put_u32(ev["peer"])
|
||||
SimEvent.Type.PLAYER_RESPAWNED:
|
||||
body.put_u32(ev["peer"])
|
||||
@@ -235,7 +235,7 @@ static func decode_events(data: PackedByteArray) -> Dictionary:
|
||||
ev["dmg"] = b.get_u16()
|
||||
ev["hp"] = b.get_u16()
|
||||
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
|
||||
SimEvent.Type.ESCAPE_CANCELLED:
|
||||
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED:
|
||||
ev["peer"] = b.get_u32()
|
||||
SimEvent.Type.PLAYER_RESPAWNED:
|
||||
ev["peer"] = b.get_u32()
|
||||
|
||||
+4
-1
@@ -7,7 +7,10 @@ extends RefCounted
|
||||
## roster message.
|
||||
## 3: dropped the post-hit invulnerability flag, added the respawn-lockout byte
|
||||
## to each player record.
|
||||
const VERSION := 3
|
||||
## 4: added SimEvent.Type.PLAYER_FIRED. It was inserted mid-enum, which shifts
|
||||
## the wire value of every event after it -- a mismatched client would
|
||||
## mis-decode every hit and death, so the handshake has to reject it.
|
||||
const VERSION := 4
|
||||
const DEFAULT_PORT := 27015
|
||||
const MAX_CLIENTS := 32
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ extends RefCounted
|
||||
enum Type {
|
||||
BULLET_SPAWN, ## uid, pos, vel, radius, life, kind, team, accel, turn
|
||||
BULLET_DESPAWN, ## uid -- early death only; expiry is derived on both sides
|
||||
## A player's weapon went off. Distinct from BULLET_SPAWN on purpose: a
|
||||
## bullet born inside an enemy is resolved and removed in the same tick, so
|
||||
## no spawn event is ever emitted for it -- and the shot still happened, and
|
||||
## still needs to be heard.
|
||||
PLAYER_FIRED, ## peer
|
||||
PLAYER_HIT, ## peer, damage, hp
|
||||
PLAYER_DIED, ## peer
|
||||
PLAYER_RESPAWNED, ## peer, pos
|
||||
|
||||
@@ -222,6 +222,7 @@ func _take_input(p: SimPlayer) -> InputFrame:
|
||||
|
||||
func _fire_player_shot(p: SimPlayer) -> void:
|
||||
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
|
||||
events.append({"t": SimEvent.Type.PLAYER_FIRED, "peer": p.peer_id})
|
||||
var dir := Vector2.RIGHT.rotated(p.aim)
|
||||
pool.spawn(
|
||||
p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET,
|
||||
|
||||
+26
-16
@@ -71,8 +71,10 @@ const BARRICADE := Rect2(16, 32, 16, 16) # a banner: low, readable, see-over
|
||||
const PLAYER_IDLE := Rect2(128, 100, 16, 28)
|
||||
const PLAYER_RUN := Rect2(192, 100, 16, 28)
|
||||
const ACTOR_FRAMES := 4
|
||||
## Ticks per animation frame.
|
||||
const FRAME_TICKS := 8
|
||||
## Seconds per actor animation frame. Time-based rather than frame-counted --
|
||||
## counting _process calls made every animation run at whatever the machine's
|
||||
## framerate happened to be, which at 240fps was four times too fast.
|
||||
const FRAME_SECONDS := 0.26
|
||||
|
||||
## EnemyDef.visual -> first idle frame. Order matches Content: drifter, turret,
|
||||
## stalker, practice dummy.
|
||||
@@ -85,17 +87,24 @@ const ENEMY_IDLE: Array[Rect2] = [
|
||||
const BOSS_IDLE := Rect2(16, 428, 32, 36) # big demon
|
||||
|
||||
# --- Bullets ----------------------------------------------------------------
|
||||
# The pack sheet is a 40x25 grid of 16x16 cells. SimConfig.KIND_* indexes into
|
||||
# these, so a new bullet kind is one more entry.
|
||||
# assets/local/bullets.png is composed by tools/build_local_assets.py: 8 frames
|
||||
# across, one row per SimConfig.KIND_*. The raw pack could not be used directly
|
||||
# because its layout is the opposite of what it looks like -- each of its 8
|
||||
# files is one animation frame, and a column within a file is a COLOUR variant,
|
||||
# not the next frame. Walking columns animates the palette and leaves the shape
|
||||
# still, which is exactly what it looked like on screen.
|
||||
const BULLET_CELL := 16.0
|
||||
const BULLET_CELLS: Array[Vector2i] = [
|
||||
Vector2i(0, 0), # KIND_PLAYER_SHOT
|
||||
Vector2i(0, 5), # KIND_ORB
|
||||
Vector2i(0, 10), # KIND_NEEDLE
|
||||
Vector2i(0, 15), # KIND_HEAVY
|
||||
]
|
||||
## Frames in a bullet's animation loop, read left to right from its cell.
|
||||
const BULLET_FRAMES := 4
|
||||
const BULLET_FRAMES := 8
|
||||
## Seconds per bullet animation frame.
|
||||
const BULLET_FRAME_SECONDS := 0.08
|
||||
|
||||
|
||||
## Region for a bullet kind at an animation frame.
|
||||
static func bullet_region(kind: int, frame_index: int) -> Rect2:
|
||||
return Rect2(
|
||||
Vector2(float(frame_index % BULLET_FRAMES), float(kind)) * BULLET_CELL,
|
||||
Vector2(BULLET_CELL, BULLET_CELL))
|
||||
|
||||
|
||||
# --- Impact effect ----------------------------------------------------------
|
||||
const IMPACT_CELL := 64.0
|
||||
@@ -116,10 +125,11 @@ static func frame(first: Rect2, n: int) -> Rect2:
|
||||
return Rect2(first.position + Vector2(first.size.x * float(n), 0.0), first.size)
|
||||
|
||||
|
||||
## Which idle/run frame to draw at this tick. Offset by an actor id so a room
|
||||
## full of the same enemy does not breathe in unison.
|
||||
static func anim_frame(tick: int, offset: int, frames: int = ACTOR_FRAMES) -> int:
|
||||
return posmod((tick / FRAME_TICKS) + offset, frames)
|
||||
## Which idle/run frame to draw. [param seconds] is elapsed wall time, so the
|
||||
## animation runs at the same speed on any machine. Offset by an actor id so a
|
||||
## room full of the same enemy does not breathe in unison.
|
||||
static func anim_frame(seconds: float, offset: int, frames: int = ACTOR_FRAMES) -> int:
|
||||
return posmod(int(seconds / FRAME_SECONDS) + offset, frames)
|
||||
|
||||
|
||||
## Pick a floor variant from tile position -- stable, so floors do not shimmer
|
||||
|
||||
+10
-13
@@ -21,7 +21,7 @@ const _FALLBACK_COLOURS: Array[Color] = [
|
||||
|
||||
var _layers: Array[MultiMeshInstance2D] = []
|
||||
## Advances the bullets' own animation, independent of the simulation.
|
||||
var _anim_tick: int = 0
|
||||
var _anim_time: float = 0.0
|
||||
## True when the licence-restricted bullet sheet is absent and we are drawing
|
||||
## generated dots instead. See Art.bullets_texture().
|
||||
var _fallback: bool = false
|
||||
@@ -31,11 +31,12 @@ func _ready() -> void:
|
||||
var sheet := Art.bullets_texture()
|
||||
_fallback = sheet == null
|
||||
var art: Texture2D = sheet if sheet != null else _make_dot_texture()
|
||||
var kinds := SimConfig.KIND_HEAVY + 1
|
||||
if _fallback:
|
||||
GameLog.info("view", "bullet sheet absent, drawing generated dots")
|
||||
var quad := QuadMesh.new()
|
||||
quad.size = Vector2.ONE
|
||||
for kind in SimConfig.KIND_HEAVY + 1:
|
||||
for kind in kinds:
|
||||
var layer := MultiMeshInstance2D.new()
|
||||
layer.texture = art
|
||||
var mm := MultiMesh.new()
|
||||
@@ -49,8 +50,8 @@ func _ready() -> void:
|
||||
_layers.append(layer)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_anim_tick += 1
|
||||
func _process(delta: float) -> void:
|
||||
_anim_time += delta
|
||||
if not _fallback:
|
||||
_apply_frame()
|
||||
|
||||
@@ -71,22 +72,18 @@ func _make_dot_texture() -> ImageTexture:
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
## Point each layer's texture at this frame's cell in the sheet. Every bullet in
|
||||
## a layer shares the frame, which is why this is one region assignment rather
|
||||
## than per-instance work.
|
||||
## Point each layer's texture at this frame's row/column in the atlas. Every
|
||||
## bullet in a layer shares the frame, so this is one region assignment per kind
|
||||
## rather than any per-instance work.
|
||||
func _apply_frame() -> void:
|
||||
var frame := (_anim_tick / 6) % Art.BULLET_FRAMES
|
||||
var frame_index := int(_anim_time / Art.BULLET_FRAME_SECONDS)
|
||||
for kind in _layers.size():
|
||||
var cell: Vector2i = Art.BULLET_CELLS[kind]
|
||||
var region := Rect2(
|
||||
Vector2(float(cell.x + frame), float(cell.y)) * Art.BULLET_CELL,
|
||||
Vector2(Art.BULLET_CELL, Art.BULLET_CELL))
|
||||
var atlas := _layers[kind].texture as AtlasTexture
|
||||
if atlas == null:
|
||||
atlas = AtlasTexture.new()
|
||||
atlas.atlas = Art.bullets_texture()
|
||||
_layers[kind].texture = atlas
|
||||
atlas.region = region
|
||||
atlas.region = Art.bullet_region(kind, frame_index)
|
||||
|
||||
|
||||
func render_pool(pool: BulletPool) -> void:
|
||||
|
||||
+18
-13
@@ -28,12 +28,13 @@ const ENEMY_COLORS := [
|
||||
@onready var bullets: Node2D = $Bullets
|
||||
|
||||
var client: ClientRuntime = null
|
||||
## Drives sprite animation. Local and cosmetic -- never the simulation's tick.
|
||||
var _anim_tick: int = 0
|
||||
## Elapsed seconds, driving sprite animation. Wall time rather than a frame
|
||||
## count, so animations run at the same speed regardless of framerate.
|
||||
var _anim_time: float = 0.0
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_anim_tick += 1
|
||||
func _process(delta: float) -> void:
|
||||
_anim_time += delta
|
||||
client = Net.client
|
||||
if client != null:
|
||||
bullets.render_pool(client.world.pool)
|
||||
@@ -129,7 +130,7 @@ func _draw_portal() -> void:
|
||||
func _draw_enemy(e: Dictionary) -> void:
|
||||
var visual := clampi(int(e["visual"]), 0, Art.ENEMY_IDLE.size() - 1)
|
||||
var first: Rect2 = Art.ENEMY_IDLE[visual]
|
||||
var src := Art.frame(first, Art.anim_frame(_anim_tick, int(e["id"])))
|
||||
var src := Art.frame(first, Art.anim_frame(_anim_time, int(e["id"])))
|
||||
_draw_sprite(Art.TILESET, src, e["pos"])
|
||||
# Health ring stays: the sprite says what it is, the ring says how hurt.
|
||||
var r: float = e["radius"]
|
||||
@@ -142,7 +143,7 @@ func _draw_boss() -> void:
|
||||
if b.is_empty() or not _visible(b["pos"]):
|
||||
return
|
||||
var pos: Vector2 = b["pos"]
|
||||
var src := Art.frame(Art.BOSS_IDLE, Art.anim_frame(_anim_tick, 0))
|
||||
var src := Art.frame(Art.BOSS_IDLE, Art.anim_frame(_anim_time, 0))
|
||||
_draw_sprite(Art.TILESET, src, pos)
|
||||
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
|
||||
draw_arc(pos, r, 0.0, TAU, 48, Color(1.0, 0.4, 0.5, 0.5), 2.0)
|
||||
@@ -184,7 +185,7 @@ func _draw_grace_ring(pos: Vector2) -> void:
|
||||
## latency than the reverse. See sim_config.gd.
|
||||
func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool, moving: bool = false) -> void:
|
||||
var strip := Art.PLAYER_RUN if moving else Art.PLAYER_IDLE
|
||||
var src := Art.frame(strip, Art.anim_frame(_anim_tick, 0))
|
||||
var src := Art.frame(strip, Art.anim_frame(_anim_time, 0))
|
||||
# Faces the way you aim, which is the whole point of a twin-stick.
|
||||
var flip := absf(wrapf(aim, -PI, PI)) > PI * 0.5
|
||||
_draw_sprite(Art.TILESET, src, pos, col if not alive else Color.WHITE, flip)
|
||||
@@ -199,12 +200,16 @@ func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool, moving: bool
|
||||
func _draw_sprite(tex: Texture2D, src: Rect2, at: Vector2,
|
||||
modulate: Color = Color.WHITE, flip_h: bool = false) -> void:
|
||||
var size := src.size * Art.SCALE
|
||||
var dest := Rect2(at - size * 0.5, size)
|
||||
if flip_h:
|
||||
# Negative width mirrors the region in place.
|
||||
dest = Rect2(Vector2(dest.position.x + size.x, dest.position.y),
|
||||
Vector2(-size.x, size.y))
|
||||
draw_texture_rect_region(tex, dest, src, modulate)
|
||||
if not flip_h:
|
||||
draw_texture_rect_region(tex, Rect2(at - size * 0.5, size), src, modulate)
|
||||
return
|
||||
# Mirror through the canvas transform, scaled about the sprite's own centre.
|
||||
# The obvious alternative -- a Rect2 with negative width -- does not mirror:
|
||||
# the draw call normalises the rect, so the sprite kept its orientation and
|
||||
# merely jumped sideways by its own width.
|
||||
draw_set_transform(at, 0.0, Vector2(-1.0, 1.0))
|
||||
draw_texture_rect_region(tex, Rect2(-size * 0.5, size), src, modulate)
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
|
||||
|
||||
## The escape channel is drawn on the player, not just in the HUD, so other
|
||||
|
||||
+13
-8
@@ -52,19 +52,24 @@ func test_every_bullet_frame_is_inside_the_sheet() -> void:
|
||||
if sheet == null:
|
||||
pass_test("bullet sheet not present locally; nothing to validate")
|
||||
return
|
||||
for kind in Art.BULLET_CELLS.size():
|
||||
var cell: Vector2i = Art.BULLET_CELLS[kind]
|
||||
for kind in SimConfig.KIND_HEAVY + 1:
|
||||
for n in Art.BULLET_FRAMES:
|
||||
var r := Rect2(
|
||||
Vector2(float(cell.x + n), float(cell.y)) * Art.BULLET_CELL,
|
||||
Vector2(Art.BULLET_CELL, Art.BULLET_CELL))
|
||||
var r := Art.bullet_region(kind, n)
|
||||
assert_true(_fits(sheet, r),
|
||||
"bullet kind %d frame %d is outside the sheet at %s" % [kind, n, r])
|
||||
|
||||
|
||||
## One entry per SimConfig.KIND_*, or a bullet kind silently renders as another.
|
||||
func test_there_is_a_sprite_for_every_bullet_kind() -> void:
|
||||
assert_eq(Art.BULLET_CELLS.size(), SimConfig.KIND_HEAVY + 1)
|
||||
## The atlas has one row per kind. A short sheet would silently draw the wrong
|
||||
## bullet rather than error.
|
||||
func test_the_bullet_atlas_has_a_row_for_every_kind() -> void:
|
||||
var sheet := Art.bullets_texture()
|
||||
if sheet == null:
|
||||
pass_test("bullet sheet not present locally")
|
||||
return
|
||||
var needed := float(SimConfig.KIND_HEAVY + 1) * Art.BULLET_CELL
|
||||
assert_gte(float(sheet.get_height()), needed,
|
||||
"atlas is too short for %d bullet kinds" % (SimConfig.KIND_HEAVY + 1))
|
||||
assert_gte(float(sheet.get_width()), Art.BULLET_FRAMES * Art.BULLET_CELL)
|
||||
|
||||
|
||||
## One entry per EnemyDef.visual actually used by content, or an enemy draws as
|
||||
|
||||
@@ -69,6 +69,33 @@ func test_input_flood_cannot_grow_the_queue_without_bound() -> void:
|
||||
"a flood of inputs must not become unbounded server memory")
|
||||
|
||||
|
||||
## The shot sound used to ride on BULLET_SPAWN, and a bullet born inside an
|
||||
## enemy is resolved and removed within the same tick -- so no spawn event was
|
||||
## ever emitted and the shot was silent. Firing is now its own event.
|
||||
func test_firing_is_reported_even_when_the_bullet_dies_instantly() -> void:
|
||||
var p: SimPlayer = world.players[PEER]
|
||||
p.pos = Vector2.ZERO
|
||||
p.aim = 0.0
|
||||
# The inert practice dummy, sitting exactly where the muzzle is, so the
|
||||
# bullet is consumed on the tick it is created. Deliberately not a turret:
|
||||
# a turret's own ring would fill the event list with its spawns and the
|
||||
# setup check below would pass for the wrong reason.
|
||||
world.spawn_enemy(Content.dummy(), Vector2(SimConfig.PLAYER_MUZZLE_OFFSET, 0.0))
|
||||
_drive(1, Vector2.ZERO, InputFrame.BTN_FIRE)
|
||||
assert_eq(_events_of(SimEvent.Type.PLAYER_FIRED).size(), 1,
|
||||
"the shot happened and has to be reported")
|
||||
assert_eq(_events_of(SimEvent.Type.BULLET_SPAWN).size(), 0,
|
||||
"setup check: the bullet really did die on the tick it spawned")
|
||||
|
||||
|
||||
func test_firing_is_reported_once_per_shot() -> void:
|
||||
_drive(60, Vector2.ZERO, InputFrame.BTN_FIRE)
|
||||
var fired := _events_of(SimEvent.Type.PLAYER_FIRED).size()
|
||||
var expected := 60 / SimConfig.PLAYER_FIRE_COOLDOWN
|
||||
assert_almost_eq(float(fired), float(expected), 1.0,
|
||||
"one report per shot the cooldown actually allowed")
|
||||
|
||||
|
||||
func test_fire_rate_is_enforced_by_the_server() -> void:
|
||||
# Hold fire every single tick; the server still applies its own cooldown.
|
||||
_drive(60, Vector2.ZERO, InputFrame.BTN_FIRE)
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compose the local-only sprite atlases from the raw asset packs.
|
||||
|
||||
Both inputs and outputs are licence-restricted and therefore untracked (see
|
||||
docs/ASSETS.md), which is why this is a build step rather than a committed file:
|
||||
anyone with the packs can regenerate, and nobody without them is blocked.
|
||||
|
||||
pip install pillow && python3 tools/build_local_assets.py
|
||||
|
||||
The bullet pack's layout is not obvious and cost a wrong guess once:
|
||||
* Each of the 8 PNGs is one ANIMATION FRAME, not one sprite sheet.
|
||||
* Within a file, a row is a bullet shape and a column is a COLOUR variant.
|
||||
So animating means cycling files, not walking columns -- walking columns just
|
||||
cycles through palettes while the shape sits still.
|
||||
|
||||
Output is one tidy atlas: 8 frames across, one row per SimConfig.KIND_*.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
sys.exit("needs pillow: pip install pillow")
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SRC = os.path.join(ROOT, "assetpacks", "New_All_Fire_Bullet_Pixel_16x16",
|
||||
"All_Fire_Bullet_Pixel_16x16_%02d.png")
|
||||
OUT = os.path.join(ROOT, "assets", "local", "bullets.png")
|
||||
CELL = 16
|
||||
FRAMES = 8
|
||||
|
||||
# (source row, source column) per bullet kind, in SimConfig.KIND_* order.
|
||||
# Column selects the colour: 0 red, 1 orange, 2 amber, 3 gold, 4 bright yellow.
|
||||
KINDS = [
|
||||
("player_shot", 11, 4), # small bright dot
|
||||
("orb", 2, 1), # round orange orb
|
||||
("needle", 11, 6), # horizontal dart, drawn rotated to velocity
|
||||
("heavy", 1, 0), # big red ball
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for f in range(FRAMES):
|
||||
if not os.path.exists(SRC % f):
|
||||
sys.exit("missing source frame: %s\n"
|
||||
"The raw pack is untracked; see assets/local/README.md."
|
||||
% (SRC % f))
|
||||
out = Image.new("RGBA", (CELL * FRAMES, CELL * len(KINDS)), (0, 0, 0, 0))
|
||||
for f in range(FRAMES):
|
||||
src = Image.open(SRC % f).convert("RGBA")
|
||||
for k, (_name, row, col) in enumerate(KINDS):
|
||||
box = (col * CELL, row * CELL, (col + 1) * CELL, (row + 1) * CELL)
|
||||
out.paste(src.crop(box), (f * CELL, k * CELL))
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
out.save(OUT)
|
||||
print("wrote %s (%dx%d): %d kinds x %d frames"
|
||||
% (OUT, out.width, out.height, len(KINDS), FRAMES))
|
||||
for k, (name, row, col) in enumerate(KINDS):
|
||||
print(" row %d = %-12s (source row %2d, colour col %d)" % (k, name, row, col))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user