lexys-labyrinth/js/tileset.js
Eevee (Evelyn Woods) b75253a249 Rearrange actor loop to put movement advancement at the end
I don't know why I ever thought this was a separate pass; I think it was
just the easiest way to make smooth scrolling work when I first
implemented it on like day 2.  Turns out it wasn't ever correct and has
all manner of subtle implications I'll be sorting out for ages.

This does make the turn-based stuff //way// simpler, though.
2020-12-10 18:51:40 -07:00

1321 lines
43 KiB
JavaScript

import { DIRECTIONS } from './defs.js';
import TILE_TYPES from './tiletypes.js';
// TODO really need to specify this format more concretely, whoof
// XXX special kinds of drawing i know this has for a fact:
// - letter tiles draw from a block (one of two blocks!) of half-tiles onto the center of the base
// - force floors are cropped from a double-size tile
// - wired tiles are a whole thing (floor)
// - thin walls are packed into just two tiles
// - directional blocks have arrows in an awkward layout, not 4x4 grid but actually positioned on the edges
// - green and purple toggle walls use an overlay
// - turtles use an overlay, seem to pick a tile at random every so often
// - animations are common, should maybe have configurable timing??
// - custom floors and walls /should/ be consolidated into a single tile probably
// - thin walls should probably be consolidated?
// - traps have a state
// special features i currently have
// - directions for actors, can be used anywhere
// - arrows: for directional blocks
// - mask: for thin walls (though the idea is useful in many more places)
// - wired: for wired tiles
// - overlay: for green/purple walls mostly, also some bogus cc1 tiles
// things that are currently NOT handled
// - bomb is supposed to have a fuse
// - critters should only animate when moving
// - rover animation depends on behavior, also has a quarter-tile overlay for its direction
// - slime and walkers have double-size tiles when moving
// - logic gates draw over the stuff underneath them
// - railroad tracks overlay a Lot
// - canopy, at all
// - swivel's floor (eugh)
// - xray vision
// - editor vision
export const CC2_TILESET_LAYOUT = {
'#wire-width': 1/16,
door_red: [0, 1],
door_blue: [1, 1],
door_yellow: [2, 1],
door_green: [3, 1],
key_red: [4, 1],
key_blue: [5, 1],
key_yellow: [6, 1],
key_green: [7, 1],
// FIXME these shouldn't be drawn with a hole when drawn in isolation, gruh
dirt_block: {
special: 'perception',
threshold: 2,
hidden: [8, 1],
revealed: [9, 1],
},
ice: [10, 1],
ice_se: [11, 1],
ice_sw: [12, 1],
ice_ne: [13, 1],
ice_nw: [14, 1],
cloner: [15, 1],
floor: {
// Wiring!
base: [0, 2],
wired: [8, 26],
wired_cross: [10, 26],
is_wired_optional: true,
},
// FIXME i think these are visible with the secret eye, but my tileset puts text on them, whoops
wall_invisible: {
special: 'perception',
threshold: 2,
hidden: [0, 2],
revealed: [9, 31],
},
// FIXME this shouldn't be visible with seeing eye (or should it not spawn at all?)
wall_invisible_revealed: [1, 2],
wall_appearing: {
special: 'perception',
threshold: 2,
hidden: [0, 2],
revealed: [11, 31],
},
wall: [1, 2],
floor_letter: {
special: 'letter',
base: [2, 2],
letter_glyphs: {
// Arrows
"⬆": [14, 31],
"➡": [14.5, 31],
"⬇": [15, 31],
"⬅": [15.5, 31],
},
letter_ranges: [{
// ASCII text (only up through uppercase)
range: [32, 96],
x0: 0,
y0: 0,
w: 0.5,
h: 0.5,
columns: 32,
}],
},
thief_tools: [3, 2],
socket: [4, 2],
hint: [5, 2],
exit: [
[6, 2],
[7, 2],
[8, 2],
[9, 2],
],
// FIXME these shouldn't be drawn with a hole when drawn in isolation, gruh
ice_block: {
special: 'perception',
threshold: 2,
hidden: [10, 2],
revealed: [11, 2],
},
score_1000: [12, 2],
score_100: [13, 2],
score_10: [14, 2],
score_2x: [15, 2],
// LCD digit font
green_chip: [9, 3],
chip_extra: [10, 3],
chip: [11, 3],
bribe: [12, 3],
speed_boots: [13, 3],
canopy: [14, 3],
// canopy xray
// TODO lit
dynamite: [0, 4],
bomb: [5, 4],
green_bomb: [6, 4],
// TODO bomb fuse tile, ugh
floor_custom_green: [8, 4],
floor_custom_pink: [9, 4],
floor_custom_yellow: [10, 4],
floor_custom_blue: [11, 4],
wall_custom_green: [12, 4],
wall_custom_pink: [13, 4],
wall_custom_yellow: [14, 4],
wall_custom_blue: [15, 4],
explosion: [[0, 5], [1, 5], [2, 5], [3, 5]],
splash_slime: [[0, 5], [1, 5], [2, 5], [3, 5]],
splash: [[4, 5], [5, 5], [6, 5], [7, 5]],
flame_jet_off: [8, 5],
flame_jet_on: [[9, 5], [10, 5], [11, 5]],
popdown_wall: [12, 5],
popdown_floor: {
special: 'perception',
threshold: 2,
hidden: [12, 5],
revealed: [13, 5],
},
popdown_floor_visible: [13, 5],
no_sign: [14, 5],
frame_block: {
base: [15, 5],
arrows: [3, 10],
},
flippers: [0, 6],
fire_boots: [1, 6],
cleats: [2, 6],
suction_boots: [3, 6],
hiking_boots: [4, 6],
lightning_bolt: [5, 6],
// weird translucent spiral
// TODO dopps can push but i don't think they have any other visuals
doppelganger1: {
base: [7, 6],
overlay: 'player',
},
doppelganger2: {
base: [7, 6],
overlay: 'player2',
},
button_blue: [8, 6],
button_green: [9, 6],
button_red: [10, 6],
button_brown: [11, 6],
button_pink: {
base: [0, 2],
wired: [12, 6],
},
button_black: {
base: [0, 2],
wired: [13, 6],
},
button_orange: [14, 6],
button_yellow: [15, 6],
// TODO moving
bug: {
north: [[0, 7], [1, 7], [2, 7], [3, 7]],
east: [[4, 7], [5, 7], [6, 7], [7, 7]],
south: [[8, 7], [9, 7], [10, 7], [11, 7]],
west: [[12, 7], [13, 7], [14, 7], [15, 7]],
},
tank_blue: {
north: [[0, 8], [1, 8]],
east: [[2, 8], [3, 8]],
south: [[4, 8], [5, 8]],
west: [[6, 8], [7, 8]],
},
glider: {
north: [[8, 8], [9, 8]],
east: [[10, 8], [11, 8]],
south: [[12, 8], [13, 8]],
west: [[14, 8], [15, 8]],
},
green_floor: [[0, 9], [1, 9], [2, 9], [3, 9]],
purple_floor: [[4, 9], [5, 9], [6, 9], [7, 9]],
green_wall: {
base: 'green_floor',
overlay: [8, 9],
},
purple_wall: {
base: 'purple_floor',
overlay: [8, 9],
},
trap: {
closed: [9, 9],
open: [10, 9],
},
button_gray: [11, 9],
// Fireball animation is REALLY FAST, runs roughly twice per move
fireball: [
[12, 9], [13, 9], [14, 9], [15, 9],
[12, 9], [13, 9], [14, 9], [15, 9],
],
fake_wall: [0, 10],
// FIXME i think these are visible with the secret eye, but my tileset puts text on them, whoops
fake_floor: {
special: 'perception',
threshold: 2,
hidden: [0, 10],
revealed: [10, 31],
},
// Thin walls are built piecemeal from these two tiles; the first is N/S,
// the second is E/W
thinwall_n: {
tile: [1, 10],
mask: [0, 0, 1, 0.5],
},
thinwall_s: {
tile: [1, 10],
mask: [0, 0.5, 1, 0.5],
},
thinwall_w: {
tile: [2, 10],
mask: [0, 0, 0.5, 1],
},
thinwall_e: {
tile: [2, 10],
mask: [0.5, 0, 0.5, 1],
},
thinwall_se: {
base: 'thinwall_s',
overlay: 'thinwall_e',
},
// TODO directional block arrows
teleport_blue: {
base: [0, 2],
wired: [[4, 10], [5, 10], [6, 10], [7, 10]],
},
popwall: [8, 10],
popwall2: [8, 10],
gravel: [9, 10],
ball: [
// appropriately, this animation ping-pongs
[10, 10], [11, 10], [12, 10], [13, 10],
[14, 10], [13, 10], [12, 10], [11, 10],
// FIXME the ball bounces so it specifically needs to play its animation every move; this
// defeats the ½x slowdown. it's dumb and means this anim as written doesn't match cc2
[10, 10], [11, 10], [12, 10], [13, 10],
[14, 10], [13, 10], [12, 10], [11, 10],
],
steel: {
// Wiring!
base: [15, 10],
wired: [9, 26],
wired_cross: [11, 26],
is_wired_optional: true,
},
// TODO should explicitly set the non-moving tile, so we can have the walk tile start with
// immediate movement?
// TODO this shouldn't run at half speed, it's already designed to be one step, and when teeth
// move at half speed it looks clumsy
teeth: {
// NOTE: CC2 inexplicably dropped north teeth and just uses the south sprites instead
north: [[1, 11], [0, 11], [1, 11], [2, 11]],
east: [[4, 11], [3, 11], [4, 11], [5, 11]],
south: [[1, 11], [0, 11], [1, 11], [2, 11]],
west: [[7, 11], [6, 11], [7, 11], [8, 11]],
},
swivel_sw: [9, 11],
swivel_nw: [10, 11],
swivel_ne: [11, 11],
swivel_se: [12, 11],
swivel_floor: [13, 11],
'#wire-tunnel': [14, 11],
stopwatch_penalty: [15, 11],
paramecium: {
north: [[0, 12], [1, 12], [2, 12]],
east: [[3, 12], [4, 12], [5, 12]],
south: [[6, 12], [7, 12], [8, 12]],
west: [[9, 12], [10, 12], [11, 12]],
},
foil: [12, 12],
turtle: {
// Turtles draw atop fake water, but don't act like water otherwise
overlay: [13, 12], // TODO also 14 + 15 for sinking
base: 'water',
},
walker: [0, 13],
// FIXME walker animations span multiple tiles
helmet: [0, 14],
stopwatch_toggle: [14, 14],
stopwatch_bonus: [15, 14],
blob: [0, 15],
// FIXME blob animations span multiple tiles
// TODO [0, 16] some kinda red/blue outline
floor_mimic: {
special: 'perception',
threshold: 1,
hidden: [0, 2],
revealed: [14, 16],
},
// TODO [15, 16] some kinda yellow/black outline
// timid teeth
teeth_timid: {
// NOTE: CC2 inexplicably dropped north teeth and just uses the south sprites instead
// NOTE: it also skimped on timid teeth frames
north: [[1, 17], [0, 17]],
east: [[3, 17], [2, 17]],
south: [[1, 17], [0, 17]],
west: [[5, 17], [4, 17]],
},
bowling_ball: [6, 17], // TODO also +18 when rolling
tank_yellow: {
north: [[8, 17], [9, 17]],
east: [[10, 17], [11, 17]],
south: [[12, 17], [13, 17]],
west: [[14, 17], [15, 17]],
},
// TODO rover has an overlay showing its direction
rover: {
inert: [0, 18],
teeth: [[0, 18], [8, 18]],
glider: [
// quite fast
[0, 18], [1, 18], [2, 18], [3, 18], [4, 18], [5, 18], [6, 18], [7, 18],
[0, 18], [1, 18], [2, 18], [3, 18], [4, 18], [5, 18], [6, 18], [7, 18],
],
bug: [[0, 18], [1, 18], [2, 18], [3, 18], [4, 18], [5, 18], [6, 18], [7, 18]],
ball: [[0, 18], [4, 18]],
teeth_timid: [[0, 18], [9, 18]],
fireball: [
// quite fast
[7, 18], [6, 18], [5, 18], [4, 18], [3, 18], [2, 18], [1, 18], [0, 18],
[7, 18], [6, 18], [5, 18], [4, 18], [3, 18], [2, 18], [1, 18], [0, 18],
],
paramecium: [[7, 18], [6, 18], [5, 18], [4, 18], [3, 18], [2, 18], [1, 18], [0, 18]],
walker: [[8, 18], [9, 18]],
},
xray_eye: [11, 18],
ghost: {
north: [12, 18],
east: [13, 18],
south: [14, 18],
west: [15, 18],
},
force_floor_n: {
base: [0, 19],
animate_height: 1,
},
force_floor_e: {
base: [3, 19],
animate_width: -1,
},
force_floor_s: {
base: [1, 20],
animate_height: -1,
},
force_floor_w: {
base: [2, 20],
animate_width: 1,
},
teleport_green: [[4, 19], [5, 19], [6, 19], [7, 19]],
teleport_yellow: [[8, 19], [9, 19], [10, 19], [11, 19]],
transmogrifier: [[12, 19], [13, 19], [14, 19], [15, 19]],
teleport_red: {
base: [0, 2],
wired: [[4, 20], [5, 20], [6, 20], [7, 20]],
},
slime: [[8, 20], [9, 20], [10, 20], [11, 20], [12, 20], [13, 20], [14, 20], [15, 20]],
force_floor_all: [[0, 21], [1, 21], [2, 21], [3, 21], [4, 21], [5, 21], [6, 21], [7, 21]],
// latches
light_switch_off: {
base: [14, 21],
wired: [12, 21],
},
light_switch_on: {
base: [14, 21],
wired: [13, 21],
},
thief_keys: [15, 21],
player: {
normal: {
north: [0, 22],
south: [0, 23],
west: [8, 23],
east: [8, 22],
},
blocked: 'pushing',
moving: {
north: [[0, 22], [1, 22], [2, 22], [3, 22], [4, 22], [5, 22], [6, 22], [7, 22]],
east: [[8, 22], [9, 22], [10, 22], [11, 22], [12, 22], [13, 22], [14, 22], [15, 22]],
south: [[0, 23], [1, 23], [2, 23], [3, 23], [4, 23], [5, 23], [6, 23], [7, 23]],
west: [[8, 23], [9, 23], [10, 23], [11, 23], [12, 23], [13, 23], [14, 23], [15, 23]],
},
pushing: {
north: [8, 24],
east: [9, 24],
south: [10, 24],
west: [11, 24],
},
swimming: {
north: [[0, 24], [1, 24]],
east: [[2, 24], [3, 24]],
south: [[4, 24], [5, 24]],
west: [[6, 24], [7, 24]],
},
// The classic CC2 behavior, spinning on ice
skating: [[0, 22], [8, 22], [0, 23], [8, 23]],
// TODO i don't know what CC2 does
forced: {
north: [2, 22],
east: [10, 22],
south: [2, 23],
west: [10, 23],
},
exited: 'normal',
// These are frames from the splash/explosion animations
drowned: [5, 5],
slimed: [5, 5],
burned: [1, 5],
exploded: [1, 5],
failed: [1, 5],
},
bogus_player_win: {
overlay: [0, 23],
base: 'exit',
},
bogus_player_swimming: {
north: [[0, 24], [1, 24]],
east: [[2, 24], [3, 24]],
south: [[4, 24], [5, 24]],
west: [[6, 24], [7, 24]],
},
bogus_player_drowned: {
overlay: [5, 5], // splash
base: 'water',
},
bogus_player_burned_fire: {
overlay: [2, 5], // explosion frame 3
base: 'fire',
},
bogus_player_burned: {
overlay: [2, 5], // explosion frame 3
base: 'floor',
},
water: [
[12, 24],
[13, 24],
[14, 24],
[15, 24],
],
logic_gate: {
special: 'logic-gate',
logic_gate_tiles: {
'latch-ccw': {
north: [8, 21],
east: [9, 21],
south: [10, 21],
west: [11, 21],
},
not: {
north: [0, 25],
east: [1, 25],
south: [2, 25],
west: [3, 25],
},
and: {
north: [4, 25],
east: [5, 25],
south: [6, 25],
west: [7, 25],
},
or: {
north: [8, 25],
east: [9, 25],
south: [10, 25],
west: [11, 25],
},
xor: {
north: [12, 25],
east: [13, 25],
south: [14, 25],
west: [15, 25],
},
'latch-cw': {
north: [0, 26],
east: [1, 26],
south: [2, 26],
west: [3, 26],
},
nand: {
north: [4, 26],
east: [5, 26],
south: [6, 26],
west: [7, 26],
},
counter: [14, 26],
},
},
'#unpowered': [13, 26],
'#powered': [15, 26],
player2: {
normal: {
north: [0, 27],
south: [0, 28],
west: [8, 28],
east: [8, 27],
},
blocked: 'pushing',
moving: {
north: [[0, 27], [1, 27], [2, 27], [3, 27], [4, 27], [5, 27], [6, 27], [7, 27]],
south: [[0, 28], [1, 28], [2, 28], [3, 28], [4, 28], [5, 28], [6, 28], [7, 28]],
west: [[8, 28], [9, 28], [10, 28], [11, 28], [12, 28], [13, 28], [14, 28], [15, 28]],
east: [[8, 27], [9, 27], [10, 27], [11, 27], [12, 27], [13, 27], [14, 27], [15, 27]],
},
pushing: {
north: [8, 29],
east: [9, 29],
south: [10, 29],
west: [11, 29],
},
swimming: {
north: [[0, 29], [1, 29]],
east: [[2, 29], [3, 29]],
south: [[4, 29], [5, 29]],
west: [[6, 29], [7, 29]],
},
// The classic CC2 behavior, spinning on ice
skating: [[0, 27], [8, 27], [0, 28], [8, 28]],
// TODO i don't know what CC2 does
forced: {
north: [2, 27],
east: [10, 27],
south: [2, 28],
west: [10, 28],
},
exited: 'normal',
// These are frames from the splash/explosion animations
drowned: [5, 5],
slimed: [5, 5],
burned: [1, 5],
exploded: [1, 5],
failed: [1, 5],
},
fire: [
[12, 29],
[13, 29],
[14, 29],
[15, 29],
],
// TODO handle train tracks! this is gonna be complicated.
railroad: {
special: 'railroad',
base: [9, 10],
railroad_ties: {
ne: [0, 30],
se: [1, 30],
sw: [2, 30],
nw: [3, 30],
ew: [4, 30],
ns: [5, 30],
},
railroad_switch: [6, 30],
railroad_inactive: {
ne: [7, 30],
se: [8, 30],
sw: [9, 30],
nw: [10, 30],
ew: [11, 30],
ns: [12, 30],
},
railroad_active: {
ne: [13, 30],
se: [14, 30],
sw: [15, 30],
nw: [0, 31],
ew: [1, 31],
ns: [2, 31],
},
},
railroad_sign: [3, 31],
dirt: [4, 31],
no_player2_sign: [5, 31],
no_player1_sign: [6, 31],
hook: [7, 31],
// misc other stuff
};
// XXX need to specify that you can't use this for cc2 levels, somehow
export const TILE_WORLD_TILESET_LAYOUT = {
floor: [0, 0],
wall: [0, 1],
chip: [0, 2],
water: [0, 3],
fire: [0, 4],
wall_invisible: [0, 5],
wall_invisible_revealed: [0, 1],
thinwall_n: [0, 6],
thinwall_w: [0, 7],
thinwall_s: [0, 8],
thinwall_e: [0, 9],
// This is the non-directed dirt block, which we don't have
// dirt_block: [0, 10],
dirt: [0, 11],
ice: [0, 12],
force_floor_s: [0, 13],
dirt_block: {
north: [0, 14],
west: [0, 15],
south: [1, 0],
east: [1, 1],
},
force_floor_n: [1, 2],
force_floor_e: [1, 3],
force_floor_w: [1, 4],
exit: [1, 5],
door_blue: [1, 6],
door_red: [1, 7],
door_green: [1, 8],
door_yellow: [1, 9],
ice_nw: [1, 10],
ice_ne: [1, 11],
ice_se: [1, 12],
ice_sw: [1, 13],
fake_wall: [1, 14],
fake_floor: [1, 15],
// TODO overlay buffer?? [2, 0]
thief_tools: [2, 1],
socket: [2, 2],
button_green: [2, 3],
button_red: [2, 4],
green_wall: [2, 5],
green_floor: [2, 6],
button_brown: [2, 7],
button_blue: [2, 8],
teleport_blue: [2, 9],
bomb: [2, 10],
trap: {
closed: [2, 11],
open: [2, 11],
},
wall_appearing: [2, 12],
gravel: [2, 13],
popwall: [2, 14],
popwall2: [2, 14],
hint: [2, 15],
thinwall_se: [3, 0],
cloner: [3, 1],
force_floor_all: [3, 2],
splash: [3, 3],
bogus_player_drowned: [3, 3],
bogus_player_burned_fire: [3, 4],
bogus_player_burned: [3, 5],
explosion: [3, 6],
explosion_other: [3, 7], // TODO ???
// 3, 8 unused
bogus_player_win: [3, 9], // TODO 10 and 11 too? does this animate?
bogus_player_swimming: {
north: [3, 12],
west: [3, 13],
south: [3, 14],
east: [3, 15],
},
bug: {
north: [4, 0],
west: [4, 1],
south: [4, 2],
east: [4, 3],
},
fireball: {
north: [4, 4],
west: [4, 5],
south: [4, 6],
east: [4, 7],
},
ball: {
north: [4, 8],
west: [4, 9],
south: [4, 10],
east: [4, 11],
},
tank_blue: {
north: [4, 12],
west: [4, 13],
south: [4, 14],
east: [4, 15],
},
glider: {
north: [5, 0],
west: [5, 1],
south: [5, 2],
east: [5, 3],
},
teeth: {
north: [5, 4],
west: [5, 5],
south: [5, 6],
east: [5, 7],
},
walker: {
north: [5, 8],
west: [5, 9],
south: [5, 10],
east: [5, 11],
},
blob: {
north: [5, 12],
west: [5, 13],
south: [5, 14],
east: [5, 15],
},
paramecium: {
north: [6, 0],
west: [6, 1],
south: [6, 2],
east: [6, 3],
},
key_blue: [6, 4],
key_red: [6, 5],
key_green: [6, 6],
key_yellow: [6, 7],
flippers: [6, 8],
fire_boots: [6, 9],
cleats: [6, 10],
suction_boots: [6, 11],
player: {
normal: {
north: [6, 12],
south: [6, 14],
west: [6, 13],
east: [6, 15],
},
moving: 'normal',
pushing: 'normal',
blocked: 'normal',
swimming: {
north: [3, 12],
west: [3, 13],
south: [3, 14],
east: [3, 15],
},
skating: 'normal',
forced: 'normal',
burned: [3, 4], // TODO TW's lynx mode doesn't use this! it uses the generic failed
exploded: [3, 6],
failed: [3, 7],
},
};
export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, {
// Completed teeth sprites
teeth: Object.assign({}, CC2_TILESET_LAYOUT.teeth, {
north: [[1, 32], [0, 32], [1, 32], [2, 32]],
}),
teeth_timid: {
north: [[7, 32], [6, 32], [7, 32], [8, 32]],
east: [[4, 32], [2, 17], [4, 32], [3, 17]],
south: [[3, 32], [0, 17], [3, 32], [1, 17]],
west: [[5, 32], [4, 17], [5, 32], [5, 17]],
},
// Extra player sprites
player: Object.assign({}, CC2_TILESET_LAYOUT.player, {
skating: {
north: [0, 33],
east: [1, 33],
south: [2, 33],
west: [3, 33],
},
forced: 'skating',
exited: [14, 32],
burned: {
north: [4, 33],
east: [5, 33],
south: [6, 33],
west: [7, 33],
},
slimed: [1, 38],
}),
player2: Object.assign({}, CC2_TILESET_LAYOUT.player2, {
// TODO skating
exited: [15, 32],
burned: {
north: [12, 33],
east: [13, 33],
south: [14, 33],
west: [15, 33],
},
slimed: [1, 38],
}),
bogus_player_burned_fire: {
overlay: [6, 33],
base: 'fire',
},
bogus_player_burned: {
overlay: [6, 33],
base: 'floor',
},
// Custom tiles
popwall2: [9, 32],
gift_bow: [10, 32],
circuit_block: {
base: [13, 32],
wired: [11, 32],
wired_cross: [12, 32],
},
// Blob and walker in all four directions
blob: {
north: [[0, 35], [1, 35], [2, 35], [3, 35], [4, 35], [5, 35], [6, 35], [7, 35]],
east: [[8, 35], [9, 35], [10, 35], [11, 35], [12, 35], [13, 35], [14, 35], [15, 35]],
south: [[0, 36], [1, 36], [2, 36], [3, 36], [4, 36], [5, 36], [6, 36], [7, 36]],
west: [[8, 36], [9, 36], [10, 36], [11, 36], [12, 36], [13, 36], [14, 36], [15, 36]],
},
walker: {
north: [[0, 37], [1, 37], [2, 37], [3, 37]],
east: [[4, 37], [5, 37], [6, 37], [7, 37]],
// Same animations but played backwards
south: [[2, 37], [1, 37], [0, 37], [3, 37]],
west: [[6, 37], [5, 37], [4, 37], [7, 37]],
},
// Custom VFX
splash_slime: [[0, 38], [1, 38], [2, 38], [3, 38]],
});
export class Tileset {
constructor(image, layout, size_x, size_y) {
this.image = image;
this.layout = layout;
this.size_x = size_x;
this.size_y = size_y;
this.animation_slowdown = 2;
}
draw(tile, tic, blit) {
// FIXME perception
this.draw_type(tile.type.name, tile, tic, 0, blit);
}
// Draw a "standard" drawspec, which is either:
// - a single tile: [x, y]
// - an animation: [[x0, y0], [x1, y1], ...]
// - a directional tile: { north: T, east: T, ... } where T is either of the above
_draw_standard(drawspec, tile, tic, blit, mask = []) {
// If we have an object, it must be a table of directions
let coords = drawspec;
if (!(coords instanceof Array)) {
coords = coords[(tile && tile.direction) ?? 'south'];
}
// Deal with animation
if (coords[0] instanceof Array) {
if (tic !== null) {
if (tile && tile.animation_speed) {
// This tile reports its own animation timing (in tics), so trust that, and just
// use the current tic's fraction.
// That said: adjusting animation speed complicates this slightly. Consider the
// player's walk animation, which takes 4 tics to complete, during which time we
// cycle through 8 frames. Playing that at half speed means only half the
// animation actually plays, but if the player continues walking, then on the
// NEXT four tics, we should play the other half. To make this work, use the
// tic as a global timer as well: if the animation started on tics 0-4, play the
// first half; if it started on tics 5-8, play the second half. They could get
// out of sync if the player hesitates, but no one will notice that, and this
// approach minimizes storing extra state.
let i = (tile.animation_progress + tic % 1) / tile.animation_speed;
// But do NOT do this for explosions or splashes, which have a fixed duration
// and only play once
if (this.animation_slowdown > 1 && ! tile.type.ttl) {
// i ranges from [0, 1), but a slowdown of N means we'll only play the first
// 1/N of it before the game ends (or loops) the animation.
// So increase by [0..N-1] to get it in some other range, then divide by N
// to scale back down to [0, 1)
i += Math.floor(tic / tile.animation_speed % this.animation_slowdown);
i /= this.animation_slowdown;
}
coords = coords[Math.floor(i * coords.length)];
}
else {
// This tile animates on a global timer, one cycle every quarter of a second
coords = coords[Math.floor(tic / this.animation_slowdown % 5 / 5 * coords.length)];
}
}
else {
coords = coords[0];
}
}
blit(coords[0], coords[1], ...mask);
}
_draw_letter(drawspec, tile, tic, blit) {
this._draw_standard(drawspec.base, tile, tic, blit);
let glyph = tile ? tile.overlaid_glyph : "?";
if (drawspec.letter_glyphs[glyph]) {
let [x, y] = drawspec.letter_glyphs[glyph];
// XXX size is hardcoded here, but not below, meh
blit(x, y, 0, 0, 0.5, 0.5, 0.25, 0.25);
}
else {
// Look for a range
let u = glyph.charCodeAt(0);
for (let rangedef of drawspec.letter_ranges) {
if (rangedef.range[0] <= u && u < rangedef.range[1]) {
let t = u - rangedef.range[0];
let x = rangedef.x0 + rangedef.w * (t % rangedef.columns);
let y = rangedef.y0 + rangedef.h * Math.floor(t / rangedef.columns);
blit(x, y, 0, 0, rangedef.w, rangedef.h,
(1 - rangedef.w) / 2, (1 - rangedef.h) / 2);
break;
}
}
}
}
_draw_logic_gate(drawspec, tile, tic, blit) {
// Layer 1: wiring state
// Always draw the unpowered wire base
let unpowered_coords = this.layout['#unpowered'];
let powered_coords = this.layout['#powered'];
blit(...unpowered_coords);
if (tile && tile.cell) {
// What goes on top varies a bit...
// FIXME implement for NOT and counter!
let r = this.layout['#wire-width'] / 2;
if (tile.cell.powered_edges & DIRECTIONS[tile.direction].bit) {
// Output (on top)
let [x0, y0, x1, y1] = this._rotate(tile.direction, 0.5 - r, 0, 0.5 + r, 0.5);
blit(powered_coords[0], powered_coords[1], x0, y0, x1 - x0, y1 - y0);
}
if (tile.cell.powered_edges & DIRECTIONS[DIRECTIONS[tile.direction].right].bit) {
// Right input, which includes the middle
let [x0, y0, x1, y1] = this._rotate(tile.direction, 0.5 - r, 0.5 - r, 1, 1);
blit(powered_coords[0], powered_coords[1], x0, y0, x1 - x0, y1 - y0);
}
if (tile.cell.powered_edges & DIRECTIONS[DIRECTIONS[tile.direction].left].bit) {
// Left input, which does not include the middle
let [x0, y0, x1, y1] = this._rotate(tile.direction, 0, 0.5 - r, 0.5 - r, 1);
blit(powered_coords[0], powered_coords[1], x0, y0, x1 - x0, y1 - y0);
}
}
// Layer 2: the tile itself
this._draw_standard(drawspec.logic_gate_tiles[tile.gate_type], tile, tic, blit);
// Layer 3: counter number
if (tile.gate_type === 'counter') {
blit(0, 3, tile.memory * 0.75, 0, 0.75, 1, 0.125, 0);
}
}
_draw_railroad(drawspec, tile, tic, blit) {
// All railroads have regular gravel underneath
// TODO would be nice to disambiguate since it's possible to have nothing visible
this._draw_standard(this.layout['gravel'], tile, tic, blit);
// FIXME what do i draw if there's no tile?
let part_order = ['ne', 'se', 'sw', 'nw', 'ew', 'ns'];
let visible_parts = [];
let topmost_part = null;
for (let [i, part] of part_order.entries()) {
if (tile && (tile.tracks & (1 << i))) {
if (tile.track_switch === i) {
topmost_part = part;
}
visible_parts.push(part);
}
}
let has_switch = (tile && tile.track_switch !== null);
for (let part of visible_parts) {
this._draw_standard(drawspec.railroad_ties[part], tile, tic, blit);
}
let tracks = has_switch ? drawspec.railroad_inactive : drawspec.railroad_active;
for (let part of visible_parts) {
if (part !== topmost_part) {
this._draw_standard(tracks[part], tile, tic, blit);
}
}
if (topmost_part) {
this._draw_standard(drawspec.railroad_active[topmost_part], tile, tic, blit);
}
if (has_switch) {
this._draw_standard(drawspec.railroad_switch, tile, tic, blit);
}
}
// Draws a tile type, given by name. Passing in a tile is optional, but
// without it you'll get defaults.
draw_type(name, tile, tic, perception, blit) {
let drawspec = this.layout[name];
if (! drawspec) {
console.error(`Don't know how to draw tile type ${name}!`);
return;
}
if (drawspec.overlay) {
// Goofy overlay thing used for green/purple toggle tiles and
// southeast thin walls. Draw the base (a type name or drawspec), then draw
// the overlay (either a type name or a regular draw spec).
// TODO chance of infinite recursion here
if (typeof drawspec.base === 'string') {
this.draw_type(drawspec.base, tile, tic, perception, blit);
}
else {
this._draw_standard(drawspec.base, tile, tic, blit);
}
if (typeof drawspec.overlay === 'string') {
this.draw_type(drawspec.overlay, tile, tic, perception, blit);
return;
}
else {
drawspec = drawspec.overlay;
}
}
// TODO shift everything to use this style, this is ridiculous
if (drawspec.special) {
if (drawspec.special === 'letter') {
this._draw_letter(drawspec, tile, tic, blit);
return;
}
else if (drawspec.special === 'perception') {
if (perception >= drawspec.threshold) {
drawspec = drawspec.revealed;
}
else {
drawspec = drawspec.hidden;
}
}
else if (drawspec.special === 'logic-gate') {
this._draw_logic_gate(drawspec, tile, tic, blit);
return;
}
else if (drawspec.special === 'railroad') {
this._draw_railroad(drawspec, tile, tic, blit);
return;
}
}
let coords = drawspec;
if (drawspec.mask) {
// Some tiles (OK, just the thin walls) don't actually draw a full
// tile, so some adjustments are needed; see below
coords = drawspec.tile;
}
else if (drawspec.wired) {
// This /should/ match CC2's draw order exactly, based on experimentation
let wire_radius = this.layout['#wire-width'] / 2;
if (tile && tile.wire_directions === 0x0f) {
// This is a wired tile with crossing wires, which acts a little differently
// Draw the base tile
blit(drawspec.base[0], drawspec.base[1]);
// Draw the two wires as separate rectangles, NS then EW
let wire_inset = 0.5 - wire_radius;
let wire_coords_ns = this.layout[
tile.cell && tile.cell.powered_edges & DIRECTIONS['north'].bit ? '#powered' : '#unpowered'];
let wire_coords_ew = this.layout[
tile.cell && tile.cell.powered_edges & DIRECTIONS['east'].bit ? '#powered' : '#unpowered'];
blit(wire_coords_ns[0], wire_coords_ns[1], wire_inset, 0, wire_radius * 2, 1);
blit(wire_coords_ew[0], wire_coords_ew[1], 0, wire_inset, 1, wire_radius * 2);
// Draw the cross tile on top
coords = drawspec.wired_cross ?? drawspec.wired;
}
else if (tile && tile.wire_directions) {
// Draw the base tile
blit(drawspec.base[0], drawspec.base[1]);
// Draw the wire part as a single rectangle, initially just a small dot in the
// center, but extending out to any edge that has a wire present
let x0 = 0.5 - wire_radius;
let x1 = 0.5 + wire_radius;
let y0 = 0.5 - wire_radius;
let y1 = 0.5 + wire_radius;
if (tile.wire_directions & DIRECTIONS['north'].bit) {
y0 = 0;
}
if (tile.wire_directions & DIRECTIONS['east'].bit) {
x1 = 1;
}
if (tile.wire_directions & DIRECTIONS['south'].bit) {
y1 = 1;
}
if (tile.wire_directions & DIRECTIONS['west'].bit) {
x0 = 0;
}
let wire_coords = this.layout[tile.cell && tile.cell.powered_edges ? '#powered' : '#unpowered'];
blit(wire_coords[0], wire_coords[1], x0, y0, x1 - x0, y1 - y0);
// Then draw the wired tile on top of it all
coords = drawspec.wired;
}
else {
// There's no wiring here, so just draw the base and then draw the wired part on top
// as normal. If the wired part is optional (as is the case for flooring in the CC2
// tileset), draw the base as normal instead.
if (drawspec.is_wired_optional) {
coords = drawspec.base;
}
else {
blit(drawspec.base[0], drawspec.base[1]);
coords = drawspec.wired;
}
}
}
else if (drawspec.arrows) {
// Directional blocks have a specific overlay, but draw the base first
coords = drawspec.base;
}
else if (drawspec.animate_width) {
// Force floors animate their... cutout, I guess?
let [x, y] = drawspec.base;
let duration = 3 * this.animation_slowdown;
x += drawspec.animate_width * (tic % duration / duration);
// Round to tile width
x = Math.floor(x * this.size_x + 0.5) / this.size_x;
coords = [x, y];
}
else if (drawspec.animate_height) {
// Same, but along the other axis
let [x, y] = drawspec.base;
let duration = 3 * this.animation_slowdown;
y += drawspec.animate_height * (tic % duration / duration);
// Round to tile height
y = Math.floor(y * this.size_y + 0.5) / this.size_y;
coords = [x, y];
}
// Apply custom per-type visual states
if (TILE_TYPES[name] && TILE_TYPES[name].visual_state) {
// Note that these accept null, too, and return a default
let state = TILE_TYPES[name].visual_state(tile);
// If it's a string, that's an alias for another state
if (typeof coords[state] === 'string') {
coords = coords[coords[state]];
}
else {
coords = coords[state];
}
if (! coords) {
console.warn("No such state", state, "for tile", name, tile);
}
}
// Generic sprite definitions from here on!
// If we still have an object, it must be a table of directions
if (!(coords instanceof Array)) {
coords = coords[(tile && tile.direction) ?? 'south'];
}
// Deal with animation
if (coords[0] instanceof Array) {
if (tic !== null) {
if (tile && tile.animation_speed) {
// This tile reports its own animation timing (in tics), so trust that, and just
// use the current tic's fraction.
// That said: adjusting animation speed complicates this slightly. Consider the
// player's walk animation, which takes 4 tics to complete, during which time we
// cycle through 8 frames. Playing that at half speed means only half the
// animation actually plays, but if the player continues walking, then on the
// NEXT four tics, we should play the other half. To make this work, use the
// tic as a global timer as well: if the animation started on tics 0-4, play the
// first half; if it started on tics 5-8, play the second half. They could get
// out of sync if the player hesitates, but no one will notice that, and this
// approach minimizes storing extra state.
let i = (tile.animation_progress + tic % 1) / tile.animation_speed;
// But do NOT do this for explosions or splashes, which have a fixed duration
// and only play once
if (this.animation_slowdown > 1 && ! tile.type.ttl) {
// i ranges from [0, 1), but a slowdown of N means we'll only play the first
// 1/N of it before the game ends (or loops) the animation.
// So increase by [0..N-1] to get it in some other range, then divide by N
// to scale back down to [0, 1)
i += Math.floor(tic / tile.animation_speed % this.animation_slowdown);
i /= this.animation_slowdown;
}
coords = coords[Math.floor(i * coords.length)];
}
else {
// This tile animates on a global timer, one cycle every quarter of a second
coords = coords[Math.floor(tic / this.animation_slowdown % 5 / 5 * coords.length)];
}
}
else {
coords = coords[0];
}
}
if (drawspec.mask) {
// Continue on with masking
coords = drawspec.tile;
let [x0, y0, w, h] = drawspec.mask;
blit(coords[0], coords[1], x0, y0, w, h);
}
else {
if (!coords) console.error(name, tile);
blit(coords[0], coords[1]);
}
// Wired tiles may also have tunnels, drawn on top of everything else
if (drawspec.wired && tile && tile.wire_tunnel_directions) {
let tunnel_coords = this.layout['#wire-tunnel'];
let tunnel_width = 6/32;
let tunnel_length = 12/32;
let tunnel_offset = (1 - tunnel_width) / 2;
if (tile.wire_tunnel_directions & DIRECTIONS['north'].bit) {
blit(tunnel_coords[0], tunnel_coords[1],
tunnel_offset, 0, tunnel_width, tunnel_length);
}
if (tile.wire_tunnel_directions & DIRECTIONS['south'].bit) {
blit(tunnel_coords[0], tunnel_coords[1],
tunnel_offset, 1 - tunnel_length, tunnel_width, tunnel_length);
}
if (tile.wire_tunnel_directions & DIRECTIONS['west'].bit) {
blit(tunnel_coords[0], tunnel_coords[1],
0, tunnel_offset, tunnel_length, tunnel_width);
}
if (tile.wire_tunnel_directions & DIRECTIONS['east'].bit) {
blit(tunnel_coords[0], tunnel_coords[1],
1 - tunnel_length, tunnel_offset, tunnel_length, tunnel_width);
}
}
// Directional blocks have arrows drawn on top
// TODO does cc2 draw even if there are no arrows?
if (drawspec.arrows && tile && tile.arrows) {
let [x, y] = drawspec.arrows;
let x0 = 0.25, y0 = 0.25, x1 = 0.75, y1 = 0.75;
if (tile.arrows.has('north')) {
y0 = 0;
}
if (tile.arrows.has('east')) {
x1 = 1;
}
if (tile.arrows.has('south')) {
y1 = 1;
}
if (tile.arrows.has('west')) {
x0 = 0;
}
blit(x, y, x0, y0, x1 - x0, y1 - y0);
}
}
_rotate(direction, x0, y0, x1, y1) {
if (direction === 'east') {
return [1 - y1, x0, 1 - y0, x1];
}
else if (direction === 'south') {
return [1 - x1, 1 - y1, 1 - x0, 1 - y0];
}
else if (direction === 'west') {
return [y0, 1 - x1, y1, 1 - x0];
}
else {
return [x0, y0, x1, y1];
}
}
}