Skip to content

Latest commit

 

History

History
405 lines (335 loc) · 23.1 KB

graphics.md

File metadata and controls

405 lines (335 loc) · 23.1 KB

Graphics packs

These documents were written for the Godot game. Paths such as src/view/*.gd name its sources at tag godot-final; the Rust game reads the same level and pack formats.

Pantry is the built-in version 3 graphics pack at res://packs/pantry. It draws bread terrain (garden64, garden32 and their variants), the egg-carton level end (levelEnd), water (water3), the chick player, 24 object codes, a tiled kitchen wall and wall-mounted shelves. Other tilemaps and object codes still use diagnostic shapes. No third-party art, fonts or music are bundled. Source images and prompts: art/pantry/README.md.

Select an external pack with --pack=/absolute/path after --, or with the EGGSHELL_PACK environment variable. The command line wins. An invalid pack logs the problem and uses placeholders. --placeholder bypasses pack loading. Graphics never select or supply the level. Pass --level-code=PATH or --levels=DIR separately. Packs do not style the pause menu or the play timer; they use the default font.

tools/device.py pack PATH uploads either supported manifest version, including dense packs. The remote directory stays /tmp/eggshell/packs/external-v2 for existing --pack= callers; the directory name does not restrict the version.

Manifest versions 2 and 3

The loader accepts version: 2 and version: 3. Both require tiles and player dictionaries, and drawing_facts: "drawing_facts.json". Drawing metadata supplies a tilemaps dictionary and per-tileset draw-slot instances. An unsupported version or invalid required section uses diagnostic shapes. The optional top-level density is the number of art pixels per world pixel. It accepts integers 1 through 4 and defaults to 1. An invalid value logs a warning and uses 1; the pack still loads. Both versions accept it. At density 2, a 32-world-pixel tile uses 64 by 64 art pixels. A 24 by 56 player image uses 48 by 112 art pixels.

Invalid optional sections are ignored. Missing images keep the existing placeholder path. Files must be relative pack paths, without parent traversal.

tiles maps tilemap names to PNG files. player maps animation names to animation entries. An animation has frames, fps, loop and an optional pixel origin: [x,y]. Each frame has file, an optional overriding origin, and an optional positive duration in frame units. Version 2's speed is still accepted in place of fps. Zero fps selects a fixed frame. A looping animation wraps; a non-looping animation holds its final frame.

Version 3 adds these optional dictionaries:

Section Shape
objects Object code to a visual entry: file and pixel origin, or animations keyed by name. Animated entries may name default_animation and a states map.
backgrounds Background id to layers, with optional per-frame variants. A layer has file, frames or animation, scroll: [x,y], scale: [x,y], offset: [x,y], reference: [x,y], repeat_x and repeat_y. anchor can replace offset and uses the selected frame's origin.
gradients Name to strips of {height, color: [r,g,b]}, with optional scroll and reference. RGB values use 0–255.
hud Named visual entries and optional opaque readout backing. See HUD backing.
fonts Named font files or bitmap sheets and glyph metadata, reserved for future text drawing.
audio Music tracks, effects, and maps from game events and music roles to them. See Audio.
terrain Tilemap or tileset family name to optional surface, underside and side strips. A strip has file and edge. See Terrain strips.

Object entries may use anchor: "position" to keep the simulation origin, rather than fit the art to its diagnostic box. facing: -1 describes art that faces left without a mirror. A variants dictionary may select a visual by the placed object's F value. A state map may use walk, idle, turn, fly, active, chew, swim, prepare, jump, fall or an animation name; a state:variant key takes precedence. Missing states use the default animation. The view uses the saved tick as its animation clock. It does not write an animation clock, change simulation state or add fields to saves.

Viewport-aware presentation

LevelView updates proven off-screen pictures only when they enter the current camera rectangle. The rectangle uses the actual rounded camera translation, container size and zoom, with a two-world-pixel guard. Entry always samples the current saved tick before drawing. This policy never changes simulation state.

view_visibility.gd is the pure visibility policy. visual_bounds.gd unions all animation frames and origins, transformed scenery children, and pixel-snap scale rounding. Sized looks use the current view_state result and each frame's size and origin ratios. Span bounds include stalks, spikes and nine-patch minimum sizes. Spring bounds cover every squash and animation frame. Inactive enemies use their placement origin, not their live position.

Only audited geometry-invariant view_state implementations can bypass look selection while hidden. If one gains a size or position change, remove it from FIXED_LOOKS or add a new proof. Unproven geometry stays on the full update path; this includes independent snow puffs and separate enemy heads. Runtime suffix nodes use the same proof where their art is position-anchored. Caches belong to the map build. Pictures bind to the fixed placement prefix; they never use or change the simulation's spatial index. Hidden nodes receive a visibility write only when they leave the presented set, not on every render.

LevelView.viewport_aware = false is a drawing-only reference for GPU parity tests. It is not a gameplay option. Public tests generate their own art and compare full updates with viewport-aware updates, including map changes, restores, interpolation, dense packs, resize and the goal sequence.

Two span parts come from the placement's E2, drawn by src/view/span_dressing.gd. p6, p7 and p8 hang a stalk E2 pixels tall from the ledge's bottom edge, 32 pixels wide; the entry <code>_stem repeats down that height. p11 and p12 stand spike strip E2 (0 or 1) on the span's top edge, 10 pixels tall and as wide as the span, turning with it; the entry <code>_spikes may pick the strip through variants and keeps 36-pixel end caps while its middle repeats. Without an entry, each part draws as a diagnostic box of the same size. The parts have no collision.

The body of a resizable span (p1 to p21, including the bridge p1 and the turning p11 and p12) with pack art is drawn in slices, not as one image stretched to the span's width. The entry carries slices: {left, right, top, bottom, fill}: margins in the entry image's pixels, kept at their size, and fill (tile, the default, or stretch) for the part between them. The slices cover the area the stretched image would cover, so they follow its width, mirror and turn. Without slices the fixed spans p6–p10 and p13–p21 repeat their whole image, and the other spans keep the stretched image (Pantry's p1 plank is drawn that way). Spans without pack art keep their diagnostic box.

The view reads bg1, bg2, bg3, gradient and bgcolor from the selected map. A background setting is id,frame, with frame 0 as the default. Entries may supply slots.bg1, slots.bg2 and slots.bg3 scroll overrides and offset_by_height maps. Scroll 1 follows terrain; scroll 0 stays fixed relative to the reference point. Attached layers may specify a pack-pixel repeat_size to keep their parent's spacing, min_repeat to limit repeated copies, and frame for a fixed animation variant. Backgrounds repeat only across the visible region. The base RGB color remains behind the gradient and all three background slots. Consecutive drawn slot groups with the same id, variant and resolved layer records draw once when every image (including all animation frames) has only fully opaque or fully transparent pixels. Partial alpha and intervening different slots keep every draw, to preserve compositing. A gradient setting is name_offset_scale, or custom height,r,g,b groups in place of the name. The offset replaces the first strip height; scale multiplies strip heights. A map's customGradientGenerator value lines|start|end|r,g,b_r,g,b... draws a generated vertical gradient instead: lines bands between reference rows start and end of the 448-pixel reference height, blending through the listed colors in steps of 8 per channel. The first color fills above start, the last fills below. The layer moves at a tenth of the camera's vertical motion. It needs no pack.

HUD icons and font metadata are used by the read-only run display.

Fast metadata checks, without starting the game:

nix develop -c godot --headless --path . --script tests/pack_manifest.gd

HUD backing

Both manifest versions accept optional hud.backing:

"hud": {"backing": {"color": [24, 48, 56], "padding": 2}}

color is exactly three integer RGB channels, each 0–255. padding is an integer number of art pixels, 0–16, and defaults to 2. Non-numeric, fractional, out-of-range and non-finite values invalidate the whole backing; other HUD entries still load. Unknown keys have no effect.

Each readout gets one opaque rectangle behind its icon and text, using the layout's measured box expanded by padding. Edges use whole HUD-scale pixels; there is no blending or antialiasing. Both normal and dense HUD plans leave at least twice the padding between readouts and wrapped rows, so backings do not overlap. Packs without a valid backing keep their existing layout and drawing.

Audio

The optional audio section supplies sound. Pantry has no audio, so it is silent. Every file is a relative Ogg Vorbis path; other entries are ignored.

"audio": {
  "music": {
    "1": {"file": "audio/meadow.ogg", "loop_start": 1.95, "volume_db": 0},
    "2": "audio/caves.ogg",
    "fanfare": "audio/fanfare.ogg"
  },
  "effects": {"hop": "audio/hop.ogg", "chime": "audio/chime.ogg"},
  "events": {"jump": "hop", "coin": "chime", "egg_bounce": ["tap1", "tap2"]},
  "tracks": {"goal": "fanfare"}
}
Key Meaning
music Track key to a file, or to {file, loop_start, volume_db}. Playback loops back to loop_start seconds (default 0). volume_db offsets the track volume (default 0, clamped to -60..24).
effects Effect key to a file.
events Game event name to an effect key, or to a list of keys that take turns by saved frame. An unmapped event uses the effect with its own name.
tracks Music role (goal, death) to a track key. An unmapped role uses the track with its own name.

A map's level-code music id selects the track with that key. Id 0, an unknown id and a remote address play no music; the player never downloads music. A custom selection whose source names a pack track uses its own loop_start,volume_db settings from the level code.

Game events:

Group Events
Movement jump, flutter, pound_start, pound_land, head_bump, splash, snow_step
Mouth and eggs tongue, spit, spit_fire, spit_ice, spit_seed, egg_throw, aim_tick, egg_made, egg_pickup
Damage and companion hit, stomp, companion_cry, countdown_high, countdown_low, countdown_full, rescue, rejoin, quick_death
Items coin, red_coin, all_red_coins, flower, all_flowers, star, checkpoint
Level objects switch_press, switch_timer, message_hit, message_open, message_scroll, message_close, egg_block, spring, gate_open
Thrown eggs and caches egg_bounce, egg_ricochet, cloud_reveal, cloud_hit, cloud_release

The level track plays during play. Death stops all sound and plays the death role once. Reaching the goal stops the level track and plays the goal role once. The level track restarts after a respawn.

src/view/audio_player.gd derives these events by comparing the previous and current saved state after each simulation step: counters, life phases, action changes, request serials, the switch's saved sound triggers and a few saved fields of object rows, matched by their stable tokens. It never writes state, so sound cannot change physics or saves. A restored or replaced state, a backward frame or a gap of more than four frames resynchronizes without events: a restore never replays old sounds. It stops effects and a one-shot role. The level track keeps playing when the restored map uses the same track and starts otherwise. A running switch timer resumes at its saved position. Effects share eight voices. A new sound for an event restarts that event's voice; otherwise the oldest voice is reused. Effects are not positional.

settings.json holds music_volume and effects_volume, whole numbers from 0 to 100 (default 80). 0 silences that group. An invalid value logs a warning and uses 80. Pass --mute after -- to silence all audio, for example in tests. Headless runs use the dummy audio driver; event derivation still runs.

Fast derivation checks, without audio output:

nix develop -c godot --headless --path . --script tests/audio.gd

Terrain strips

The optional terrain dictionary lets fill tiles hold only interior material. The view draws edge material from collision, so moss appears only where the terrain's top is open to air. Each key is a tilemap name, such as garden32_1, or a tileset family, such as garden32. The family is the name without a final _ and number. A tilemap's own entry wins over its family's entry.

"terrain": {
  "castle32": {
    "surface": {"file": "terrain/moss.png", "edge": 6},
    "underside": {"file": "terrain/drip.png", "edge": 0},
    "side": {"file": "terrain/wall.png", "edge": 1}
  }
}
Strip Drawn along Repeats edge
surface Every upward-facing open edge Along x, with the image width Image rows above the edge line, in the open air. Rows below it are inside the terrain.
underside Every downward-facing open edge Along x Image rows above the edge line, inside the terrain. Rows below it hang into the open air.
side Every sideways open edge Along y, with the image height Image columns left of the edge line. Draw the strip for a wall whose open side faces left. The view mirrors it for walls that face right.

edge is a whole number of art pixels from 0 to the strip's height (width for side). It defaults to 0. An invalid strip logs a warning and is ignored. The strip pattern is fixed to the world: art column x uses image column x mod width, so a run continues across tiles without a seam.

src/view/terrain_edges.gd finds the open edges. It takes every edge of every cell's collision polygon, with the cell's flips. An edge faces up or down when its normal is at least as vertical as it is horizontal; otherwise it is a side. For each art pixel along the edge, it probes half an art pixel outside the edge. The edge is open there when no terrain of the same kind covers the probe. Solid terrain counts only solid layers; water counts only water layers. Only layers in the same drawing group (layer1 or layer2) count. The map border counts as closed, so the ground along the map bottom and sides gets no strip. Placed objects, such as one-way bridges, neither receive nor hide strips. The finder first probes the middle of every 8-world-pixel interval and both ends; an edge with no open probe is skipped. A gap narrower than 8 world pixels between two other cells can therefore be missed.

Slopes are stepped, never rotated: each art column (each row, for a side) is one whole slice of the strip, moved by whole art pixels. The edge line in a column follows the same pixel-centre rule that masks partial fill tiles. No strip pixel is scaled, blended or dropped. src/view/terrain_strips.gd draws one node per dressed tile layer at the layer's own z index, after all tile layers, in the order sides, undersides, surfaces. A surface covers the top corner of a wall. Strips have no collision and change no saved state.

Packs without terrain add no node and draw exactly as before.

Checks: tests/terrain_edges.gd finds edges on small generated levels (headless). tests/test_terrain_render.py reads back GPU frames under Xvfb and checks each strip pixel on open tops, mirrored walls and unchanged internal joins.

Pantry scenery and objects (provisional)

The manifest may carry three optional dictionaries. Packs without them keep the diagnostic path.

  • objects: object code to file and origin (pixel anchor). The view uses them for simulated objects (enemies, spring, bridge, goal) and draws every other placed object as a static sprite at its level position, with its rotation and mirror fields. Static objects have no behaviour yet. e5_head and recovery name the plant head and the recovery marker. companion names the companion on the player's back, with states idle (attached) and cry (in the bubble); a pack without it draws a diagnostic box.
  • background: wall, counter and cabinet textures. The wall repeats over the whole map and scrolls 1:1 with the terrain, because supports are fixed to it. There is no parallax. The counter runs along the map bottom and the cabinet band along the map top.
  • supports: shelf and bracket textures. src/view/pack_dressing.gd finds every downward-facing collision edge whose open side has no solid terrain and no map bottom. Flat runs get a shelf board inside the terrain's bottom edge and brackets on the wall below. Sloped undersides get a diagonal brace inside the terrain. Back-layer supports are tinted. Nothing hangs into the space the player moves through, and no support has collision.

All of this is drawing only. Level JSON, collision, simulation and saves are unchanged. Built-in res:// textures load through ResourceLoader; external PNGs load directly. A source checkout needs godot --headless --path . --editor --import after an artwork change.

View policy

All pack image sizes and pixel coordinates use art pixels. This includes origins, offsets, anchors, repeat sizes and scroll references. Divide them by density to get world pixels. Layer scale and scroll remain ratios. Simulation positions, level positions and map settings stay in world pixels. Missing art keeps its diagnostic world size.

The view uses nearest filtering only, with no smoothing or mipmapping. The reference scale is the square root of container area divided by 832 by 448. Divide that reference by density. Choose the integer m closest in ratio, never below 1. Each art pixel covers m screen pixels on each axis. World scale is m * density. Raise m to fit level bounds if needed. Clamp the camera inside the level. Round the root offset to whole screen pixels.

Density 1 keeps the existing drawing and layer transforms. For denser packs, snap each sprite's final corner to screen pixels, including origins and mirrors. Round squash, fit and layer scales to whole screen pixels per art pixel, with a minimum of 1. This can change the fitted width or squash amount. Restore the unsnapped transforms before each draw so resizing cannot cause drift. Off-axis rotations use nearest sampling, so their art pixels are not uniform. HUD icons and bitmap glyphs use the same m; they wrap instead of shrinking. Omit an icon if it would push its text out of a narrow window. In a window too small for one readout, later HUD items can be clipped; health stays first at the top.

At density 1, a 640 by 480 container shows 640 by 480 world pixels. At density 2, it shows 320 by 240. At 1920 by 1080, both show 960 by 540. At 3840 by 2160, density 2 shows 960 by 540. A wider view does not activate more enemies. Activation belongs to the fixed simulation reference area; see Inactive enemies.

Goal and recovery markers are sprites (generated, or the pack's art). This keeps their first appearance on the sprite shader path already used at startup. Creating a new drawing primitive mid-level can trigger shader compilation.

Zoom

Zoom is a whole-number step added to the fitted multiple m. Each step keeps art pixels square and whole: world scale stays m * density. Zoom never changes the simulation, its activation area or saved states.

Limit Rule
Zoom out m stays at least 1 and at least the multiple that fits the level bounds. The camera never shows space outside the level.
Zoom in m stops before fewer than 208 by 112 world pixels are visible, but never below the unzoomed multiple.

The range depends only on the container, the level size and density, so a resized window keeps a valid zoom. At density 1, a 1920 by 1080 container fits m = 2; its range is m = 1 (1920 by 1080 world pixels) to m = 9. A window at 832 by 448 cannot zoom out, because m = 1 already. HUD art keeps the unzoomed multiple.

Zoom keys are = and - (or keypad + and -), and 0 to reset. A controller uses a firm right-stick push up or down, and a right-stick press to reset. settings.json keeps the last step as zoom (whole numbers from -16 to 16, default 0). The view clamps a saved step into the current range; a press moves from the step actually shown.

Inactive enemies

The simulation activates a placed enemy only inside the fixed 832 by 448 reference area around the saved follow point. A wider or zoomed-out view can show enemies outside that area. The view draws them still and dimmed: the default pose at its first frame, at the enemy's placement, with a grey tint at 55% opacity. This applies to enemies that wait for their first activation and to enemies queued to return to their placement. Defeated enemies, and queued enemies that cannot return, stay hidden.

Reason: hiding them makes enemies appear from nothing in plain view when the player approaches, which reads as a spawn. Drawing them normally suggests they move and threaten when they do not. A still, dimmed figure shows the level's real layout and tells the player the enemy is not yet active. The rule reads saved state only; it never activates an enemy.

Smooth drawing

Physics stays at 60 steps per second. On displays faster than 60 Hz, each picture blends drawn positions between the state before the last step and the current state, by the elapsed part of the current step. This covers the player, camera, companion and every entity position. Values that object types derive for drawing, such as animation frames, use the current state. The blended picture is a separate copy; the view never writes state.

Pictures snap to the current state, without blending, after a restore, a frame gap, a map change, a respawn, and for any position that moves more than 64 pixels in one step. While steps are paused, for example after the goal, the view draws the current state. At 60 Hz the view draws saved states as before. Pass --smooth=on or --smooth=off after the separator to override the automatic choice, which uses the display's reported refresh rate.

Fast checks: tests/screen_plan.gd covers zoom ranges, tests/view_motion.gd covers blending and settings, and tests/view_invariance.gd drives one input sequence at 60, 120 and 144 Hz and three zoom steps, and proves that the state matches a view-free run at every step.