Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afe76c22ce | |||
| 3586555aea | |||
| 9fa2e260f2 |
+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