349 lines
12 KiB
Bash
349 lines
12 KiB
Bash
|
|
#!/bin/bash
|
||
|
|
#
|
||
|
|
# Refresh the tutorial art in docs/tutorials/assets/ from Kenney.nl.
|
||
|
|
#
|
||
|
|
# Those PNGs are tracked on purpose. They are what the two tutorial games in
|
||
|
|
# examples/ draw, what docs/tutorials/PROVENANCE.md accounts for, and what a
|
||
|
|
# reader copies into their own project -- so the tree has to build and the
|
||
|
|
# tutorials have to run with no network access at all. This script therefore
|
||
|
|
# has the same rule mkcontrollermappings.sh was fixed into for 0.5.0: **it must
|
||
|
|
# never replace a good tracked asset with a worse one**. Everything is fetched
|
||
|
|
# and repacked in a temporary directory, every step is checked, and the staged
|
||
|
|
# files are moved into place only once all of them have come out right. Any
|
||
|
|
# failure leaves the tracked copies exactly as they were and exits non-zero.
|
||
|
|
#
|
||
|
|
# Nothing in the build runs this. It is a deliberate, standalone refresh, and
|
||
|
|
# its output should land in its own commit so the diff is reviewable.
|
||
|
|
#
|
||
|
|
# Two of the eight images are verbatim copies of a Kenney tileset. The other six
|
||
|
|
# are repacked: 16x16 source art composited bottom-centre into 32x32 cells, one
|
||
|
|
# row, left to right, which is the layout akgl_spritesheet_coords_for_frame
|
||
|
|
# indexes. The repack lives here rather than in a second script so that the
|
||
|
|
# tracked bytes are reproducible from one command.
|
||
|
|
#
|
||
|
|
# The JSON (sprites, characters) and the TMJ maps are hand-authored source, not
|
||
|
|
# generated. This script does not touch them.
|
||
|
|
#
|
||
|
|
# Usage: scripts/fetch_tutorial_assets.sh [repository-root]
|
||
|
|
|
||
|
|
set -euo pipefail
|
||
|
|
|
||
|
|
AKGL_KENNEY_ASSET_URL="${AKGL_KENNEY_ASSET_URL:-https://kenney.nl/assets}"
|
||
|
|
# A floor, not a target. Each pack page carries its own licence line; a page
|
||
|
|
# that does not say CC0 is either the wrong page or a relicensed pack, and
|
||
|
|
# either way its bytes must not land in this repository.
|
||
|
|
AKGL_KENNEY_LICENSE_TEXT="${AKGL_KENNEY_LICENSE_TEXT:-Creative Commons CC0}"
|
||
|
|
|
||
|
|
function die()
|
||
|
|
{
|
||
|
|
echo "fetch_tutorial_assets: $*" >&2
|
||
|
|
exit 1
|
||
|
|
}
|
||
|
|
|
||
|
|
function note()
|
||
|
|
{
|
||
|
|
echo "fetch_tutorial_assets: $*"
|
||
|
|
}
|
||
|
|
|
||
|
|
rootdir="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||
|
|
assetdir="${rootdir}/docs/tutorials/assets"
|
||
|
|
|
||
|
|
if [[ ! -d "${assetdir}/sidescroller" ]] || [[ ! -d "${assetdir}/jrpg" ]]; then
|
||
|
|
die "no such directory: ${assetdir}/{sidescroller,jrpg}"
|
||
|
|
fi
|
||
|
|
|
||
|
|
command -v curl >/dev/null 2>&1 || die "curl is not available"
|
||
|
|
command -v unzip >/dev/null 2>&1 || die "unzip is not available"
|
||
|
|
command -v convert >/dev/null 2>&1 || die "ImageMagick 'convert' is not available"
|
||
|
|
command -v identify >/dev/null 2>&1 || die "ImageMagick 'identify' is not available"
|
||
|
|
|
||
|
|
workdir="$(mktemp -d)"
|
||
|
|
|
||
|
|
function cleanup()
|
||
|
|
{
|
||
|
|
rm -rf "${workdir}"
|
||
|
|
}
|
||
|
|
trap cleanup EXIT
|
||
|
|
|
||
|
|
staging="${workdir}/staging"
|
||
|
|
mkdir -p "${staging}/sidescroller" "${staging}/jrpg"
|
||
|
|
|
||
|
|
##
|
||
|
|
## Fetching
|
||
|
|
##
|
||
|
|
|
||
|
|
# Download one pack, check its page says CC0, and unpack it under ${workdir}/<slug>.
|
||
|
|
function fetch_pack()
|
||
|
|
{
|
||
|
|
local slug="$1"
|
||
|
|
local page="${workdir}/${slug}.html"
|
||
|
|
local zip="${workdir}/${slug}.zip"
|
||
|
|
local dest="${workdir}/${slug}"
|
||
|
|
local url=""
|
||
|
|
|
||
|
|
# --fail so an HTTP error status is an exit status rather than an error page
|
||
|
|
# in the body. Not a pipeline: the status being checked is curl's, and a
|
||
|
|
# pipeline reports the last command's.
|
||
|
|
if ! curl --fail --silent --show-error --location "${AKGL_KENNEY_ASSET_URL}/${slug}" --output "${page}"; then
|
||
|
|
die "could not fetch the pack page for ${slug}; tracked assets left as they were"
|
||
|
|
fi
|
||
|
|
|
||
|
|
if ! grep -q "${AKGL_KENNEY_LICENSE_TEXT}" "${page}"; then
|
||
|
|
die "the ${slug} page does not say '${AKGL_KENNEY_LICENSE_TEXT}'; refusing to vendor it"
|
||
|
|
fi
|
||
|
|
|
||
|
|
url="$(grep -o "https://[^'\"]*${slug}\.zip" "${page}" | head -1)"
|
||
|
|
if [[ -z "${url}" ]]; then
|
||
|
|
die "no download link found on the ${slug} page; tracked assets left as they were"
|
||
|
|
fi
|
||
|
|
|
||
|
|
if ! curl --fail --silent --show-error --location "${url}" --output "${zip}"; then
|
||
|
|
die "download failed from ${url}; tracked assets left as they were"
|
||
|
|
fi
|
||
|
|
|
||
|
|
if ! unzip -tq "${zip}" >/dev/null 2>&1; then
|
||
|
|
die "${slug}.zip did not survive an integrity check; tracked assets left as they were"
|
||
|
|
fi
|
||
|
|
|
||
|
|
mkdir -p "${dest}"
|
||
|
|
if ! unzip -qo "${zip}" -d "${dest}"; then
|
||
|
|
die "could not unpack ${slug}.zip; tracked assets left as they were"
|
||
|
|
fi
|
||
|
|
|
||
|
|
if ! grep -q "Creative Commons Zero" "${dest}/License.txt"; then
|
||
|
|
die "${slug} does not carry a CC0 License.txt; refusing to vendor it"
|
||
|
|
fi
|
||
|
|
|
||
|
|
note "fetched ${slug} from ${url}"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Refuse to publish an audio file that is not actually Ogg. `file(1)` is not
|
||
|
|
# guaranteed to be installed; the container magic is four bytes at offset zero.
|
||
|
|
function require_ogg()
|
||
|
|
{
|
||
|
|
local file="$1"
|
||
|
|
local minbytes="$2"
|
||
|
|
|
||
|
|
[[ -f "${file}" ]] || die "expected file is missing: ${file}"
|
||
|
|
if [[ "$(head -c 4 "${file}")" != "OggS" ]]; then
|
||
|
|
die "${file} is not an Ogg stream; tracked assets left as they were"
|
||
|
|
fi
|
||
|
|
if [[ "$(wc -c < "${file}")" -lt "${minbytes}" ]]; then
|
||
|
|
die "${file} is under ${minbytes} bytes; tracked assets left as they were"
|
||
|
|
fi
|
||
|
|
}
|
||
|
|
|
||
|
|
# Refuse to work from an image that is not the size the frame arithmetic assumes.
|
||
|
|
function require_size()
|
||
|
|
{
|
||
|
|
local file="$1"
|
||
|
|
local want="$2"
|
||
|
|
local got=""
|
||
|
|
|
||
|
|
[[ -f "${file}" ]] || die "expected file is missing: ${file}"
|
||
|
|
got="$(identify -format '%wx%h' "${file}")"
|
||
|
|
if [[ "${got}" != "${want}" ]]; then
|
||
|
|
die "${file} is ${got}, expected ${want}; the pack layout has changed and the frame offsets no longer hold"
|
||
|
|
fi
|
||
|
|
}
|
||
|
|
|
||
|
|
##
|
||
|
|
## Repacking
|
||
|
|
##
|
||
|
|
## A sheet is one row of 32x32 cells. Each cell holds one 16x16 source tile
|
||
|
|
## composited at (8, 16) -- horizontally centred, sitting on the cell's bottom
|
||
|
|
## edge -- so an actor drawn into a 32x32 destination rectangle has its feet on
|
||
|
|
## the bottom of that rectangle whichever sheet it came from.
|
||
|
|
##
|
||
|
|
|
||
|
|
AKGL_SHEET_CELL=32
|
||
|
|
AKGL_SOURCE_TILE=16
|
||
|
|
AKGL_CELL_OFFSET_X=8
|
||
|
|
AKGL_CELL_OFFSET_Y=16
|
||
|
|
|
||
|
|
# build_sheet <source-png> <output-png> <spec>...
|
||
|
|
#
|
||
|
|
# Each spec is "X,Y" or "X,Y,flop": the source pixel offset of a 16x16 tile, and
|
||
|
|
# whether to mirror it horizontally. Specs are laid into cells left to right, so
|
||
|
|
# the position of a spec in the argument list *is* its frame id.
|
||
|
|
function build_sheet()
|
||
|
|
{
|
||
|
|
local src="$1"
|
||
|
|
local out="$2"
|
||
|
|
shift 2
|
||
|
|
|
||
|
|
local -a args=()
|
||
|
|
local count=$#
|
||
|
|
local idx=0
|
||
|
|
local spec=""
|
||
|
|
local sx=""
|
||
|
|
local sy=""
|
||
|
|
local flop=""
|
||
|
|
|
||
|
|
args+=( -size "$((count * AKGL_SHEET_CELL))x${AKGL_SHEET_CELL}" xc:none )
|
||
|
|
for spec in "$@"; do
|
||
|
|
IFS=',' read -r sx sy flop <<< "${spec}"
|
||
|
|
args+=( '(' "${src}" -crop "${AKGL_SOURCE_TILE}x${AKGL_SOURCE_TILE}+${sx}+${sy}" +repage )
|
||
|
|
if [[ "${flop:-}" == "flop" ]]; then
|
||
|
|
args+=( -flop )
|
||
|
|
fi
|
||
|
|
args+=( ')' -geometry "+$(( (idx * AKGL_SHEET_CELL) + AKGL_CELL_OFFSET_X ))+${AKGL_CELL_OFFSET_Y}" -composite )
|
||
|
|
idx=$((idx + 1))
|
||
|
|
done
|
||
|
|
|
||
|
|
# -strip, and the two date properties by name, because ImageMagick writes
|
||
|
|
# date:create/date:modify tEXt chunks from the wall clock. Without this a
|
||
|
|
# refresh that changed nothing at all still produced eight different files,
|
||
|
|
# and the diff would say the art had changed when it had not.
|
||
|
|
args+=( -strip +set date:create +set date:modify +set date:timestamp )
|
||
|
|
if ! convert "${args[@]}" "${out}"; then
|
||
|
|
die "could not repack ${out}; tracked assets left as they were"
|
||
|
|
fi
|
||
|
|
}
|
||
|
|
|
||
|
|
# rpg_urban_character <character-index> <output-png>
|
||
|
|
#
|
||
|
|
# The RPG Urban Pack lays each character out as a 4-column by 3-row block in the
|
||
|
|
# last four columns of the tileset: columns are left, down, up, right; rows are
|
||
|
|
# stand, step A, step B. This regroups one block into the twelve-frame order the
|
||
|
|
# tutorial sprite JSON expects -- down, left, right, up, three frames each.
|
||
|
|
function rpg_urban_character()
|
||
|
|
{
|
||
|
|
local charidx="$1"
|
||
|
|
local out="$2"
|
||
|
|
local src="${workdir}/rpg-urban-pack/Tilemap/tilemap_packed.png"
|
||
|
|
local left=368
|
||
|
|
local down=384
|
||
|
|
local up=400
|
||
|
|
local right=416
|
||
|
|
local y0=$(( charidx * 3 * 16 ))
|
||
|
|
local y1=$(( y0 + 16 ))
|
||
|
|
local y2=$(( y0 + 32 ))
|
||
|
|
|
||
|
|
build_sheet "${src}" "${out}" \
|
||
|
|
"${down},${y0}" "${down},${y1}" "${down},${y2}" \
|
||
|
|
"${left},${y0}" "${left},${y1}" "${left},${y2}" \
|
||
|
|
"${right},${y0}" "${right},${y1}" "${right},${y2}" \
|
||
|
|
"${up},${y0}" "${up},${y1}" "${up},${y2}"
|
||
|
|
}
|
||
|
|
|
||
|
|
##
|
||
|
|
## Do the work
|
||
|
|
##
|
||
|
|
|
||
|
|
fetch_pack "pixel-line-platformer"
|
||
|
|
fetch_pack "rpg-urban-pack"
|
||
|
|
fetch_pack "music-jingles"
|
||
|
|
|
||
|
|
plp="${workdir}/pixel-line-platformer/Tilemap/tilemap_packed.png"
|
||
|
|
rup="${workdir}/rpg-urban-pack/Tilemap/tilemap_packed.png"
|
||
|
|
|
||
|
|
require_size "${plp}" "160x96"
|
||
|
|
require_size "${rup}" "432x288"
|
||
|
|
|
||
|
|
# Tilesets are copied verbatim. Both are already 16x16 with zero spacing and
|
||
|
|
# zero margin, which is the only geometry akgl_tilemap_compute_tileset_offsets
|
||
|
|
# gets right: it adds `spacing` to the tile pitch but sets the first row's y
|
||
|
|
# offset to `spacing` rather than zero, and it ignores `margin` entirely.
|
||
|
|
cp "${plp}" "${staging}/sidescroller/tiles.png"
|
||
|
|
cp "${rup}" "${staging}/jrpg/tiles.png"
|
||
|
|
|
||
|
|
# Pixel Line Platformer tile ids, ten columns: id -> ((id % 10) * 16, (id / 10) * 16)
|
||
|
|
# 40, 41, 42 the armed rabbit: contact, passing (also the jump pose), contact
|
||
|
|
# 44 the gold pickup
|
||
|
|
# 51, 52 the moth, wings up and down
|
||
|
|
# 55, 56 the red blob, two frames
|
||
|
|
#
|
||
|
|
# akgl_actor_render always draws with SDL_FLIP_NONE, so a left-facing run has to
|
||
|
|
# exist in the sheet as its own frames rather than being mirrored at draw time.
|
||
|
|
build_sheet "${plp}" "${staging}/sidescroller/player.png" \
|
||
|
|
"0,64" "16,64" "32,64" \
|
||
|
|
"0,64,flop" "16,64,flop" "32,64,flop"
|
||
|
|
build_sheet "${plp}" "${staging}/sidescroller/coin.png" \
|
||
|
|
"64,64"
|
||
|
|
build_sheet "${plp}" "${staging}/sidescroller/hazard.png" \
|
||
|
|
"80,80" "96,80" "16,80" "32,80"
|
||
|
|
|
||
|
|
rpg_urban_character 0 "${staging}/jrpg/player.png"
|
||
|
|
rpg_urban_character 3 "${staging}/jrpg/npc_shopkeeper.png"
|
||
|
|
rpg_urban_character 2 "${staging}/jrpg/npc_elder.png"
|
||
|
|
|
||
|
|
# One jingle per game, copied unchanged. akgl_load_start_bgm() is the library's
|
||
|
|
# only file-audio entry point -- akgl_audio_* is a synthesiser, and there is no
|
||
|
|
# sound-effect loader at all -- so a jingle is what a tutorial can actually
|
||
|
|
# play. Both are stings of under two seconds, and the loop request in
|
||
|
|
# akgl_load_start_bgm does not take effect, so each plays once.
|
||
|
|
cp "${workdir}/music-jingles/Audio/8-Bit jingles/jingles_NES00.ogg" \
|
||
|
|
"${staging}/sidescroller/jingle_start.ogg"
|
||
|
|
cp "${workdir}/music-jingles/Audio/Pizzicato jingles/jingles_PIZZI07.ogg" \
|
||
|
|
"${staging}/jrpg/jingle_start.ogg"
|
||
|
|
|
||
|
|
cp "${workdir}/pixel-line-platformer/License.txt" "${staging}/LICENSE.kenney_pixel-line-platformer.txt"
|
||
|
|
cp "${workdir}/rpg-urban-pack/License.txt" "${staging}/LICENSE.kenney_rpg-urban-pack.txt"
|
||
|
|
cp "${workdir}/music-jingles/License.txt" "${staging}/LICENSE.kenney_music-jingles.txt"
|
||
|
|
|
||
|
|
##
|
||
|
|
## Check the staging area before anything is allowed near the tracked copies
|
||
|
|
##
|
||
|
|
|
||
|
|
# name expected size
|
||
|
|
AKGL_TUTORIAL_ASSETS=(
|
||
|
|
"sidescroller/tiles.png 160x96"
|
||
|
|
"sidescroller/player.png 192x32"
|
||
|
|
"sidescroller/coin.png 32x32"
|
||
|
|
"sidescroller/hazard.png 128x32"
|
||
|
|
"jrpg/tiles.png 432x288"
|
||
|
|
"jrpg/player.png 384x32"
|
||
|
|
"jrpg/npc_shopkeeper.png 384x32"
|
||
|
|
"jrpg/npc_elder.png 384x32"
|
||
|
|
)
|
||
|
|
|
||
|
|
staged=0
|
||
|
|
for entry in "${AKGL_TUTORIAL_ASSETS[@]}"; do
|
||
|
|
read -r name want <<< "${entry}"
|
||
|
|
require_size "${staging}/${name}" "${want}"
|
||
|
|
if [[ ! -s "${staging}/${name}" ]]; then
|
||
|
|
die "staged ${name} is empty; tracked assets left as they were"
|
||
|
|
fi
|
||
|
|
staged=$((staged + 1))
|
||
|
|
done
|
||
|
|
|
||
|
|
if [[ "${staged}" -ne "${#AKGL_TUTORIAL_ASSETS[@]}" ]]; then
|
||
|
|
die "staged ${staged} images, expected ${#AKGL_TUTORIAL_ASSETS[@]}; tracked assets left as they were"
|
||
|
|
fi
|
||
|
|
|
||
|
|
AKGL_TUTORIAL_AUDIO=(
|
||
|
|
"sidescroller/jingle_start.ogg"
|
||
|
|
"jrpg/jingle_start.ogg"
|
||
|
|
)
|
||
|
|
|
||
|
|
for name in "${AKGL_TUTORIAL_AUDIO[@]}"; do
|
||
|
|
require_ogg "${staging}/${name}" 4096
|
||
|
|
done
|
||
|
|
|
||
|
|
AKGL_TUTORIAL_LICENSES=(
|
||
|
|
"LICENSE.kenney_pixel-line-platformer.txt"
|
||
|
|
"LICENSE.kenney_rpg-urban-pack.txt"
|
||
|
|
"LICENSE.kenney_music-jingles.txt"
|
||
|
|
)
|
||
|
|
|
||
|
|
for name in "${AKGL_TUTORIAL_LICENSES[@]}"; do
|
||
|
|
if [[ ! -s "${staging}/${name}" ]]; then
|
||
|
|
die "staged ${name} is empty; tracked assets left as they were"
|
||
|
|
fi
|
||
|
|
done
|
||
|
|
|
||
|
|
##
|
||
|
|
## Publish
|
||
|
|
##
|
||
|
|
|
||
|
|
for entry in "${AKGL_TUTORIAL_ASSETS[@]}"; do
|
||
|
|
read -r name want <<< "${entry}"
|
||
|
|
mv "${staging}/${name}" "${assetdir}/${name}"
|
||
|
|
done
|
||
|
|
for name in "${AKGL_TUTORIAL_AUDIO[@]}" "${AKGL_TUTORIAL_LICENSES[@]}"; do
|
||
|
|
mv "${staging}/${name}" "${assetdir}/${name}"
|
||
|
|
done
|
||
|
|
|
||
|
|
note "wrote ${staged} images, ${#AKGL_TUTORIAL_AUDIO[@]} jingles and ${#AKGL_TUTORIAL_LICENSES[@]} upstream licence files to ${assetdir}"
|