Compare commits

...

153 Commits
2.0 ... master

Author SHA1 Message Date
Eevee (Evelyn Woods)
2c879f99d2 Style nits: redarken bg, sharpen button highlights, emphasize splash headers 2024-05-09 21:01:39 -06:00
Eevee (Evelyn Woods)
b82e112cbc Teach the connect tool to move destinations and delete connections 2024-05-09 21:00:10 -06:00
Eevee (Evelyn Woods)
2fa84e0477 Fix some subtle bugs with some octants in walk_grid 2024-05-09 19:02:01 -06:00
Eevee (Evelyn Woods)
6d003287e4 Fleshed out editor tools considerably
- Added a layer selector

- Added a line tool

- Changed the fill tool to stop at blocking terrain, when filling with
  something other than terrain

- Updated the rotate tool to respect the layer selector

- Updated the adjust tool to respect the layer selector

- Added support to the adjust tool for railroad tracks, ice, swivels,
  force floors

- Restored the adjust tool's support for buttons, added a pressed-button
  preview, and added support for blue buttons

- Added an ice tool, for drawing ice corners interactively

- Added a text tool, for writing longer text via letter tiles

- Added a thin walls tool

- Changed the wire tool to draw wires for more types of mouse movements,
  so that any kind of scribble should produce a continuous wire;
  individual clicks will also place a wire immediately
2024-05-09 18:27:14 -06:00
Eevee (Evelyn Woods)
e963c83c4d Fix undoing green button presses 2024-05-08 08:55:23 -06:00
Eevee (Evelyn Woods)
0cd7537ce6 Add dirt sfx, and Cerise versions of the footstep and mmf sounds
Also toned down the reverb on get-tool while I'm here.
2024-05-07 17:50:58 -06:00
Eevee (Evelyn Woods)
ed67f371cb Lighten the color scheme somewhat 2024-05-07 17:10:05 -06:00
Eevee (Evelyn Woods)
246e56187c Fix the editor to also crop the selection 2024-05-06 23:12:35 -06:00
Eevee (Evelyn Woods)
214eaad1f5 Fix an editor crash when floating a selection containing a red button 2024-05-06 22:45:00 -06:00
Eevee (Evelyn Woods)
9e90b18f1f Fix the size of the "clear inventory" button 2024-05-06 22:11:37 -06:00
Eevee (Evelyn Woods)
d4910a4147 Sort of estimate the size of undo entries 2024-05-06 22:11:03 -06:00
Eevee (Evelyn Woods)
fe096436da Add some random functions (which I'm already using whoops) 2024-05-06 22:06:56 -06:00
Eevee (Evelyn Woods)
7b5e9b564d Fix entering debug mode from the splash screen 2024-05-06 22:04:14 -06:00
Eevee (Evelyn Woods)
98c77ed798 Fix undoing level properties 2024-05-06 21:11:39 -06:00
Eevee (Evelyn Woods)
68eb16f7e7 Get the level filter out of the thread pool, and add a non-threaded pool 2024-05-06 20:07:31 -06:00
Eevee (Evelyn Woods)
559730eae4 Fix and clean up wiring
- Fixed a gigantic bug where, due to a typo, a new circuit was created
  for every single wire segment.  Oops!

- The wiring phase now has somewhat fewer intermediate parts.

- Power-generating tiles have an explicit update phase, rather than
  updating in a method whose name starts with "get".

Anyway it's slightly faster than when I started and that's nice.

Only drawback is that circuit recalculation doesn't quite undo
correctly, but I think the effect is only visual.
2024-05-06 20:03:37 -06:00
Eevee (Evelyn Woods)
aa4b3f3794 Remove the tile pool
Doesn't save any space, doesn't make anything noticeably faster, and
also doesn't work and I don't want to waste time figuring out why.
2024-05-06 15:38:11 -06:00
Eevee (Evelyn Woods)
0e752972f0 Fix some style nits 2024-05-06 15:34:32 -06:00
Eevee (Evelyn Woods)
38d7b55032 Fix local directory loading 2024-05-06 15:03:31 -06:00
Eevee (Evelyn Woods)
913a8144f1 Fix overwriting a VFX with a transmute 2024-05-06 14:58:27 -06:00
Eevee (Evelyn Woods)
4772d63719 Remove some lingering console.log()s 2024-05-06 14:08:51 -06:00
Eevee (Evelyn Woods)
626d146375 Add explicit support in the level for sokoban buttons
Gets a lot of junk out of the sokoban buttons' implementations.

Also, undo closures are gone now!
2024-05-06 14:07:35 -06:00
Eevee (Evelyn Woods)
3c7b8948ae Remove the undo closure for failing the level 2024-05-06 13:41:29 -06:00
Eevee (Evelyn Woods)
d1f0ac4956 Simplify wire phase undo
Turns out we don't really need to store these changes to tile power at
all; they can be rederived from circuit power.

One more undo closure gone.
2024-05-06 13:37:34 -06:00
Eevee (Evelyn Woods)
b891d6f38c Undo inventory changes with tile props
This removes almost all of the remaining undo closures.
2024-05-06 12:57:58 -06:00
Eevee (Evelyn Woods)
20b19c53ff Cut down on some undo closures
If I can get rid of all of these, I can combine multiple undo entries,
and allow undoing backwards in time further (but more coarsely) with the
same memory usage.

This also introduces some actor pooling, which...  reduces memory usage
very slightly on clone-heavy levels, but may not be worth it overall.
2024-05-06 12:40:38 -06:00
Eevee (Evelyn Woods)
c900ec80db Reduce undo memory usage by a third
- Changes to tiles are now stored in a plain object rather than a Map,
  which it turns out takes up a decent bit more space.

- Changes to a tile's type or cell no longer need additional closures to
  perform the cell movement.

- Less impactful, but changes to level properties are now stored as a
  diff, not as a full set every tic.
2024-05-06 11:21:27 -06:00
Eevee (Evelyn Woods)
63da1ff38c Implement the toll gate 2024-05-05 16:20:21 -06:00
Eevee (Evelyn Woods)
3f6278f281 Fix rendering of the moment of death
Finally!  With the help of several mildly unpleasant hacks, the game now
draws as if a monster killing the player were walking onto her.
2024-05-05 15:32:56 -06:00
Eevee (Evelyn Woods)
4527eb972e Add a little noise to the rewind effect 2024-05-05 14:55:24 -06:00
Eevee (Evelyn Woods)
d54ba0a191 Increase rewinding speed 2024-05-05 13:28:05 -06:00
Eevee (Evelyn Woods)
45a8e0055d Untangle doppelganger movement from the visual is_blocked flag
Also, doppelgangers copy even failed force floor overrides.
2024-05-04 13:05:19 -06:00
Eevee (Evelyn Woods)
f6ee09b6c7 Added a new auto-fix for actors atop bombs in CCL levels
This fixes CCLP5 level 25 (and I think one or two others) by introducing
a new item, the dormant bomb, which turns into a regular bomb when
something moves off of it.

Other accumulated tileset touchups that snuck in:

- Recolored the canopy to hopefully look more like a tent
- Lightened slime by one shade
- Made the custom green floor colors clash less, and lightened the
  yellow floor grout
- Removed the shadow from the pause stopwatch, and lightened the other
  two, to better distinguish their different behavior
- Added little rivets to steel walls
- Made the symbols on the sokoban blocks easier to see
- Lightened the blue and red keys to match the shade of the other two,
  and also hopefully make them easier to see atop water and thieves
- Shrank the railroad crossing sign slightly
- Added laces to the hiking boots
- Added shading to the bowling ball
- Removed the shadows from the actor versions of the bowling ball and
  dynamite
- Darkened the dynamite item to better distinguish it from active
  dynamite
- Lightened the blue teleporter exit so it doesn't look too much like an
  inactive red teleporter
- Improved the gradient on the beetle
- Made the hint tile look recessed like CC2, to better convey that it
  blocks some actors
- Added art for some possible tiles: rainbow teleporter, toll gate, nega
  heart, phantom ring, feather
2024-05-04 12:09:48 -06:00
Eevee (Evelyn Woods)
e33c35bbe0 Fix (or unfix) the search radius for orange buttons 2024-05-04 11:35:20 -06:00
Eevee (Evelyn Woods)
1481047b94 Adjust the bug/paramecium and frame blocks in the editor palette to be more consistent 2024-04-28 09:24:13 -06:00
Eevee (Evelyn Woods)
037d9d86fb Fix a couple missed spots with actors_move_instantly 2024-04-25 05:24:34 -06:00
Eevee (Evelyn Woods)
9763ceaa1c Revamp tileset options; refactor drawing a bit; work on tileset conversion
Tileset options now identify the tilesets by their appearance, rather
than the fairly useless "custom 1" or whatever.

At last you can draw a tile without creating a renderer.  Truly this is
the future.

Tileset conversion is still incredibly jank, but it does a fairly decent
job (at least at LL -> CC2) without too much custom fiddling yet.
2024-04-25 05:22:18 -06:00
Eevee (Evelyn Woods)
5a17b9022d Arrange the compat flags into categories & show compat icon in main UI 2024-04-24 12:30:59 -06:00
Eevee (Evelyn Woods)
0efbefb999 Politely decline to emulate a bug in TW Lynx 2024-04-24 07:52:11 -06:00
Eevee (Evelyn Woods)
55c4c574ec New MS compat flag: Block splashes don't block the player 2024-04-24 03:32:35 -06:00
Eevee (Evelyn Woods)
df0ab43e70 Add partial support for the mouse move format in TWS files 2024-04-24 03:21:14 -06:00
Eevee (Evelyn Woods)
097a4b04d8 Move Lynx trap ejection to its own mini-step 2024-04-23 02:58:23 -06:00
Eevee (Evelyn Woods)
7e210de5e7 New compat flag for making popwalls actually pop on arrival 2024-04-23 02:56:10 -06:00
Eevee (Evelyn Woods)
991704ee19 Erase animations at decision time, apparently 2024-04-23 02:52:47 -06:00
Eevee (Evelyn Woods)
c5f2728ad0 Fix the Lynx fake-wall flicking behavior 2024-04-23 00:31:56 -06:00
Eevee (Evelyn Woods)
6c3cf8b4b4 Fix DAT files to not insert implicit button connections 2024-04-22 13:57:31 -06:00
Eevee (Evelyn Woods)
1cb92a454d Show the correct replay input when rewinding 2024-04-22 12:49:46 -06:00
Eevee (Evelyn Woods)
430fa5c354 Length connection arrows for adjacent cells 2024-04-22 10:23:22 -06:00
Eevee (Evelyn Woods)
5da2cf14db Give the adjust tool a live preview (still rough) 2024-04-22 10:22:31 -06:00
Eevee (Evelyn Woods)
e7903d5895 Fix mirroring/flipping on ice corners and similar, oops 2024-04-22 10:21:58 -06:00
Eevee (Evelyn Woods)
6a92641d57 Add get_terrain() and get_actor() to StoredCell 2024-04-22 10:21:31 -06:00
Eevee (Evelyn Woods)
90a8f73b93 Tint it slightly pinker... 2024-04-22 10:21:02 -06:00
Eevee (Evelyn Woods)
13918a579f Fix the center point for keyboard zoom 2024-04-22 10:09:25 -06:00
Eevee (Evelyn Woods)
20e2b64390 Update connections after a full-level transform 2024-04-22 10:09:04 -06:00
Eevee (Evelyn Woods)
0a5e5c66c2 Add a rough circuit preview to the wire tool 2024-04-22 09:44:50 -06:00
Eevee (Evelyn Woods)
5f80e880c2 Add shortcuts to zoom in/out and reset the zoom 2024-04-22 09:07:55 -06:00
Eevee (Evelyn Woods)
3a9e7c1cd8 Split the adjust tool into rotate/adjust
It was trying to do too many things.  Also, the adjust tool is now free
to operate on actors, and can toggle the form of a number of them.

- Rearranged the palette to put colored tiles in canonical key order,
  finally

- Expanded the size of the SVG overlay slightly so hover effects don't
  get cut off at the level border

- Fixed some MouseOperation nonsense by simply using the same object
  when the same operation is bound to both mouse buttons

- Added a verb and preview to the adjust tool, in the hopes of making it
  slightly more clear what it might do

- Enhanced the adjust tool to place individual thin walls and frame
  arrows
2024-04-22 00:24:07 -06:00
Eevee (Evelyn Woods)
abbda898c7 Add support for gray buttons to the adjust tool 2024-04-21 03:53:57 -06:00
Eevee (Evelyn Woods)
1170c5970e Fix blank circuit blocks? Although they seemed to work already? 2024-04-21 03:51:44 -06:00
Eevee (Evelyn Woods)
39f0f20dc6 Update implicit button connections when editing, I hope 2024-04-21 02:30:34 -06:00
Eevee (Evelyn Woods)
04d6b3dddb Refactor circuit-tracing to be more in algorithms
This should make it more usable in the editor.
2024-04-21 00:39:23 -06:00
Eevee (Evelyn Woods)
c45ebe60e1 Run replays in reverse order, in the hopes of a teeny speedup 2024-04-21 00:38:34 -06:00
Eevee (Evelyn Woods)
b360fa3998 Change sand slowdown from 100% to 50%, and give it the gravel sound 2024-04-20 03:34:14 -06:00
Eevee (Evelyn Woods)
29fbb56c88 Update grass description 2024-04-20 03:29:29 -06:00
Eevee (Evelyn Woods)
a31c8b8a86 Update icon to the new (current) palette 2024-04-20 03:28:22 -06:00
Eevee (Evelyn Woods)
3dfa9bd361 Continue to fuck around with the color scheme a bit at a time 2024-04-20 03:27:11 -06:00
Eevee (Evelyn Woods)
43d5d65366 Fix loading of c2g zips, oops 2024-04-20 02:56:47 -06:00
Eevee (Evelyn Woods)
0098660d7b Change editor export to use fragments 2024-04-20 02:13:05 -06:00
Eevee (Evelyn Woods)
cd2d28dedd Switch to using fragment; support direct linking to packs and levels
That includes direct loading from GliderBot, though there is no UI for
this at the moment, and the URL is also not updated live.
2024-04-20 01:46:01 -06:00
Eevee (Evelyn Woods)
b6f38f835d Enable zlib compression of exported levels 2024-04-20 01:45:25 -06:00
Eevee (Evelyn Woods)
b44da28020 Try fruitlessly to make c2g parsing more tolerant of mistakes 2024-04-20 01:44:32 -06:00
Eevee (Evelyn Woods)
06ceb827f3 Don't let the player get stuck in an inactive red teleporter 2024-04-19 21:51:34 -06:00
Eevee (Evelyn Woods)
17f4e77054 Fix force-proof players to still bonk on force floors
Fixes the replay of Chaos to Metastable, my white whale!
2024-04-19 21:41:57 -06:00
Eevee (Evelyn Woods)
939c71aab7 Don't die to a monster that was just hooked
This interaction sounds ridiculous but it is real CC2 nonsense.  Fixes
the Hoopla replay!
2024-04-19 00:24:58 -06:00
Eevee (Evelyn Woods)
af57e8a33e Remove the raft from the editor 2024-04-18 02:08:55 -06:00
Eevee (Evelyn Woods)
e3d8a0f669 Fix the floodfill tool 2024-04-18 02:08:17 -06:00
Eevee (Evelyn Woods)
3cf81b53ad Improve the connection tool somewhat; show implicit connections
For example, you can now make connections with the connection tool.
Remarkable.

Unfortunately, implicit connections aren't updated as you edit the level
yet.

Also came with some refactors for searching a level and whatnot.
2024-04-18 00:56:20 -06:00
Eevee (Evelyn Woods)
5e2dfdd926 Allow clicking green buttons in the editor; move cursor into MouseOperation 2024-04-17 20:30:23 -06:00
Eevee (Evelyn Woods)
c624964b76 Oops! Fix calls to blocks_leaving 2024-04-17 19:46:50 -06:00
Eevee (Evelyn Woods)
e9650db4d8 Hardcode green toggles a bit less, and shrink the undo size 2024-04-17 19:46:29 -06:00
Eevee (Evelyn Woods)
5aeeb8a974 Touch up some tile tooltips; rearrange experimental tiles 2024-04-17 03:52:39 -06:00
Eevee (Evelyn Woods)
e11a5956bd Make hearts and mines transmogrify into each other 2024-04-17 03:51:47 -06:00
Eevee (Evelyn Woods)
618f292ec9 Add an xray view and a "matching button" sprite for the sokoban blocks 2024-04-17 03:51:11 -06:00
Eevee (Evelyn Woods)
849010fc75 Add some saturation to the palette
Opinions are mixed, but not mine.  I like this.
2024-04-17 03:50:19 -06:00
Eevee (Evelyn Woods)
2439048f59 Fix transforming selection + add more transforms 2024-04-17 02:24:06 -06:00
Eevee (Evelyn Woods)
ed5f76221b Add support for subtracting from the selection 2024-04-17 01:22:45 -06:00
Eevee (Evelyn Woods)
eaa3bf6965 Spruce up the editor toolbar
Ditch the textured backgrounds (hard to read), add some icons for the
controls, and recolor the icons themselves to the new tentative palette.
2024-04-17 01:09:55 -06:00
Eevee (Evelyn Woods)
ba11e48c7d Highlight the most interesting button in a dialog 2024-04-16 23:58:47 -06:00
Eevee (Evelyn Woods)
7e0c1b0337 Improve the editor's selection tool (slightly WIP)
It now supports arbitrary regions!  The tool itself still makes
rectangles, but you can shift-drag to add to the selection.

It also distinguishes visually between a floating selection and not, is
more easily visible against certain tile backgrounds and at small zoom
levels, and, I don't know, probably some other stuff.
2024-04-16 23:55:35 -06:00
Eevee (Evelyn Woods)
48482b2a65 Recolor the whole thing to pinkish-orange
You know.  Lexy colors.  Seems to make sense.

Also fixed several places I just hated the color scheme, such as the
hover color in popup menus and the title bar in dialogs.  Woohoo.
2024-04-16 23:44:28 -06:00
Eevee (Evelyn Woods)
e1e99e73e7 Fix circuit blocks; distinguish floor wiring from black button wiring 2024-04-16 21:09:46 -06:00
Eevee (Evelyn Woods)
3802b10956 Visually indicate when a floor is in an odd wiring state
This distinguished a regular crossed floor from what you get when
blowing up e.g. a blue or red teleporter.

Fixes #60.
2024-04-16 20:48:50 -06:00
Eevee (Evelyn Woods)
bef5550a95 Make mouse operations always exist, not only while clicking
This allows for multi-eyedrop (where right-clicking the same cell cycles
through everything in that cell) to finally work.

Also fixes #72, I think.
2024-04-16 05:23:56 -06:00
Eevee (Evelyn Woods)
933d20d559 Factor out the list of pushable tiles 2024-04-13 22:06:00 -06:00
Eevee (Evelyn Woods)
cddc274701 Don't let key repeat interfere with the restart timer 2024-04-12 19:03:24 -06:00
Eevee (Evelyn Woods)
fe4c111fa9 Thread the bulk tester
Four threads makes it twice as fast.  Go figure.
2024-04-12 18:29:49 -06:00
Eevee (Evelyn Woods)
a06f53af29 Emit wire pulses in reverse reading order
Fixes the replay for CC2LP1 #67 Before My Very Eyes.
2024-04-12 04:00:37 -06:00
Eevee (Evelyn Woods)
52bc2bdf8e Show the entire blast radius of dynamite 2024-04-12 01:08:16 -06:00
Eevee (Evelyn Woods)
f7b8d3c7bc Whoops I meant to spice up the score, not the label 2024-04-12 00:07:07 -06:00
Eevee (Evelyn Woods)
01dd4eb1a8 Show best score on the level overlay; touch up scorecard and mobile CSS a bit 2024-04-11 23:49:18 -06:00
Eevee (Evelyn Woods)
a3b283b51e Allow holding R (for one second) to restart the level 2024-04-11 23:41:48 -06:00
Eevee (Evelyn Woods)
1df89884ed Implement MS-style instant movement for some reason 2024-04-11 03:50:58 -06:00
Eevee (Evelyn Woods)
2b35dd5bce Swap turntable colors to sort of match teleporters; increase gate margins 2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
ebe848ec99 Fix trap timing on Lynx
CC2's goofy `on_stand` on arrival behavior made them extra extra fast,
which is too fast.
2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
9bf418258f Convert several uses of on_begin to on_ready 2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
65664bba7b Simplify dynamite spawn code 2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
d7e1b969e8 Fix this errant comma and shame myself 2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
fd590f8353 Slightly reduce memory usage (?) for undoing transmutation 2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
f417162f6f Refactor bombs to use on_stand instead of on_begin 2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
25cb6f2f05 Outdent the messy push-handling block in can_actor_enter_cell 2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
f422b4b395 Add a compat flag for pushing a sliding block
The default is now what I /think/ is the Lynx behavior: try to push the
block first, and only give it a pending direction if the push fails.
CC2 always uses the pending mechanism.
2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
f896e1bdfd Fix Lynx's no-backwards-overriding; remove the force-floor-on-arrive flag
Not sure the latter one is even correct at all; it completely breaks
ICEHOUSE, for one.  I guess it made more sense with the previous hacky
implementation of force floors applying at the start of the game.
2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
80edfa1ae9 Clear the pending flags in more sensible places 2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
64ca8f008c Restore standing on arrival
A few CC2LP1 replays desynced, so, I guess this is right actually.
2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
2ee86b50d2 Remove slide_automatically
Since sliding happens either on cell arrival or in the actor's idle
phase, the actor will always have a pending slide by the end of a tic,
so this code doesn't actually do anything.
2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
f140804713 Don't on_stand() on arrival; fix the CC1 force-floor compat flag 2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
a1f357f317 Make sliding be the tiles' responsibility
This seems to simplify things and also explain the CC2 semantics: force
floors activate while being stood on (which happens, I guess, during
idle), so it applies to objects that start the level on force floors.
This was probably done to make force floor flipping work, too.  On the
other hand, ice still only activates when being stepped on.
2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
7ba261c7d9 Fix some style nits; add some comments 2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
7a9e3a6eb1 Check forced movement when stepping on a cell, not during movement
This does simplify things a bit, and it also fixes the replay for CC2LP1
level 160, Sneak Around.  It breaks three Voting replays, unfortunately,
but doesn't break anything else, so I'm inclined to call it better.
2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
b0650e7d6e If you select exactly the compat flags matching a ruleset, highlight that button 2024-04-11 02:24:34 -06:00
Eevee (Evelyn Woods)
6d6f4f7c47 Add CCLP5 2024-04-11 02:24:34 -06:00
Eevee
816b249f67
Merge pull request #92 from chz16/one-way-walls-fix
Add a missing tile encoding spec for one-way walls
2023-03-13 16:49:21 -06:00
Ili Butterfield
50ebd95509 Add a missing tile encoding spec for one-way walls 2022-12-30 23:51:32 -08:00
Eevee (Evelyn Woods)
15a8be1c15 Play sounds very near the player at full volume, but spatialize chip pickups 2021-12-22 23:58:14 -07:00
Eevee (Evelyn Woods)
a088e50b3b Fix a typo; add a suggestion about hard refreshing a broken game 2021-12-22 22:31:37 -07:00
Eevee (Evelyn Woods)
1e02c6aa6f Complete the pgchip ice block emulation (fixes #34) 2021-12-22 22:30:59 -07:00
Eevee (Evelyn Woods)
2c95c7eacd Update MS compat so bugs and walkers still avoid fire (fixes #69) (nice) 2021-12-22 22:09:13 -07:00
Eevee (Evelyn Woods)
b4ebdf069d Add a MegaZeux-like ambient animation to ice tiles 2021-12-22 21:37:46 -07:00
Eevee (Evelyn Woods)
45dbeacc4a Support the old Web Audio API for Firefox's sake 2021-12-22 21:34:30 -07:00
Eevee (Evelyn Woods)
6d580af817 Use the inactive tile for electrified floors by default (fixes #67) 2021-12-22 21:25:01 -07:00
Eevee (Evelyn Woods)
bcbb536bdc Fix a couple bugs with drawing double-size tiles 2021-12-22 21:13:19 -07:00
Eevee (Evelyn Woods)
c8de4edfff Add spatial audio and sound effect captions 2021-12-22 20:55:15 -07:00
Eevee
91a5ab6786
Merge pull request #87 from Techokami/master
Fix missing tooltips in editor
2021-12-22 16:50:21 -07:00
Techokami
4ac01a403f One more missed tooltip 2021-12-18 23:54:43 -05:00
Techokami
9309e9c838 Fix missing tooltips in editor 2021-12-17 19:04:59 -05:00
Eevee (Evelyn Woods)
77afca5799 Fix handling of blocked diagonal movement (fixes #86)
I had it mostly right based on experimentation, but had the conditions
inside-out, which allowed this case to slip through the cracks.  This
makes the Settlement of Arrakis replay sync.
2021-12-03 07:16:59 -07:00
Eevee (Evelyn Woods)
4ebe5c1149 Preserve the color of sokoban blocks on cloning 2021-11-28 22:47:20 -07:00
Eevee (Evelyn Woods)
34e430e8a1 Fix a typo in tileset detection 2021-11-28 22:47:20 -07:00
Eevee
47313521ed
Merge pull request #83 from Patashu/bug-fixes
Experimental tile bug fixes
2021-11-28 22:37:13 -07:00
Timothy Stiles
6f27332cce Cerise doesn't break cracked tiles (because she's dainty)
Teal Knight suggestion. It's a purely backwards compatible way to distinguish the two characters a little more, and fits her theme, but it's up to you.
2021-11-28 18:26:18 +11:00
Timothy Stiles
073aba65ab glass block with a heart shouldn't crash (fixes #84) 2021-11-21 14:47:40 +11:00
Timothy Stiles
71abc13330 let any actor with a key unlock gates (unlike doors) 2021-11-20 12:57:58 +11:00
Timothy Stiles
8feb732a8f boulders are pushed in the movement not facing direction (fixes #81) 2021-11-18 20:53:56 +11:00
Timothy Stiles
a87db67d84 lexy w/ skates and cerise now crack but don't slide on cracked ice (fixes #82) 2021-11-18 20:22:00 +11:00
Timothy Stiles
d675cddafb spaceify 2021-11-18 18:40:15 +11:00
Timothy Stiles
2df4dc5829 fix 'blowing up electric floors doesn't remove the wiring' regression 2021-11-18 18:39:46 +11:00
Timothy Stiles
42d543b235 fix an electric floor visual bug (fixes #80) 2021-11-18 18:35:06 +11:00
Timothy Stiles
94a7ec5a2c dropping 2 ankhs in a row shouldn't crash (fixes #79) 2021-11-18 18:27:28 +11:00
Timothy Stiles
590ecb36ae placing a circuit block on a tile shouldn't crash (fixes #78) 2021-11-18 18:19:57 +11:00
Eevee (Evelyn Woods)
96bc4e0a3c Restore the breathing room when adjusting game scale 2021-06-03 02:22:41 -06:00
Eevee (Evelyn Woods)
51bc3dfe83 Add support for TW large tilesets, real MS tilesets, better tileset detection, and an attempted fix for CC1 thin wall tiles 2021-06-03 02:15:45 -06:00
Eevee (Evelyn Woods)
3e7390ffc0 Fix rendering of actors zooming through traps in Lynx 2021-06-03 02:03:25 -06:00
Eevee (Evelyn Woods)
ca1a48c0fe Fix sokoban buttons to count being pressed at level start 2021-05-26 22:49:29 -06:00
64 changed files with 7976 additions and 3097 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 508 B

After

Width:  |  Height:  |  Size: 522 B

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 B

After

Width:  |  Height:  |  Size: 494 B

Binary file not shown.

BIN
icons/layer-actor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

BIN
icons/layer-all.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 B

BIN
icons/layer-canopy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 B

BIN
icons/layer-item.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 B

BIN
icons/layer-item_mod.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

BIN
icons/layer-swivel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 B

BIN
icons/layer-terrain.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

BIN
icons/layer-thin_wall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 B

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 B

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 B

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 B

After

Width:  |  Height:  |  Size: 421 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 B

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 B

After

Width:  |  Height:  |  Size: 440 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 B

After

Width:  |  Height:  |  Size: 381 B

BIN
icons/tool-ice.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 B

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 B

After

Width:  |  Height:  |  Size: 368 B

BIN
icons/tool-rotate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 B

After

Width:  |  Height:  |  Size: 408 B

BIN
icons/tool-select-wand.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 B

BIN
icons/tool-text.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 B

BIN
icons/tool-thin-walls.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 B

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 488 B

View File

@ -115,6 +115,19 @@
<path d="M1,6 a5,5 0 1,1 10,0 a5,5 0 1,1 -10,0 m2,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0"></path>
<path d="M14,12 l-2,2 -4,-4 2,-2 4,4"></path>
</g>
<g id="svg-icon-mouse1">
<path d="
M9,2 a3,3 0 0,1 3,3 v5 a4,4 0 0,1 -8,0 v-5 a3,3 0 0,1 3,-3 z
M9,3 v5 h-4 v-3 a2,2 0 0,1 2,-2 h1 z
"></path>
<!--M9,3 a2,2 0 0,0 -2,2 v3 h3 z-->
</g>
<g id="svg-icon-mouse2">
<path d="
M9,2 a3,3 0 0,1 3,3 v5 a4,4 0 0,1 -8,0 v-5 a3,3 0 0,1 3,-3 z
M7,3 h2 a2,2 0 0,1 2,2 v3 h-4 v-5 z
"></path>
</g>
</defs>
</svg>
<header id="header-main">
@ -122,7 +135,7 @@
<h1><a href="https://github.com/eevee/lexys-labyrinth">Lexy's Labyrinth</a></h1>
<p>— an <a href="https://github.com/eevee/lexys-labyrinth">open source</a> game by <a href="https://eev.ee/">eevee</a></p>
<nav>
<button id="main-compat" type="button">mode: <output>lexy</output></button>
<button id="main-compat" type="button"><img src="icons/compat-lexy.png" alt=""> <output>lexy</output></button>
<button id="main-options" type="button">options</button>
</nav>
</header>
@ -151,6 +164,7 @@
<h1>oops!</h1>
<p>Sorry, the game was unable to load at all.</p>
<p>If you have JavaScript partly or wholly blocked, I salute you! ...but this is an interactive game and cannot work without it.</p>
<p>If not, it's possible that the game updated, but you have a mix of old and new code. Try a hard refresh (Ctrl-Shift-R).</p>
<p class="-with-error">I did manage to capture this error, which you might be able to <a href="https://github.com/eevee/lexys-labyrinth/issues/new">report somewhere</a>:</p>
<pre class="-with-error stack-trace"></pre>
</main>
@ -202,8 +216,8 @@
<div class="button-row">
<input id="splash-upload-file" type="file" accept=".dat,.ccl,.c2m,.ccs,.zip" multiple>
<input id="splash-upload-dir" type="file" webkitdirectory>
<button type="button" id="splash-upload-file-button" class="button-big">Load files</button>
<button type="button" id="splash-upload-dir-button" class="button-big">Load directory</button>
<button type="button" id="splash-upload-file-button" class="button-big button-bright">Load files</button>
<button type="button" id="splash-upload-dir-button" class="button-big button-bright">Load directory</button>
</div>
<ul class="played-pack-list" id="splash-other-pack-list">
<!-- populated by js -->
@ -213,8 +227,8 @@
<section id="splash-your-levels">
<h2>Create</h2>
<div class="button-row">
<button type="button" id="splash-create-pack" class="button-big">New pack</button>
<button type="button" id="splash-create-level" class="button-big">New scratch level<br>(won't be saved!)</button>
<button type="button" id="splash-create-pack" class="button-big button-bright">New pack</button>
<button type="button" id="splash-create-level" class="button-big button-bright">New scratch level<br>(won't be saved!)</button>
</div>
</section>
</main>
@ -226,8 +240,7 @@
<span class="-optional-label">pause</span> <span class="keyhint"><kbd>p</kbd></span></button>
<button class="control-restart" type="button" title="restart">
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M13,13 A 7,7 270 1,1 13,3 L15,1 15,7 9,7 11,5 A 4,4 270 1,0 11,11 z"></path></svg>
<span class="-optional-label">retry</span>
</button>
<span class="-optional-label">retry</span> <span class="keyhint"><kbd>r</kbd></span></button>
<button class="control-undo" type="button" title="undo">
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M6,5 6,2 1,7 6,12 6,9 A 10,10 60 0,1 15,12 A 10,10 90 0,0 6,5"></path></svg>
<span class="-optional-label">undo</span> <span class="keyhint"><kbd>u</kbd></span></button>
@ -252,6 +265,7 @@
<section id="player-game-area">
<div class="level"><!-- level canvas and any overlays go here --></div>
<div class="player-overlay-message"></div>
<div class="player-overlay-captions"></div>
<div class="player-hint-wrapper">
<div class="player-hint"></div>
<svg class="player-hint-bg-icon svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-hint"></use></svg>

View File

@ -1,9 +1,95 @@
import { DIRECTIONS, DIRECTION_ORDER } from './defs.js';
import { DIRECTIONS, LAYERS } from './defs.js';
export function trace_floor_circuit(level, start_cell, start_edge, on_wire, on_dead_end) {
// Iterates over every terrain tile in the grid that has one of the given types (a Set of type
// names), in linear order, optionally in reverse. The starting cell is checked last.
// Yields [tile, cell].
export function* find_terrain_linear(levelish, start_cell, type_names, reverse = false) {
let i = levelish.coords_to_scalar(start_cell.x, start_cell.y);
while (true) {
if (reverse) {
i -= 1;
if (i < 0) {
i += levelish.size_x * levelish.size_y;
}
}
else {
i += 1;
i %= levelish.size_x * levelish.size_y;
}
let cell = levelish.linear_cells[i];
let tile = cell[LAYERS.terrain];
if (tile && type_names.has(tile.type.name)) {
yield [tile, cell];
}
if (cell === start_cell)
return;
}
}
// Iterates over every terrain tile in the grid that has one of the given types (a Set of type
// names), spreading outward in a diamond pattern. The starting cell is not included.
// Only used by orange buttons.
// Yields [tile, cell].
export function* find_terrain_diamond(levelish, start_cell, type_names) {
// Note that this won't search the entire level in all cases, but it does match CC2 behavior.
// Not worth a compat flag since it only affects level design, and fairly perversely
let max_search_radius = Math.max(levelish.size_x, levelish.size_y) + 1;
for (let dist = 1; dist <= max_search_radius; dist++) {
// Start east and move counterclockwise
let sx = start_cell.x + dist;
let sy = start_cell.y;
for (let direction of [[-1, -1], [-1, 1], [1, 1], [1, -1]]) {
for (let i = 0; i < dist; i++) {
let cell = levelish.cell(sx, sy);
sx += direction[0];
sy += direction[1];
if (! cell)
continue;
let terrain = cell[LAYERS.terrain];
if (type_names.has(terrain.type.name)) {
yield [terrain, cell];
}
}
}
}
}
export const CONNECTION_FUNCTIONS = {
forward: find_terrain_linear,
diamond: find_terrain_diamond,
};
export class Circuit {
constructor() {
this.is_powered = null;
this.tiles = new Map;
this.inputs = new Map;
}
add_tile_edge(tile, edgebits) {
this.tiles.set(tile, (this.tiles.get(tile) ?? 0) | edgebits);
}
add_input_edge(tile, edgebits) {
this.inputs.set(tile, (this.inputs.get(tile) ?? 0) | edgebits);
}
}
// Traces a wire circuit and calls the given callbacks when finding either a new wire or an ending.
// actor_mode describes how to handle circuit blocks:
// - still: Actor wires are examined only for actors with a zero cooldown. (Normal behavior.)
// - always: Actor wires are always examined. (compat.tiles_react_instantly behavior.)
// - ignore: Skip actors entirely. (Editor behavior.)
// Returns a Circuit.
export function trace_floor_circuit(levelish, actor_mode, start_cell, start_edge, on_wire, on_dead_end) {
let is_first = true;
let pending = [[start_cell, start_edge]];
let seen_cells = new Map;
let circuit = new Circuit;
while (pending.length > 0) {
let next = [];
for (let [cell, edge] of pending) {
@ -15,45 +101,65 @@ export function trace_floor_circuit(level, start_cell, start_edge, on_wire, on_d
let seen_edges = seen_cells.get(cell) ?? 0;
if (seen_edges & edgeinfo.bit)
continue;
let tile = terrain;
let actor = cell.get_actor();
let wire_directions = terrain.wire_directions;
if ((actor?.wire_directions ?? null !== null) && (actor.movement_cooldown === 0 || level.compat.tiles_react_instantly))
if (actor && actor.type.contains_wire && (
(actor_mode === 'still' && actor.movement_cooldown === 0) || actor_mode === 'always'))
{
wire_directions = actor.wire_directions;
tile = actor;
}
// The wire comes in from this edge towards the center; see how it connects within this
// cell, then check for any neighbors
let connections = edgeinfo.bit;
if (! is_first && ((wire_directions ?? 0) & edgeinfo.bit) === 0) {
// There's not actually a wire here (but not if this is our starting cell, in which
// case we trust the caller)
if (on_dead_end) {
on_dead_end(terrain.cell, edge);
let mode = tile.wire_propagation_mode ?? tile.type.wire_propagation_mode;
if (! is_first && ((tile.wire_directions ?? 0) & edgeinfo.bit) === 0) {
// There's not actually a wire here, so check for things that respond to receiving
// power... but if this is the starting cell, we trust the caller and skip it (XXX why)
for (let tile2 of cell) {
if (! tile2)
continue;
if (tile2.type.name === 'logic_gate') {
// Logic gates are technically not wired, but still attached to
// circuits, mostly so blue teleporters can follow them
let wire = tile2.type._gate_types[tile2.gate_type][
(DIRECTIONS[edge].index - DIRECTIONS[tile2.direction].index + 4) % 4];
if (! wire)
continue;
circuit.add_tile_edge(tile2, DIRECTIONS[edge].bit);
if (wire.match(/^out/)) {
circuit.add_input_edge(tile2, DIRECTIONS[edge].bit);
}
}
else if (tile2.type.on_power) {
circuit.add_tile_edge(tile2, DIRECTIONS[edge].bit);
}
}
continue;
}
else if (terrain.type.wire_propagation_mode === 'none') {
else if (mode === 'none') {
// The wires in this tile never connect to each other
}
else if (terrain.type.wire_propagation_mode === 'cross' ||
(wire_directions === 0x0f && terrain.type.wire_propagation_mode !== 'all'))
{
else if (mode === 'cross' || (mode === 'autocross' && tile.wire_directions === 0x0f)) {
// This is a cross pattern, so only opposite edges connect
if (wire_directions & edgeinfo.opposite_bit) {
if (tile.wire_directions & edgeinfo.opposite_bit) {
connections |= edgeinfo.opposite_bit;
}
}
else {
// Everything connects
connections |= wire_directions;
connections |= tile.wire_directions;
}
seen_cells.set(cell, seen_edges | connections);
if (on_wire) {
on_wire(terrain, connections);
circuit.add_tile_edge(tile, connections);
if (tile.type.update_power_emission) {
// TODO could just do this in a pass afterwards?
circuit.add_input_edge(tile, connections);
}
for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) {
@ -67,10 +173,12 @@ export function trace_floor_circuit(level, start_cell, start_edge, on_wire, on_d
let neighbor;
if ((terrain.wire_tunnel_directions ?? 0) & dirinfo.bit) {
// Search in this direction for a matching tunnel
neighbor = find_matching_wire_tunnel(level, cell.x, cell.y, direction);
// Note that while actors (the fuckin circuit block) can be wired, tunnels ONLY
// appear on terrain, and are NOT affected by actors on top
neighbor = find_matching_wire_tunnel(levelish, cell.x, cell.y, direction);
}
else {
neighbor = level.get_neighboring_cell(cell, direction);
neighbor = levelish.get_neighboring_cell(cell, direction);
}
/*
@ -88,16 +196,18 @@ export function trace_floor_circuit(level, start_cell, start_edge, on_wire, on_d
pending = next;
is_first = false;
}
return circuit;
}
export function find_matching_wire_tunnel(level, x, y, direction) {
export function find_matching_wire_tunnel(levelish, x, y, direction) {
let dirinfo = DIRECTIONS[direction];
let [dx, dy] = dirinfo.movement;
let nesting = 0;
while (true) {
x += dx;
y += dy;
let candidate = level.cell(x, y);
let candidate = levelish.cell(x, y);
if (! candidate)
return null;
@ -118,28 +228,3 @@ export function find_matching_wire_tunnel(level, x, y, direction) {
}
}
}
// TODO make this guy work generically for orange, red, brown buttons? others...?
export function find_implicit_connection() {
}
// Iterates over a grid in a diamond pattern, spreading out from the given start cell (but not
// including it). Only used for connecting orange buttons.
export function* iter_cells_in_diamond(levelish, x0, y0) {
let max_search_radius = Math.max(levelish.size_x, levelish.size_y) + 1;
for (let dist = 1; dist <= max_search_radius; dist++) {
// Start east and move counterclockwise
let sx = x0 + dist;
let sy = y0;
for (let direction of [[-1, -1], [-1, 1], [1, 1], [1, -1]]) {
for (let i = 0; i < dist; i++) {
let cell = levelish.cell(sx, sy);
if (cell) {
yield cell;
}
sx += direction[0];
sy += direction[1];
}
}
}
}

View File

@ -135,178 +135,198 @@ export const COMPAT_RULESET_LABELS = {
};
export const COMPAT_RULESET_ORDER = ['lexy', 'steam', 'steam-strict', 'lynx', 'ms', 'custom'];
// FIXME some of the names of the flags themselves kinda suck
export const COMPAT_FLAGS = [
// Level loading
// TODO? /strictly/ speaking, these should be turned on for lynx+ms/lynx respectively, but then i'd
// have to also alter the behavior of the corresponding terrain, which seems kind of silly
{
key: 'no_auto_convert_ccl_popwalls',
label: "Recessed walls under actors in CCL levels are left alone",
rulesets: new Set(['steam-strict']),
// TODO some ms compat things that wouldn't be too hard to add:
// - walkers choose a random /unblocked/ direction, not just a random direction
// - (boosting) player cooldown is /zero/ after ending a slide
// - cleats allow walking through ice corner walls while standing on them
// - blocks can be pushed through thin walls + ice corners
export const COMPAT_FLAG_CATEGORIES = [{
title: "Level loading",
flags: [{
key: 'no_auto_convert_ccl_popwalls',
label: "Recessed walls under actors are not auto-converted in CCL levels",
rulesets: new Set(['steam-strict', 'lynx', 'ms']),
}, {
key: 'no_auto_convert_ccl_blue_walls',
label: "Blue walls under blocks are not auto-converted in CCL levels",
rulesets: new Set(['steam-strict', 'lynx', 'ms']),
}, {
key: 'no_auto_convert_ccl_bombs',
label: "Mines under actors are not auto-converted in CCL levels",
rulesets: new Set(['steam-strict', 'lynx', 'ms']),
}],
}, {
key: 'no_auto_convert_ccl_blue_walls',
label: "Blue walls under blocks in CCL levels are left alone",
rulesets: new Set(['steam-strict']),
},
// Core
{
key: 'allow_double_cooldowns',
label: "Actors may cooldown twice in one tic",
rulesets: new Set(['steam', 'steam-strict', 'lynx']),
title: "Actor behavior",
flags: [{
key: 'emulate_60fps',
label: "Actors update at 60 FPS",
rulesets: new Set(['steam', 'steam-strict']),
}, {
key: 'no_separate_idle_phase',
label: "Actors teleport immediately after moving",
rulesets: new Set(['steam', 'steam-strict']),
}, {
key: 'allow_double_cooldowns',
label: "Actors may move forwards twice in one tic",
rulesets: new Set(['steam', 'steam-strict', 'lynx']),
}, {
key: 'player_moves_last',
label: "Players always update last",
rulesets: new Set(['lynx']),
}, {
key: 'reuse_actor_slots',
label: "New actors reuse slots in the actor list",
rulesets: new Set(['lynx']),
}, {
key: 'player_protected_by_items',
label: "Players can't be trampled while standing on items",
rulesets: new Set(['lynx']),
}, {
key: 'force_lynx_animation_lengths',
label: "Animations play at their slower Lynx duration",
rulesets: new Set(['lynx']),
}, {
// Note that this requires no_early_push as well
key: 'player_safe_at_decision_time',
label: "Players can't be trampled at decision time",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'bonking_isnt_instant',
label: "Bonking while sliding doesn't apply instantly",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'actors_move_instantly',
label: "Movement is instant",
rulesets: new Set(['ms']),
}],
}, {
key: 'no_separate_idle_phase',
label: "Actors teleport immediately after moving",
rulesets: new Set(['steam', 'steam-strict']),
title: "Monsters",
flags: [{
// TODO ms needs "player doesn't block monsters", but tbh that's kind of how it should work
// anyway, especially in combination with the ankh
// TODO? in lynx they ignore the button while in motion too
// TODO what about in a trap, in every game??
// TODO what does ms do when a tank is on ice or a ff? wiki's description is wacky
// TODO yellow tanks seem to have memory too??
key: 'tanks_always_obey_button',
label: "Blue tanks obey blue buttons even on clone machines",
rulesets: new Set(['steam-strict']),
}, {
key: 'tanks_ignore_button_while_moving',
label: "Blue tanks ignore blue buttons while moving",
rulesets: new Set(['lynx']),
}, {
key: 'blobs_use_tw_prng',
label: "Blobs use the Lynx RNG",
rulesets: new Set(['lynx']),
}, {
key: 'teeth_target_internal_position',
label: "Teeth pursue the cell the player is moving into",
rulesets: new Set(['lynx']),
}, {
key: 'rff_blocks_monsters',
label: "Monsters cannot step on random force floors",
rulesets: new Set(['ms']),
}, {
key: 'fire_allows_most_monsters',
label: "Monsters can walk into fire, except for bugs and walkers",
rulesets: new Set(['ms']),
}],
}, {
key: 'player_moves_last',
label: "Player always moves last",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'player_protected_by_items',
label: "Players can't be trampled when standing on items",
rulesets: new Set(['lynx']),
}, {
// Note that this requires no_early_push as well
key: 'player_safe_at_decision_time',
label: "Players can't be trampled at decision time",
rulesets: new Set(['lynx']),
}, {
key: 'emulate_60fps',
label: "Game runs at 60 FPS",
rulesets: new Set(['steam', 'steam-strict']),
}, {
key: 'reuse_actor_slots',
label: "Game reuses slots in the actor list",
rulesets: new Set(['lynx']),
}, {
key: 'force_lynx_animation_lengths',
label: "Animations use Lynx duration",
rulesets: new Set(['lynx']),
},
// Tiles
{
// XXX this is goofy
key: 'tiles_react_instantly',
label: "Tiles react when approached",
rulesets: new Set(['ms']),
}, {
key: 'rff_actually_random',
label: "Random force floors are actually random",
rulesets: new Set(['ms']),
}, {
key: 'no_backwards_override',
label: "Player can't override backwards on a force floor",
rulesets: new Set(['lynx']),
}, {
key: 'force_floors_inert_on_first_tic',
label: "Force floors don't trigger on the first tic",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'traps_like_lynx',
label: "Traps eject faster, and even when already open",
rulesets: new Set(['lynx']),
}, {
key: 'blue_floors_vanish_on_arrive',
label: "Fake blue walls vanish on arrival",
rulesets: new Set(['lynx']),
}, {
key: 'green_teleports_can_fail',
label: "Green teleporters sometimes fail",
rulesets: new Set(['steam-strict']),
},
// Items
{
key: 'no_immediate_detonate_bombs',
label: "Mines under non-player actors don't explode at level start",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'detonate_bombs_under_players',
label: "Mines under players explode at level start",
rulesets: new Set(['steam', 'steam-strict']),
}, {
key: 'cloned_bowling_balls_can_be_lost',
label: "Bowling balls on cloners are destroyed when fired at point blank",
rulesets: new Set(['steam-strict']),
}, {
key: 'monsters_ignore_keys',
label: "Monsters completely ignore keys",
rulesets: new Set(['ms']),
},
// Blocks
{
key: 'no_early_push',
label: "Pushing blocks happens at move time",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'use_legacy_hooking',
label: "Pulling blocks with the hook happens at decision time",
rulesets: new Set(['steam', 'steam-strict']),
}, {
// FIXME this is kind of annoying, there are some collision rules too
key: 'tanks_teeth_push_ice_blocks',
label: "Ice blocks emulate pgchip rules",
rulesets: new Set(['ms']),
}, {
key: 'allow_pushing_blocks_off_faux_walls',
label: "Blocks may be pushed off of blue (fake), invisible, and revealing walls",
rulesets: new Set(['lynx']),
}, {
key: 'emulate_spring_mining',
label: "Spring mining is possible",
rulesets: new Set(['steam-strict']),
title: "Blocks",
flags: [{
key: 'use_legacy_hooking',
label: "Pulling blocks with the hook happens earlier, and may prevent moving",
rulesets: new Set(['steam', 'steam-strict']),
}, {
key: 'no_directly_pushing_sliding_blocks',
label: "Pushing sliding blocks queues a move, rather than moving them right away",
rulesets: new Set(['steam', 'steam-strict']),
}, {
key: 'emulate_spring_mining',
label: "Pushing a block off a recessed wall may cause you to move into the resulting wall",
rulesets: new Set(['steam-strict']),
}, {
key: 'no_early_push',
label: "Pushing blocks happens at move time (block slapping is disabled)",
// XXX wait but the DEFAULT behavior allows block slapping, which lynx has, so why is lynx listed here?
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'use_pgchip_ice_blocks',
label: "Ice blocks use pgchip rules",
rulesets: new Set(['ms']),
}, {
key: 'allow_pushing_blocks_off_faux_walls',
label: "Blocks may be pushed off of blue (fake), invisible, and revealing walls",
rulesets: new Set(['lynx']),
}, {
key: 'block_splashes_dont_block',
label: "Block splashes don't block the player",
rulesets: new Set(['ms']),
/* XXX not implemented
}, {
key: 'emulate_flicking',
label: "Flicking is possible",
rulesets: new Set(['ms']),
*/
},
}],
}, {
title: "Terrain",
flags: [{
key: 'green_teleports_can_fail',
label: "Green teleporters sometimes fail",
rulesets: new Set(['steam-strict']),
}, {
key: 'no_backwards_override',
label: "Players can't override backwards on a force floor",
rulesets: new Set(['lynx']),
}, {
key: 'traps_like_lynx',
label: "Traps eject faster, and eject when already open",
rulesets: new Set(['lynx']),
}, {
key: 'blue_floors_vanish_on_arrive',
label: "Fake blue walls vanish when stepped on",
rulesets: new Set(['lynx']),
}, {
key: 'popwalls_pop_on_arrive',
label: "Recessed walls activate when stepped on",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'rff_actually_random',
label: "Random force floors are actually random",
rulesets: new Set(['ms']),
}],
}, {
title: "Items",
flags: [{
key: 'cloned_bowling_balls_can_be_lost',
label: "Bowling balls on cloners are destroyed when fired at point blank",
rulesets: new Set(['steam-strict']),
}, {
// XXX is this necessary, with the addition of the dormant bomb?
key: 'bombs_immediately_detonate_under_players',
label: "Mines under players detonate when the level starts",
rulesets: new Set(['steam-strict']),
}, {
key: 'bombs_detonate_on_arrive',
label: "Mines detonate only when stepped on",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'monsters_ignore_keys',
label: "Monsters completely ignore keys",
rulesets: new Set(['ms']),
}],
}];
// Monsters
{
// TODO? in lynx they ignore the button while in motion too
// TODO what about in a trap, in every game??
// TODO what does ms do when a tank is on ice or a ff? wiki's description is wacky
// TODO yellow tanks seem to have memory too??
key: 'tanks_always_obey_button',
label: "Blue tanks always obey blue buttons",
rulesets: new Set(['steam-strict']),
}, {
key: 'tanks_ignore_button_while_moving',
label: "Blue tanks ignore blue buttons while moving",
rulesets: new Set(['lynx']),
}, {
key: 'blobs_use_tw_prng',
label: "Blobs use the Tile World RNG",
rulesets: new Set(['lynx']),
}, {
key: 'teeth_target_internal_position',
label: "Teeth target the player's internal position",
rulesets: new Set(['lynx']),
}, {
key: 'rff_blocks_monsters',
label: "Random force floors block monsters",
rulesets: new Set(['ms']),
}, {
key: 'bonking_isnt_instant',
label: "Bonking while sliding doesn't apply instantly",
rulesets: new Set(['lynx', 'ms']),
}, {
key: 'fire_allows_monsters',
label: "Fire doesn't block monsters",
rulesets: new Set(['ms']),
},
];
export function compat_flags_for_ruleset(ruleset) {
let compat = {};
for (let compatdef of COMPAT_FLAGS) {
if (compatdef.rulesets.has(ruleset)) {
compat[compatdef.key] = true;
for (let category of COMPAT_FLAG_CATEGORIES) {
for (let compatdef of category.flags) {
if (compatdef.rulesets.has(ruleset)) {
compat[compatdef.key] = true;
}
}
}
return compat;

View File

@ -197,7 +197,7 @@ export class EditorLevelMetaOverlay extends DialogOverlay {
let size_x = Math.max(1, Math.min(100, parseInt(els.size_x.value, 10)));
let size_y = Math.max(1, Math.min(100, parseInt(els.size_y.value, 10)));
if (size_x !== stored_level.size_x || size_y !== stored_level.size_y) {
this.conductor.editor.resize_level(size_x, size_y);
this.conductor.editor.crop_level(0, 0, size_x, size_y);
}
stored_level.blob_behavior = parseInt(els.blob_behavior.value, 10);

View File

@ -7,8 +7,21 @@ export const TOOLS = {
pencil: {
icon: 'icons/tool-pencil.png',
name: "Pencil",
desc: "Place, erase, and select tiles.\nLeft click: draw\nCtrl: erase\nShift: replace all layers\nRight click: pick foreground tile\nCtrl-right click: pick background tile",
desc: [
"Place, erase, and select tiles.",
"Picks the top-most tile by default.",
"Use the layer selector to pick specific tiles.",
"",
"[mouse1] Draw",
"[shift] [mouse1] Draw, replacing entire cell",
"[ctrl] [mouse1] Erase (terrain becomes background)",
"[ctrl] [shift] [mouse1] Erase entire cell",
"",
"[mouse2] Pick foreground tile",
"[ctrl] [mouse2] Pick background tile",
].join("\n"),
uses_palette: true,
uses_layers: true,
op1: mouseops.PencilOperation,
op2: mouseops.EyedropOperation,
shortcut: 'b',
@ -19,7 +32,10 @@ export const TOOLS = {
name: "Line",
desc: "Draw straight lines",
uses_palette: true,
uses_layers: undefined,
shortcut: 'l',
op1: mouseops.LineOperation,
op2: mouseops.EyedropOperation,
},
box: {
// TODO not implemented
@ -27,13 +43,27 @@ export const TOOLS = {
name: "Box",
desc: "Fill a rectangular area with tiles",
uses_palette: true,
uses_layers: undefined,
shortcut: 'u',
},
fill: {
icon: 'icons/tool-fill.png',
name: "Fill",
desc: "Flood-fill an area with tiles",
desc: [
"Flood-fill an area with the current tile.",
"By default, fills the traversable region within the same layer.",
"Use the layer selector to floodfill within a specific layer.",
"",
"[mouse1] Floodfill",
"[ctrl] [mouse1] Floodfill, ignoring traversability",
"[shift] [mouse1] Fill all matching tiles in the entire level",
"",
"[mouse2] Pick foreground tile",
// TODO override the traversable part? ctrl?
// TODO fill all similar tiles instead? shift?
].join("\n"),
uses_palette: true,
uses_layers: undefined,
op1: mouseops.FillOperation,
op2: mouseops.EyedropOperation,
shortcut: 'g',
@ -41,28 +71,140 @@ export const TOOLS = {
select_box: {
icon: 'icons/tool-select-box.png',
name: "Box select",
desc: "Select and manipulate rectangles.",
desc: [
"Select and manipulate rectangles.",
"",
"[mouse1] Select rectangle",
"[shift] [mouse1] Add to selection",
"[ctrl] [mouse1] Remove from selection",
"",
"[mouse1] Move selection",
"[ctrl] [mouse1] Clone selection",
].join("\n"),
affects_selection: true,
uses_layers: undefined,
op1: mouseops.SelectOperation,
shortcut: 'm',
},
select_wand: {
icon: 'icons/tool-select-wand.png',
name: "Wand select",
desc: [
"Select regions of similar tiles.",
"",
"[mouse1] Select contiguous similar tiles",
"[mouse2] Select all similar tiles",
"[shift] Add to selection",
"[ctrl] Remove from selection",
"",
"[mouse1] Move selection",
"[ctrl] [mouse1] Clone selection",
].join("\n"),
affects_selection: true,
uses_layers: true,
op1: mouseops.WandSelectOperation,
shortcut: 'w',
},
'force-floors': {
icon: 'icons/tool-force-floors.png',
name: "Force floors",
desc: "Draw force floors following the cursor.",
uses_layers: false,
min_version: 'cc1',
op1: mouseops.ForceFloorOperation,
},
ice: {
icon: 'icons/tool-ice.png',
name: "Ice",
desc: [
"Draw ice following the cursor.",
"",
"[mouse1] Lay ice",
].join("\n"),
uses_layers: false,
min_version: 'cc1',
op1: mouseops.IceOperation,
op2: mouseops.IceOperation,
},
tracks: {
icon: 'icons/tool-tracks.png',
name: "Tracks",
desc: "Draw tracks following the cursor.\nLeft click: Lay tracks\nCtrl-click: Erase tracks\nRight click: Toggle track switch",
desc: [
"Draw tracks following the cursor.",
"",
"[mouse1] Lay tracks",
"[ctrl] [mouse1] Erase tracks",
"[mouse2] Toggle track switch",
].join("\n"),
uses_layers: false,
min_version: 'cc2',
op1: mouseops.TrackOperation,
op2: mouseops.TrackOperation,
},
text: {
icon: 'icons/tool-text.png',
name: "Text",
desc: [
"Type text directly onto the floor.",
"",
"[mouse1] Move cursor",
].join("\n"),
uses_layers: false,
min_version: 'cc2',
op1: mouseops.TextOperation,
op2: mouseops.TextOperation,
},
thin_walls: {
icon: 'icons/tool-thin-walls.png',
name: "Thin walls",
desc: [
"Draw thin walls by dragging along the edges of cells.",
"",
"[mouse1] Draw thin walls",
"[mouse2] Draw one-way walls (LL only)",
"[ctrl] Erase",
].join("\n"),
uses_layers: false,
op1: mouseops.ThinWallOperation,
op2: mouseops.ThinWallOperation,
},
// TODO this is so clumsy. maybe right-click to cycle target, like pencil? i don't know. that
// seems annoying for piercing through a lot of thin walls
// TODO you can't shift-mouse2 in firefox also, it brings up the real context menu
rotate: {
icon: 'icons/tool-rotate.png',
name: "Rotate",
desc: [
"Rotate existing tiles.",
"Works on both actors and orientable terrain.",
"Affects the top-most rotatable tile by default.",
"Use the layer selector to affect specific tiles.",
"",
"[mouse1] Rotate clockwise",
"[mouse2] Rotate counter-clockwise",
].join("\n"),
uses_layers: new Set(['terrain', 'actor']),
op1: mouseops.RotateOperation,
op2: mouseops.RotateOperation,
shortcut: 'r',
},
adjust: {
icon: 'icons/tool-adjust.png',
name: "Adjust",
desc: "Edit existing tiles.\nLeft click: rotate actor or toggle terrain\nRight click: rotate or toggle in reverse\nShift: always target terrain\nCtrl-click: edit properties of complex tiles\n(wires, railroads, hints, etc.)",
desc: [
"Inspect and alter tiles in a variety of ways:",
"• Transmogrify tiles (including terrain)",
"• Edit letter tiles or hint text",
"• Change frame block arrows, track directions",
"• Preview or press buttons",
"• Edit thin walls",
"Affects the top-most adjustable tile by default.",
"Use the layer selector to affect specific tiles.",
"",
"[mouse1] Adjust tile",
//"[mouse2] Adjust tile backwards",
].join("\n"),
uses_layers: true,
op1: mouseops.AdjustOperation,
op2: mouseops.AdjustOperation,
shortcut: 'a',
@ -70,31 +212,48 @@ export const TOOLS = {
connect: {
icon: 'icons/tool-connect.png',
name: "Connect",
// XXX shouldn't you be able to drag the destination?
// TODO mod + right click for RRO or diamond alg? ah but we only have ctrl available
// ok lemme think then
// left drag: create a new connection (supported connections only)
// ctrl-click: erase all connections
// shift-drag: create a new connection (arbitrary cells)
// right drag: move a connection endpoint
// ctrl-right drag: move the other endpoint (if a cell is both source and dest)
desc: "Set up CC1-style clone and trap connections.\n(WIP)\nNOTE: Not supported in CC2!\nRight click: auto link using Lynx rules",
//desc: "Set up CC1-style clone and trap connections.\nNOTE: Not supported in CC2!\nLeft drag: link button with valid target\nCtrl-click: erase link\nRight click: auto link using Lynx rules",
desc: [
"Set up CC1-style clone and trap connections.",
"(Supported in CC1 and LL, but not CC2!)",
"",
"[mouse1] Connect a button to a mechanism",
"[mouse1] Move existing connections",
"[ctrl] [mouse1] Delete connection",
"[shift] [mouse1] Allow connecting to any cell",
"(not recommended)",
"[mouse2] Auto link a button using Lynx/CC2 rules",
].join("\n"),
uses_layers: false,
op1: mouseops.ConnectOperation,
op2: mouseops.ConnectOperation,
},
wire: {
icon: 'icons/tool-wire.png',
name: "Wire",
desc: "Edit CC2 wiring.\nLeft click: draw wires\nCtrl-click: erase wires\nRight click: toggle tunnels (floor only)",
desc: [
"Edit CC2 wiring.",
"",
"[mouse1] Draw wire",
"[ctrl] [mouse1] Erase wire",
"",
"[mouse2] Toggle tunnels (floor only)",
].join("\n"),
uses_layers: true,
op1: mouseops.WireOperation,
op2: mouseops.WireOperation,
},
camera: {
icon: 'icons/tool-camera.png',
name: "Camera",
desc: "Draw and edit custom camera regions",
help: "Draw and edit camera regions.\n(LL only. When the player is within a camera region,\nthe camera stays locked inside it.)\nLeft click: create or edit a region\nRight click: erase a region",
desc: [
"Draw and edit camera regions.",
"(LL only. When the player is within a camera region,",
"the camera stays locked inside it.)",
"",
"[mouse1] Create or edit a region",
"[mouse2] Delete a region",
].join("\n"),
uses_layers: false,
op1: mouseops.CameraOperation,
op2: mouseops.CameraEraseOperation,
},
@ -103,7 +262,14 @@ export const TOOLS = {
// slade when you have some selected?
// TODO ah, railroads...
};
export const TOOL_ORDER = ['pencil', 'select_box', 'fill', 'adjust', 'force-floors', 'tracks', 'connect', 'wire', 'camera'];
export const TOOL_ORDER = [
'pencil', 'line', 'box', 'fill',
'select_box', 'select_wand',
'rotate', 'adjust',
'force-floors', 'ice', 'tracks', 'text', 'thin_walls',
'wire', 'connect',
'camera',
];
export const TOOL_SHORTCUTS = {};
for (let [tool, tooldef] of Object.entries(TOOLS)) {
if (tooldef.shortcut) {
@ -111,6 +277,9 @@ for (let [tool, tooldef] of Object.entries(TOOLS)) {
}
}
export const SELECTABLE_LAYERS = [null, 'terrain', 'item', 'item_mod', 'actor', 'swivel', 'thin_wall', 'canopy'];
export const SELECTABLE_LAYER_NAMES = ["auto", "terrain", "items", "item mods", "actors", "swivels", "thin walls", "canopies"];
// TODO this MUST use a LL tileset!
export const PALETTE = [{
title: "Basics",
@ -140,10 +309,10 @@ export const PALETTE = [{
'no_player1_sign',
'no_player2_sign',
'floor_custom_green', 'floor_custom_pink', 'floor_custom_yellow', 'floor_custom_blue',
'wall_custom_green', 'wall_custom_pink', 'wall_custom_yellow', 'wall_custom_blue',
'floor_custom_pink', 'floor_custom_blue', 'floor_custom_yellow', 'floor_custom_green',
'wall_custom_pink', 'wall_custom_blue', 'wall_custom_yellow', 'wall_custom_green',
'door_blue', 'door_red', 'door_yellow', 'door_green',
'door_red', 'door_blue', 'door_yellow', 'door_green',
'swivel_nw',
'railroad/straight',
'railroad/curve',
@ -157,8 +326,8 @@ export const PALETTE = [{
}, {
title: "Items",
tiles: [
'key_blue', 'key_red', 'key_yellow', 'key_green',
'flippers', 'fire_boots', 'cleats', 'suction_boots',
'key_red', 'key_blue', 'key_yellow', 'key_green',
'cleats', 'suction_boots', 'fire_boots', 'flippers',
'hiking_boots', 'speed_boots', 'lightning_bolt', 'railroad_sign',
'helmet', 'foil', 'hook', 'xray_eye',
'bribe', 'bowling_ball', 'dynamite', 'no_sign',
@ -173,8 +342,8 @@ export const PALETTE = [{
'walker',
'fireball',
'glider',
'bug',
'paramecium',
'bug',
'doppelganger1',
'doppelganger2',
@ -211,10 +380,10 @@ export const PALETTE = [{
'button_orange', 'flame_jet_off', 'flame_jet_on',
'transmogrifier',
'teleport_blue',
'teleport_red',
'teleport_green',
'teleport_blue',
'teleport_yellow',
'teleport_green',
'stopwatch_bonus',
'stopwatch_penalty',
'stopwatch_toggle',
@ -235,6 +404,7 @@ export const PALETTE = [{
'logic_gate/latch-cw',
'logic_gate/latch-ccw',
'logic_gate/counter',
'button_pink',
'button_black',
'light_switch_off',
@ -246,41 +416,46 @@ export const PALETTE = [{
}, {
title: "Experimental",
tiles: [
'circuit_block/xxx',
'gift_bow',
'skeleton_key',
'sokoban_block/red',
'sokoban_block/blue',
'sokoban_block/yellow',
'sokoban_block/green',
'sokoban_button/red',
'sokoban_button/blue',
'sokoban_button/yellow',
'sokoban_button/green',
'sokoban_wall/red',
'sokoban_wall/blue',
'sokoban_wall/yellow',
'sokoban_wall/green',
'gate_red',
'gate_blue',
'gate_yellow',
'gate_green',
'sand',
'one_way_walls/south',
'dash_floor',
'spikes',
'sand',
'grass',
'cracked_ice',
'hole',
'cracked_floor',
'hole',
'turntable_cw',
'turntable_ccw',
'teleport_blue_exit',
'electrified_floor',
'ankh',
'score_5x',
'boulder',
'circuit_block/xxx',
'glass_block',
'logic_gate/diode',
'sokoban_block/red',
'sokoban_button/red',
'sokoban_wall/red',
'sokoban_block/blue',
'sokoban_button/blue',
'sokoban_wall/blue',
'sokoban_block/green',
'sokoban_button/green',
'sokoban_wall/green',
'sokoban_block/yellow',
'sokoban_button/yellow',
'sokoban_wall/yellow',
'one_way_walls/south',
'boulder',
'gift_bow',
'skeleton_key',
'ankh',
'score_5x',
],
}];
@ -289,8 +464,8 @@ export const PALETTE = [{
export const SPECIAL_PALETTE_ENTRIES = {
'thin_walls/south': { name: 'thin_walls', edges: DIRECTIONS['south'].bit },
'frame_block/0': { name: 'frame_block', direction: 'south', arrows: new Set },
'frame_block/1': { name: 'frame_block', direction: 'north', arrows: new Set(['north']) },
'frame_block/2a': { name: 'frame_block', direction: 'north', arrows: new Set(['north', 'east']) },
'frame_block/1': { name: 'frame_block', direction: 'south', arrows: new Set(['south']) },
'frame_block/2a': { name: 'frame_block', direction: 'south', arrows: new Set(['south', 'west']) },
'frame_block/2o': { name: 'frame_block', direction: 'south', arrows: new Set(['north', 'south']) },
'frame_block/3': { name: 'frame_block', direction: 'south', arrows: new Set(['north', 'east', 'south']) },
'frame_block/4': { name: 'frame_block', direction: 'south', arrows: new Set(['north', 'east', 'south', 'west']) },
@ -515,7 +690,7 @@ export const TILE_DESCRIPTIONS = {
name: "Ice corner",
desc: "Acts like ice, but turns anything sliding on it around the corner. Edges act like thin walls.",
},
force_floor_n: {
force_floor_s: {
name: "Force floor",
desc: "Slides anything on it in the indicated direction, unless it has suction boots. Players may attempt to step off, but not on their first slide. No effect on ghosts.",
},
@ -706,15 +881,15 @@ export const TILE_DESCRIPTIONS = {
// Mechanisms
dirt_block: {
name: "Dirt block",
desc: "Can be pushed, but only one at a time. Resists fire. Turns to dirt in water.",
desc: "A single-push block (i.e., cannot push other blocks ahead of it). Fireproof. Turns to dirt in water.",
},
ice_block: {
name: "Ice block",
desc: "Can be pushed. Pushes any ice block or frame block ahead of it. Turns to water in fire. Turns to ice in water.",
desc: "A multi-push block (i.e., can push other blocks ahead of it). Cannot push dirt blocks directly. Turns to water in fire. Turns to ice in water.",
},
frame_block: {
name: "Frame block",
desc: "Can be pushed, but only in the directions given by the arrows. Pushes any other kind of block ahead of it. Can be moved in other directions by ice, force floors, etc. Rotates when moved along a curved railroad track.",
desc: "A multi-push block. Can only be pushed in the directions given by the arrows. Can be moved in other directions by ice, force floors, etc. Rotates when moved along a curved railroad track.",
},
green_floor: {
name: "Toggle floor",
@ -818,10 +993,6 @@ export const TILE_DESCRIPTIONS = {
name: "NOT gate",
desc: "Emits power only when not receiving power.",
},
'logic_gate/diode': {
name: "Diode",
desc: "Emits power only when receiving power. (Effectively, this delays power by one frame.)",
},
'logic_gate/and': {
name: "AND gate",
desc: "Emits power while both inputs are receiving power.",
@ -880,81 +1051,9 @@ export const TILE_DESCRIPTIONS = {
},
// Experimental
circuit_block: {
name: "Circuit block",
desc: "May contain wires, which will connect to any adjacent wires and conduct power as normal. When pushed into water, turns into floor with the same wires.",
},
gift_bow: {
name: "Gift bow",
desc: "When placed atop an item, anything may step on the item and will pick it up, even if it normally could not do so. When placed alone, has no effect, but an item may be dropped beneath it.",
},
skeleton_key: {
name: "Skeleton key",
desc: "Counts as a tool, not a key. Opens any color lock if the owner lacks a matching key.",
},
gate_red: {
name: "Red gate",
desc: "Requires a red key. Unlike doors, may be placed on top of other terrain.",
},
sand: {
name: "Sand",
desc: "Anything walking on it moves at half speed. Stops all blocks.",
},
ankh: {
name: "Ankh",
desc: "When dropped on empty floor by a player, inscribes a sacred symbol which will save a player's life once.",
},
turntable_cw: {
name: "Turntable (clockwise)",
desc: "Rotates anything entering this tile clockwise. Frame blocks are rotated too. If connected to wire, only functions while receiving power.",
},
turntable_ccw: {
name: "Turntable (counterclockwise)",
desc: "Rotates anything entering this tile counterclockwise. Frame blocks are rotated too. If connected to wire, only functions while receiving power.",
},
electrified_floor: {
name: "Electrified floor",
desc: "Conducts power (like a 4-way wire). While powered, destroys anything not wearing lightning boots (except dirt blocks).",
},
hole: {
name: "Hole",
desc: "A bottomless pit. Destroys everything (except ghosts).",
},
cracked_floor: {
name: "Cracked floor",
desc: "Turns into a hole when something steps off of it (except ghosts).",
},
cracked_ice: {
name: "Cracked ice",
desc: "Turns into water when something steps off of it (except ghosts).",
},
score_5x: {
name: "×5 bonus",
desc: "Quintuples the player's current bonus points. Can be collected by doppelgangers, rovers, and bowling balls, but will not grant bonus points.",
},
spikes: {
name: "Spikes",
desc: "Stops players (and doppelgangers) unless they have hiking boots. Everything else can pass.",
},
boulder: {
name: "Boulder",
desc: "Similar to a dirt block, but rolls when pushed. Boulders transfer momentum to each other. Has ice block/frame block collision. Turns into gravel in water. Spreads slime.",
},
dash_floor: {
name: "Dash floor",
desc: "Anything walking on it moves at double speed. Stacks with speed shoes!",
},
teleport_blue_exit: {
name: "Blue teleporter exit",
desc: "A blue teleporter for all intents and purposes except it can only be exited, not entered.",
},
glass_block: {
name: "Glass block",
desc: "Similar to a dirt block, but stores the first item it moves over, dropping it when destroyed and cloning it in a cloning machine. Has ice block/frame block collision. Turns into floor in water. Doesn't have dirt block immunities.",
},
sokoban_block: {
name: "Sokoban block",
desc: "Similar to a dirt block. Turns to colored floor in water. Can't pass over colored floor of a different color. Has no effect on sokoban buttons of a different color.",
desc: "A single-push block. Can't pass over colored floor of a different color. Has no effect on sokoban buttons of a different color. Turns to colored floor in water.",
},
sokoban_button: {
name: "Sokoban button",
@ -964,6 +1063,147 @@ export const TILE_DESCRIPTIONS = {
name: "Sokoban wall",
desc: "Acts like wall. Turns to floor while all sokoban buttons of the same color are pressed.",
},
gate_red: {
name: "Red gate",
desc: "Requires a red key. Unlike doors, may be placed on top of other terrain, and any actor with the key may unlock it.",
},
gate_blue: {
name: "Blue gate",
desc: "Requires a blue key. Unlike doors, may be placed on top of other terrain, and any actor with the key may unlock it.",
},
gate_yellow: {
name: "Yellow gate",
desc: "Requires a yellow key. Unlike doors, may be placed on top of other terrain, and any actor with the key may unlock it.",
},
gate_green: {
name: "Green gate",
desc: "Requires a green key. Unlike doors, may be placed on top of other terrain, and any actor with the key may unlock it.",
},
one_way_walls: {
name: "One-way wall",
desc: "Similar to a thin wall, but can be passed through one side only.",
},
dash_floor: {
name: "Dash floor",
desc: "Anything walking on it moves at double speed. Stacks with speed boots.",
},
spikes: {
name: "Spikes",
desc: "Stops players (and doppelgangers) unless they have hiking boots. Everything else can pass.",
},
sand: {
name: "Sand",
desc: "Anything walking on it moves 50% slower. Stops all blocks.",
},
grass: {
name: "Grass",
desc: "Stops all blocks, tanks, and rovers. Turns to fire when a fireball touches it. Both types of bug refuse to leave once they enter.",
},
cracked_ice: {
name: "Cracked ice",
desc: "Turns into water when something steps off of it (except ghosts and Cerise).",
},
cracked_floor: {
name: "Cracked floor",
desc: "Turns into a hole when something steps off of it (except ghosts and Cerise).",
},
hole: {
name: "Hole",
desc: "A bottomless pit. Destroys everything (except ghosts).",
},
turntable_cw: {
name: "Turntable (clockwise)",
desc: "Rotates anything entering this tile clockwise. Frame blocks rotate as if on tracks. If connected to wire, only functions while receiving power.",
},
turntable_ccw: {
name: "Turntable (counterclockwise)",
desc: "Rotates anything entering this tile counterclockwise. Frame blocks rotate as if on tracks. If connected to wire, only functions while receiving power.",
},
teleport_blue_exit: {
name: "Blue teleporter exit",
desc: "A blue teleporter for all intents and purposes except it can only be exited, not entered.",
},
electrified_floor: {
name: "Electrified floor",
desc: "Conducts power (like a 4-way wire). While powered, destroys anything not wearing lightning boots (except dirt blocks).",
},
circuit_block: {
name: "Circuit block",
desc: "A single-push block. May contain wires, which will connect to any adjacent wires and conduct power as normal, replacing anything on the floor below. When pushed into water, turns into floor with the same wires.",
},
glass_block: {
name: "Glass block",
desc: "A single-push block. Can pick up one item it moves over, which may then be cloned via a clone machine. Drops the item when destroyed. Turns to floor in water.",
},
'logic_gate/diode': {
name: "Diode",
desc: "Only transmits power in one direction.",
},
boulder: {
name: "Boulder",
desc: "Rolls when pushed. Transfers momentum to another boulder it hits. Fireproof. Turns into gravel in water. Spreads slime.",
},
gift_bow: {
name: "Gift bow",
desc: "When placed atop an item, anything may step on the item and will pick it up, even if it normally could not do so. When placed alone, has no effect, but an item may be dropped beneath it.",
},
skeleton_key: {
name: "Skeleton key",
desc: "Counts as a tool, not a key. Opens any lock when the owner lacks a matching key, but only once.",
},
ankh: {
name: "Ankh",
desc: "When dropped on empty floor by a player, inscribes a sacred symbol which will resurrect a player on that tile, then vanish. Only one symbol may exist at a time, and only the player may step on the symbol.",
},
score_5x: {
name: "×5 bonus",
desc: "Quintuples the player's current bonus points. Can be collected by doppelgangers, rovers, and bowling balls, but will not grant bonus points.",
},
// TODO on the chopping block
raft: {
name: "Raft",
desc: "A vehicle for crossing water safely. Follows its passenger until they reach land. Can be pushed around (like a single-push block) while swimming.",
},
iceberg: {
name: "Iceberg",
desc: "Behaves like ice when stepped on. Can be pushed around (like a multi-push block) while swimming.",
},
cart: {
name: "Cart",
desc: "A vehicle for moving in both directions on tracks. Automatically pushes and pulls any number of adjacent carts.",
},
// XXX extremely aspirational lmao
laser: {
name: "Laser",
desc: "Fires a powerful beam in a straight line, which destroys anything that touches it.",
},
prism_main: {
name: "Prism",
desc: "A multi-push block. Reflects any laser hitting it. Turns to ice in water.",
},
crystal: {
name: "Crystal",
desc: "Emits power while struck by a laser.",
},
fan: {
name: "Fan",
desc: "Blows strong wind in a straight line, pushing anything in its path. Only active while powered.",
},
toll: {
name: "Toll",
desc: "When placed atop an item, that item becomes a toll required to pass. One of the item will be taken away every time an actor passes. When placed alone, has no effect, but an item may be dropped beneath it.",
},
phantom_ring: {
name: "Phantom ring",
desc: "Allows its wearer to walk through all varieties of solid walls, but mysteriously vanishes once they exit into normal space.",
},
log: {
name: "Log",
desc: "May be rolled in one direction, in which case it acts like a boulder. May be stood up in the other direction, in which case it stands up on end and acts like a single-push block. Turns to grass in water.",
},
};
@ -1310,16 +1550,16 @@ function add_special_tile_cycle(rotation_order, mirror_mapping, flip_mapping) {
}
if (name in mirror_mapping) {
let mirror = mirror_mapping[name];
let mirrored = mirror_mapping[name];
behavior.mirror = function mirror(tile) {
tile.type = TILE_TYPES[mirror];
tile.type = TILE_TYPES[mirrored];
};
}
if (name in flip_mapping) {
let flip = flip_mapping[name];
let flipped = flip_mapping[name];
behavior.flip = function flip(tile) {
tile.type = TILE_TYPES[flip];
tile.type = TILE_TYPES[flipped];
};
}

View File

@ -1,14 +1,18 @@
// Small helper classes used by the editor, often with their own UI for the SVG overlay.
import { mk, mk_svg } from '../util.js';
import { DIRECTIONS } from '../defs.js';
import { BitVector, mk, mk_svg } from '../util.js';
export class SVGConnection {
constructor(sx, sy, dx, dy) {
this.source = mk_svg('circle.-source', {r: 0.5});
this.source = mk_svg('circle.-source', {r: 0.5, cx: sx + 0.5, cy: sy + 0.5});
this.line = mk_svg('line.-arrow', {});
this.dest = mk_svg('rect.-dest', {width: 1, height: 1});
this.dest = mk_svg('rect.-dest', {x: dx, y: dy, width: 1, height: 1});
this.element = mk_svg('g.overlay-connection', this.source, this.line, this.dest);
this.set_source(sx, sy);
this.set_dest(dx, dy);
this.sx = sx;
this.sy = sy;
this.dx = dx;
this.dy = dy;
this._update_line_endpoints();
}
set_source(sx, sy) {
@ -16,27 +20,54 @@ export class SVGConnection {
this.sy = sy;
this.source.setAttribute('cx', sx + 0.5);
this.source.setAttribute('cy', sy + 0.5);
this.line.setAttribute('x1', sx + 0.5);
this.line.setAttribute('y1', sy + 0.5);
this._update_line_endpoints();
}
set_dest(dx, dy) {
this.dx = dx;
this.dy = dy;
this.line.setAttribute('x2', dx + 0.5);
this.line.setAttribute('y2', dy + 0.5);
this.dest.setAttribute('x', dx);
this.dest.setAttribute('y', dy);
this._update_line_endpoints();
}
_update_line_endpoints() {
// Start the line at the edge of the circle, so, add 0.5 in the direction of the line
let vx = this.dx - this.sx;
let vy = this.dy - this.sy;
let line_length = Math.sqrt(vx*vx + vy*vy);
let trim_x = 0;
let trim_y = 0;
if (line_length >= 1) {
trim_x = 0.5 * vx / line_length;
trim_y = 0.5 * vy / line_length;
}
this.line.setAttribute('x1', this.sx + 0.5 + trim_x);
this.line.setAttribute('y1', this.sy + 0.5 + trim_y);
// Technically this isn't quite right, since the ending is a square and the arrowhead will
// poke into it a bit from angles near 45°, but that requires a bit more trig than seems
// worth it, and it looks kinda neat anyway.
// Also, one nicety: if the cells are adjacent, don't trim the endpoint, or we won't have
// an arrow at all.
if (line_length < 2) {
this.line.setAttribute('x2', this.dx + 0.5);
this.line.setAttribute('y2', this.dy + 0.5);
}
else {
this.line.setAttribute('x2', this.dx + 0.5 - trim_x);
this.line.setAttribute('y2', this.dy + 0.5 - trim_y);
}
}
}
// TODO probably need to combine this with Selection somehow since it IS one, just not committed yet
export class PendingSelection {
constructor(owner) {
export class PendingRectangularSelection {
constructor(owner, mode) {
this.owner = owner;
this.mode = mode ?? 'new'; // new, add, subtract
this.element = mk_svg('rect.overlay-pending-selection');
this.owner.svg_group.append(this.element);
this.size_text = mk_svg('text.overlay-edit-tip');
this.owner.svg_group.append(this.element, this.size_text);
this.rect = null;
}
@ -47,15 +78,29 @@ export class PendingSelection {
this.element.setAttribute('y', this.rect.y);
this.element.setAttribute('width', this.rect.width);
this.element.setAttribute('height', this.rect.height);
this.size_text.textContent = `${this.rect.width} × ${this.rect.height}`;
this.size_text.setAttribute('x', this.rect.x + this.rect.width / 2);
this.size_text.setAttribute('y', this.rect.y + this.rect.height / 2);
}
commit() {
this.owner.set_from_rect(this.rect);
if (this.mode === 'new') {
this.owner.clear();
this.owner.add_rect(this.rect);
}
else if (this.mode === 'add') {
this.owner.add_rect(this.rect);
}
else if (this.mode === 'subtract') {
this.owner.subtract_rect(this.rect);
}
this.element.remove();
this.size_text.remove();
}
discard() {
this.element.remove();
this.size_text.remove();
}
}
@ -65,206 +110,524 @@ export class Selection {
this.svg_group = mk_svg('g');
this.editor.svg_overlay.append(this.svg_group);
// Used for the floating preview and selection rings, which should all move together
this.selection_group = mk_svg('g');
this.svg_group.append(this.selection_group);
this.rect = null;
this.element = mk_svg('rect.overlay-selection.overlay-transient');
this.svg_group.append(this.element);
// Note that this is a set of the ORIGINAL coordinates of the selected cells. Moving a
// floated selection doesn't change this; instead it updates floated_offset
this.cells = new Set;
this.bbox = null;
// I want a black-and-white outline ring so it shows against any background, but the only
// way to do that in SVG is apparently to just duplicate the path
this.ring_bg_element = mk_svg('path.overlay-selection-background.overlay-transient');
this.ring_element = mk_svg('path.overlay-selection.overlay-transient');
this.selection_group.append(this.ring_bg_element, this.ring_element);
this.floated_cells = null;
this.floated_element = null;
this.floated_canvas = null;
this.floated_offset = null;
}
get is_empty() {
return this.rect === null;
return this.cells.size === 0;
}
get is_floating() {
return !! this.floated_cells;
}
get has_moved() {
return !! (this.floated_offset && (this.floated_offset[0] || this.floated_offset[0]));
}
contains(x, y) {
// Empty selection means everything is selected?
if (this.rect === null)
if (this.is_empty)
return true;
return this.rect.left <= x && x < this.rect.right && this.rect.top <= y && y < this.rect.bottom;
if (this.floated_offset) {
x -= this.floated_offset[0];
y -= this.floated_offset[1];
}
return this.cells.has(this.editor.stored_level.coords_to_scalar(x, y));
}
create_pending() {
return new PendingSelection(this);
create_pending(mode) {
return new PendingRectangularSelection(this, mode);
}
set_from_rect(rect) {
let old_rect = this.rect;
add_rect(rect) {
let old_cells = this.cells;
// TODO would be nice to only store the difference between the old/new sets of cells?
this.cells = new Set(this.cells);
this.editor._do(
() => this._set_from_rect(rect),
() => this._add_rect(rect),
() => {
if (old_rect) {
this._set_from_rect(old_rect);
}
else {
this._clear();
}
this._set_from_set(old_cells);
},
false,
);
}
_set_from_rect(rect) {
this.rect = rect;
this.element.classList.add('--visible');
this.element.setAttribute('x', this.rect.x);
this.element.setAttribute('y', this.rect.y);
this.element.setAttribute('width', this.rect.width);
this.element.setAttribute('height', this.rect.height);
if (this.floated_element) {
let tileset = this.editor.renderer.tileset;
this.floated_canvas.width = rect.width * tileset.size_x;
this.floated_canvas.height = rect.height * tileset.size_y;
let foreign_obj = this.floated_element.querySelector('foreignObject');
foreign_obj.setAttribute('width', this.floated_canvas.width);
foreign_obj.setAttribute('height', this.floated_canvas.height);
_add_rect(rect) {
let stored_level = this.editor.stored_level;
for (let y = rect.top; y < rect.bottom; y++) {
for (let x = rect.left; x < rect.right; x++) {
this.cells.add(stored_level.coords_to_scalar(x, y));
}
}
if (! this.bbox) {
this.bbox = rect;
}
else {
// Just recreate it from scratch to avoid mixing old and new properties
let new_x = Math.min(this.bbox.x, rect.x);
let new_y = Math.min(this.bbox.y, rect.y);
this.bbox = new DOMRect(
new_x, new_y,
Math.max(this.bbox.right, rect.right) - new_x,
Math.max(this.bbox.bottom, rect.bottom) - new_y);
}
this._update_outline();
}
subtract_rect(rect) {
let old_cells = this.cells;
this.cells = new Set(this.cells);
this.editor._do(
() => this._subtract_rect(rect),
() => {
this._set_from_set(old_cells);
},
false,
);
}
_subtract_rect(rect) {
if (this.is_empty)
// Nothing to do
return;
let stored_level = this.editor.stored_level;
for (let y = rect.top; y < rect.bottom; y++) {
for (let x = rect.left; x < rect.right; x++) {
this.cells.delete(stored_level.coords_to_scalar(x, y));
}
}
// TODO shrink bbox? i guess i only have to check along the edges that the rect intersects?
this._update_outline();
}
_set_from_set(cells) {
this.cells = cells;
// Recompute bbox
if (cells.size === 0) {
this.bbox = null;
}
else {
let min_x = null;
let min_y = null;
let max_x = null;
let max_y = null;
for (let n of cells) {
let [x, y] = this.editor.stored_level.scalar_to_coords(n);
if (min_x === null) {
min_x = x;
min_y = y;
max_x = x;
max_y = y;
}
else {
min_x = Math.min(min_x, x);
max_x = Math.max(max_x, x);
min_y = Math.min(min_y, y);
max_y = Math.max(max_y, y);
}
}
this.bbox = new DOMRect(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1);
}
// XXX ??? if (this.floated_element) {
this._update_outline();
}
// Faster internal version of contains() that ignores the floating offset
_contains(x, y) {
let stored_level = this.editor.stored_level;
return stored_level.is_point_within_bounds(x, y) &&
this.cells.has(stored_level.coords_to_scalar(x, y));
}
_update_outline() {
if (this.is_empty) {
this.ring_bg_element.classList.remove('--visible');
this.ring_element.classList.remove('--visible');
return;
}
// Convert the borders between cells to an SVG path.
// I don't know an especially clever way to do this so I guess I'll just make it up. The
// basic idea is to start with the top-left highlighted cell, start tracing from its top
// left corner towards the right (which must be a border, because this is the top left
// selected cell, so nothing above it is selected), then just keep going until we get back
// to where we started. Then we... repeat.
// But how do we repeat? My tiny insight is that every island (including holes) must cross
// the top of at least one cell; the only alternatives are for it to be zero width or only
// exist in the bottom row, and either way that makes it zero area, which isn't allowed. So
// we only have to track and check the top edges of cells, and run through every cell in the
// grid in order, stopping to draw a new outline when we find a cell whose top edge we
// haven't yet examined (and whose top edge is in fact a border). We unfortunately need to
// examine cells outside the selection, too, so that we can identify holes. But we can
// restrict all of this to within the bbox, so that's nice.
// Also, note that we concern ourselves with /grid points/ here, which are intersections of
// grid lines, whereas the grid cells are the spaces between grid lines.
// TODO might be more efficient to store a list of horizontal spans instead of just cells,
// but of course this would be more complicated
let seen_tops = new BitVector(this.bbox.width * this.bbox.height);
// In clockwise order for ease of rotation, starting with right
let directions = [
[1, 0],
[0, 1],
[-1, 0],
[0, -1],
];
let segments = [];
for (let y = this.bbox.top; y < this.bbox.bottom; y++) {
for (let x = this.bbox.left; x < this.bbox.right; x++) {
if (seen_tops.get((x - this.bbox.left) + this.bbox.width * (y - this.bbox.top)))
// Already traced
continue;
if (this._contains(x, y) === this._contains(x, y - 1))
// Not a top border
continue;
// Start a new segment!
let gx = x;
let gy = y;
let dx = 1;
let dy = 0;
let d = 0;
let segment = [];
segments.push(segment);
segment.push([gx, gy]);
while (segment.length < 100) {
// At this point we know that d is a valid direction and we've just traced it
if (dx === 1) {
seen_tops.set((gx - this.bbox.left) + this.bbox.width * (gy - this.bbox.top));
}
else if (dx === -1) {
seen_tops.set((gx - 1 - this.bbox.left) + this.bbox.width * (gy - this.bbox.top));
}
gx += dx;
gy += dy;
if (gx === x && gy === y)
break;
// Now we're at a new point, so search for the next direction, starting from the left
// Again, this is clockwise order (tr, br, bl, tl), arranged so that direction D goes
// between cells D and D + 1
let neighbors = [
this._contains(gx, gy - 1),
this._contains(gx, gy),
this._contains(gx - 1, gy),
this._contains(gx - 1, gy - 1),
];
let new_d = (d + 1) % 4;
for (let i = 3; i <= 4; i++) {
let sd = (d + i) % 4;
if (neighbors[sd] !== neighbors[(sd + 1) % 4]) {
new_d = sd;
break;
}
}
if (new_d !== d) {
// We're turning, so this is a new point
segment.push([gx, gy]);
d = new_d;
[dx, dy] = directions[d];
}
}
}
}
// TODO do it again for the next region... but how do i tell where the next region is?
let pathdata = [];
for (let subpath of segments) {
let first = true;
for (let [x, y] of subpath) {
if (first) {
first = false;
pathdata.push(`M${x},${y}`);
}
else {
pathdata.push(`L${x},${y}`);
}
}
pathdata.push('z');
}
this.ring_bg_element.classList.add('--visible');
this.ring_bg_element.setAttribute('d', pathdata.join(' '));
this.ring_element.classList.add('--visible');
this.ring_element.setAttribute('d', pathdata.join(' '));
}
move_by(dx, dy) {
if (! this.rect)
if (this.is_empty)
return;
this.rect.x += dx;
this.rect.y += dy;
this.element.setAttribute('x', this.rect.x);
this.element.setAttribute('y', this.rect.y);
if (! this.floated_element)
if (! this.floated_cells) {
console.error("Can't move a non-floating selection");
return;
}
let bbox = this.rect;
this.floated_element.setAttribute('transform', `translate(${bbox.x} ${bbox.y})`);
this.floated_offset[0] += dx;
this.floated_offset[1] += dy;
this._update_floating_transform();
}
_update_floating_transform() {
let transform = `translate(${this.floated_offset[0]} ${this.floated_offset[1]})`;
this.selection_group.setAttribute('transform', transform);
}
clear() {
let rect = this.rect;
if (! rect)
// FIXME behavior when floating is undefined
if (this.is_empty)
return;
let old_cells = this.cells;
this.editor._do(
() => this._clear(),
() => this._set_from_rect(rect),
() => {
this._set_from_set(old_cells);
},
false,
);
}
_clear() {
this.rect = null;
this.element.classList.remove('--visible');
}
*iter_coords() {
if (! this.rect)
return;
let stored_level = this.editor.stored_level;
for (let y = this.rect.top; y < this.rect.bottom; y++) {
for (let x = this.rect.left; x < this.rect.right; x++) {
let n = stored_level.coords_to_scalar(x, y);
yield [x, y, n];
}
}
this.cells = new Set;
this.bbox = null;
this.ring_bg_element.classList.remove('--visible');
this.ring_element.classList.remove('--visible');
}
// Convert this selection into a floating selection, plucking all the selected cells from the
// level and replacing them with blank cells.
enfloat(copy = false) {
if (this.floated_cells)
if (this.floated_cells) {
console.error("Trying to float a selection that's already floating");
return;
}
let floated_cells = [];
let tileset = this.editor.renderer.tileset;
let floated_cells = new Map;
let stored_level = this.editor.stored_level;
let bbox = this.rect;
let canvas = mk('canvas', {width: bbox.width * tileset.size_x, height: bbox.height * tileset.size_y});
let ctx = canvas.getContext('2d');
ctx.drawImage(
this.editor.renderer.canvas,
bbox.x * tileset.size_x, bbox.y * tileset.size_y, bbox.width * tileset.size_x, bbox.height * tileset.size_y,
0, 0, bbox.width * tileset.size_x, bbox.height * tileset.size_y);
for (let [x, y, n] of this.iter_coords()) {
for (let n of this.cells) {
let [x, y] = stored_level.scalar_to_coords(n);
let cell = stored_level.linear_cells[n];
if (copy) {
floated_cells.push(cell.map(tile => tile ? {...tile} : null));
floated_cells.set(n, cell.map(tile => tile ? {...tile} : null));
}
else {
floated_cells.push(cell);
floated_cells.set(n, cell);
this.editor.replace_cell(cell, this.editor.make_blank_cell(x, y));
}
}
let floated_element = mk_svg('g', mk_svg('foreignObject', {
x: 0, y: 0,
width: canvas.width, height: canvas.height,
transform: `scale(${1/tileset.size_x} ${1/tileset.size_y})`,
}, canvas));
floated_element.setAttribute('transform', `translate(${bbox.x} ${bbox.y})`);
// FIXME far more memory efficient to recreate the canvas in the redo, rather than hold onto
// it forever
this.editor._do(
() => {
this.floated_canvas = canvas;
this.floated_element = floated_element;
this.floated_cells = floated_cells;
this.svg_group.append(floated_element);
this.floated_offset = [0, 0];
this._init_floated_canvas();
this.ring_element.classList.add('--floating');
},
() => this._defloat(),
() => this._delete_floating(),
);
}
// Create floated_canvas and floated_element, based on floated_cells, or update them if they
// already exist
_init_floated_canvas() {
let tileset = this.editor.renderer.tileset;
if (! this.floated_canvas) {
this.floated_canvas = mk('canvas');
}
this.floated_canvas.width = this.bbox.width * tileset.size_x;
this.floated_canvas.height = this.bbox.height * tileset.size_y;
this.redraw();
if (! this.floated_element) {
this.floated_element = mk_svg('g', mk_svg('foreignObject', {
x: 0,
y: 0,
transform: `scale(${1/tileset.size_x} ${1/tileset.size_y})`,
}, this.floated_canvas));
// This goes first, so the selection ring still appears on top
this.selection_group.prepend(this.floated_element);
}
let foreign = this.floated_element.querySelector('foreignObject');
foreign.setAttribute('width', this.floated_canvas.width);
foreign.setAttribute('height', this.floated_canvas.height);
// The canvas only covers our bbox, so it needs to start where the bbox does
this.floated_element.setAttribute('transform', `translate(${this.bbox.x} ${this.bbox.y})`);
}
stamp_float(copy = false) {
if (! this.floated_element)
return;
let stored_level = this.editor.stored_level;
let i = 0;
for (let [x, y, n] of this.iter_coords()) {
let cell = this.floated_cells[i];
for (let n of this.cells) {
let [x, y] = stored_level.scalar_to_coords(n);
x += this.floated_offset[0];
y += this.floated_offset[1];
// If the selection is moved so that part of it is outside the level, skip that bit
if (! stored_level.is_point_within_bounds(x, y))
continue;
let cell = this.floated_cells.get(n);
if (copy) {
cell = cell.map(tile => tile ? {...tile} : null);
}
cell.x = x;
cell.y = y;
this.editor.replace_cell(stored_level.linear_cells[n], cell);
i += 1;
let n2 = stored_level.coords_to_scalar(x, y);
this.editor.replace_cell(stored_level.linear_cells[n2], cell);
}
}
defloat() {
// Converts a floating selection back to a regular selection, including stamping it in place
commit_floating() {
// This is OK; we're idempotent
if (! this.floated_element)
return;
this.stamp_float();
let element = this.floated_element;
let canvas = this.floated_canvas;
let cells = this.floated_cells;
// Actually apply the offset, so we can be a regular selection again
let old_cells = this.cells;
let old_bbox = DOMRect.fromRect(this.bbox);
let new_cells = new Set;
let stored_level = this.editor.stored_level;
for (let n of old_cells) {
let [x, y] = stored_level.scalar_to_coords(n);
x += this.floated_offset[0];
y += this.floated_offset[1];
if (stored_level.is_point_within_bounds(x, y)) {
new_cells.add(stored_level.coords_to_scalar(x, y));
}
}
let old_floated_cells = this.floated_cells;
let old_floated_offset = this.floated_offset;
this.editor._do(
() => this._defloat(),
() => {
this.floated_cells = cells;
this.floated_canvas = canvas;
this.floated_element = element;
this.svg_group.append(element);
this._delete_floating();
this._set_from_set(new_cells);
},
() => {
// Don't use _set_from_set here; it's not designed for an offset float
this.cells = old_cells;
this.bbox = old_bbox;
this._update_outline();
this.floated_cells = old_floated_cells;
this.floated_offset = old_floated_offset;
this._init_floated_canvas();
this._update_floating_transform();
this.ring_element.classList.add('--floating');
},
false,
);
}
_defloat() {
// Modifies the cells (and their arrangement) within a floating selection
_rearrange_cells(original_width, convert_coords, upgrade_tile) {
if (! this.floated_cells)
return;
let new_cells = new Set;
let new_floated_cells = new Map;
let w = this.editor.stored_level.size_x;
let h = this.editor.stored_level.size_y;
for (let n of this.cells) {
// Alas this needs manually computing since the level may have changed size
let x = n % original_width;
let y = Math.floor(n / original_width);
let [x2, y2] = convert_coords(x, y, w, h);
let n2 = x2 + w * y2;
let cell = this.floated_cells.get(n);
cell.x = x2;
cell.y = y2;
for (let tile of cell) {
if (tile) {
upgrade_tile(tile);
}
}
new_cells.add(n2);
new_floated_cells.set(n2, cell);
}
// Track the old and new centers of the bboxes so the transform can be center-relative
let [cx0, cy0] = convert_coords(
Math.floor(this.bbox.x + this.bbox.width / 2),
Math.floor(this.bbox.y + this.bbox.height / 2),
w, h);
// Alter the bbox by just transforming two opposite corners
let [x1, y1] = convert_coords(this.bbox.left, this.bbox.top, w, h);
let [x2, y2] = convert_coords(this.bbox.right - 1, this.bbox.bottom - 1, w, h);
let xs = [x1, x2];
let ys = [y1, y2];
xs.sort((a, b) => a - b);
ys.sort((a, b) => a - b);
this.bbox = new DOMRect(xs[0], ys[0], xs[1] - xs[0] + 1, ys[1] - ys[0] + 1);
// Now make it center-relative by shifting the offsets
let [cx1, cy1] = convert_coords(
Math.floor(this.bbox.x + this.bbox.width / 2),
Math.floor(this.bbox.y + this.bbox.height / 2),
w, h);
this.floated_offset[0] += cx1 - cx0;
this.floated_offset[1] += cy1 - cy0;
this._update_floating_transform();
// No need for undo; this is undone by performing the reverse operation
this.cells = new_cells;
this.floated_cells = new_floated_cells;
this._init_floated_canvas();
this._update_outline();
}
_delete_floating() {
this.selection_group.removeAttribute('transform');
this.ring_element.classList.remove('--floating');
this.floated_element.remove();
this.floated_cells = null;
this.floated_offset = null;
this.floated_element = null;
this.floated_canvas = null;
this.floated_cells = null;
}
// Redraw the selection canvas from scratch
@ -272,19 +635,22 @@ export class Selection {
if (! this.floated_canvas)
return;
// FIXME uhoh, how do i actually do this? we have no renderer of our own, we have a
// separate canvas, and all the renderer stuff expects to get ahold of a level. i guess
// refactor it to draw a block of cells?
this.editor.renderer.draw_static_generic({
x0: 0, y0: 0,
x1: this.rect.width, y1: this.rect.height,
cells: this.floated_cells,
width: this.rect.width,
ctx: this.floated_canvas.getContext('2d'),
});
let ctx = this.floated_canvas.getContext('2d');
for (let n of this.cells) {
let [x, y] = this.editor.stored_level.scalar_to_coords(n);
this.editor.renderer.draw_static_generic({
// Incredibly stupid hack for just drawing one cell
x0: 0, x1: 0,
y0: 0, y1: 0,
width: 1,
cells: [this.floated_cells.get(n)],
ctx: ctx,
destx: x - this.bbox.left,
desty: y - this.bbox.top,
});
}
}
// TODO allow floating/dragging, ctrl-dragging to copy, anchoring...
// TODO make more stuff respect this (more things should go through Editor for undo reasons anyway)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,18 @@
import { LAYERS } from './defs.js';
import { DIRECTIONS, LAYERS } from './defs.js';
import * as util from './util.js';
export class StoredCell extends Array {
constructor() {
super(LAYERS.MAX);
}
get_terrain() {
return this[LAYERS.terrain] ?? null;
}
get_actor() {
return this[LAYERS.actor] ?? null;
}
}
export class Replay {
@ -77,6 +85,10 @@ export class LevelInterface {
return x + y * this.size_x;
}
cell_to_scalar(cell) {
return this.coords_to_scalar(cell.x, cell.y);
}
is_point_within_bounds(x, y) {
return (x >= 0 && x < this.size_x && y >= 0 && y < this.size_y);
}
@ -89,6 +101,11 @@ export class LevelInterface {
return null;
}
}
get_neighboring_cell(cell, direction) {
let move = DIRECTIONS[direction].movement;
return this.cell(cell.x + move[0], cell.y + move[1]);
}
}
export class StoredLevel extends LevelInterface {
@ -128,8 +145,10 @@ export class StoredLevel extends LevelInterface {
this.linear_cells = [];
// Maps of button positions to trap/cloner positions, as scalars
this.has_custom_connections = false;
this.custom_connections = {};
// Not supported by Steam CC2, but supported by Tile World even in Lynx mode
this.custom_connections = new Map;
// If true, Lynx-style implicit connections don't work at all
this.only_custom_connections = false;
// New LL feature: custom camera regions, as lists of {x, y, width, height}
this.camera_regions = [];
@ -152,6 +171,7 @@ export class StoredLevel extends LevelInterface {
export class StoredPack {
constructor(identifier, level_loader) {
// This isn't very strongly defined, but it's used to distinguish scores for packs and URLs
this.identifier = identifier;
this.title = "";
this._level_loader = level_loader;

View File

@ -894,6 +894,10 @@ const TILE_ENCODING = {
name: 'sand',
is_extension: true,
},
0xe8: {
name: 'grass',
is_extension: true,
},
0xed: {
name: 'ankh',
has_next: true,
@ -926,6 +930,22 @@ const TILE_ENCODING = {
modifier: modifier_color,
is_extension: true,
},
0xf4: {
name: 'one_way_walls',
has_next: true,
is_extension: true,
extra_args: [
{
size: 1,
decode(tile, mask) {
tile.edges = mask;
},
encode(tile) {
return tile.edges;
},
},
],
},
};
const REVERSE_TILE_ENCODING = {};
for (let [tile_byte, spec] of Object.entries(TILE_ENCODING)) {
@ -1312,12 +1332,11 @@ export function parse_level(buf, number = 1) {
if (bytes.length % 4 !== 0)
throw new Error(`Expected LXCX chunk to be a multiple of 4 bytes; got ${bytes.length}`);
level.has_custom_connections = true;
let p = 0;
while (p < bytes.length) {
let src = view.getUint16(p, true);
let dest = view.getUint16(p + 2, true);
level.custom_connections[src] = dest;
level.custom_connections.set(src, dest);
p += 4;
}
}
@ -1552,12 +1571,12 @@ export function synthesize_level(stored_level) {
// Store MSCC-like custom connections
// TODO LL feature, should be distinguished somehow
let num_connections = Object.keys(stored_level.custom_connections).length;
let num_connections = stored_level.custom_connections.size;
if (num_connections > 0) {
let buf = new ArrayBuffer(4 * num_connections);
let view = new DataView(buf);
let p = 0;
for (let [src, dest] of Object.entries(stored_level.custom_connections)) {
for (let [src, dest] of stored_level.custom_connections) {
view.setUint16(p + 0, src, true);
view.setUint16(p + 2, dest, true);
p += 4;
@ -1756,7 +1775,7 @@ const TOKENIZE_RX = RegExp(
// 2: Comments are preceded by ; or // for some reason and run to the end of the line
'|(?:;|//)(.*)' +
// 3: Strings are double-quoted (only!) and contain no escapes
'|"([^"]*?)"' +
'|"([^"]*?)(?:"|$)' +
// 4: Labels are indicated by a #, including when used with 'goto'
// (the exact set of allowed characters is unclear and i'm fudging it here)
'|#(\\w+)' +
@ -1771,7 +1790,7 @@ const TOKENIZE_RX = RegExp(
'|([a-zA-Z]\\S*)' +
// 8: Anything else is an error
'|(\\S+)' +
')', 'g');
')', 'gm');
const DIRECTIVES = {
// Important stuff
'chdir': ['string'],
@ -1875,6 +1894,7 @@ class ParseError extends Error {
super(`${message} at line ${parser.lineno}`);
}
}
ParseError.prototype.name = 'ParseError';
class Parser {
constructor(string) {
@ -2156,7 +2176,7 @@ const MAX_SIMULTANEOUS_REQUESTS = 5;
_fetch_map(path, n);
};
// FIXME and right off the bat we have an Issue: this is a text format so i want a string, not
// an arraybuffer!
let contents = util.string_from_buffer_ascii(buf);
@ -2172,8 +2192,9 @@ const MAX_SIMULTANEOUS_REQUESTS = 5;
if (stmt.kind === 'directive' && stmt.name === 'map') {
let path = stmt.args[0].value;
path = path.replace(/\\/, '/');
// FIXME can we get away with not downloading all of them eagerly?
fetch_map(path, level_number);
level_number++;
level_number += 1;
}
else if (stmt.kind === 'directive' && stmt.name === 'game') {
// TODO apparently cc2 lets you change this mid-game and will then use a different save

View File

@ -209,7 +209,7 @@ export function parse_level_metadata(bytes) {
function parse_level(bytes, number) {
let level = new format_base.StoredLevel(number);
level.has_custom_connections = true;
level.only_custom_connections = true;
level.format = 'ccl';
level.uses_ll_extensions = false;
level.chips_required = 0;
@ -353,7 +353,7 @@ function parse_level(bytes, number) {
let s = level.coords_to_scalar(button_x, button_y);
let d = level.coords_to_scalar(trap_x, trap_y);
if (level.linear_cells[s][LAYERS.terrain].type.name === 'button_brown') {
level.custom_connections[s] = d;
level.custom_connections.set(s, d);
}
}
}
@ -372,7 +372,7 @@ function parse_level(bytes, number) {
let s = level.coords_to_scalar(button_x, button_y);
let d = level.coords_to_scalar(cloner_x, cloner_y);
if (level.linear_cells[s][LAYERS.terrain].type.name === 'button_red') {
level.custom_connections[s] = d;
level.custom_connections.set(s, d);
}
}
}
@ -561,9 +561,10 @@ export function synthesize_level(stored_level) {
else if (other.type.name === 'button_brown') {
cxn_target = 'trap';
}
if (cxn_target && i in stored_level.custom_connections) {
let dest = stored_level.custom_connections[i];
if (cxn_target && stored_level.custom_connections.has(i)) {
let dest = stored_level.custom_connections.get(i);
let dest_cell = stored_level.linear_cells[dest];
// FIXME these need to be sorted by destination actually
if (dest_cell && dest_cell[LAYERS.terrain].type.name === cxn_target) {
if (other.type.name === 'button_red') {
cloner_cxns.push(x, y, ...stored_level.scalar_to_coords(dest));
@ -620,7 +621,7 @@ export function synthesize_level(stored_level) {
// TODO do something with not-ascii; does TW support utf8 or latin1 or anything?
add_block(3, util.bytestring_to_buffer(stored_level.title.substring(0, 63) + "\0"));
// Trap and cloner connections
function to_words(cxns) {
function encode_connections(cxns) {
let words = new ArrayBuffer(cxns.length * 2);
let view = new DataView(words);
for (let [i, val] of cxns.entries()) {
@ -629,10 +630,10 @@ export function synthesize_level(stored_level) {
return words;
}
if (trap_cxns.length > 0) {
add_block(4, to_words(trap_cxns));
add_block(4, encode_connections(trap_cxns));
}
if (cloner_cxns.length > 0) {
add_block(5, to_words(cloner_cxns));
add_block(5, encode_connections(cloner_cxns));
}
// Password
// TODO support this for real lol

View File

@ -13,6 +13,7 @@ const TW_DIRECTION_TO_INPUT_BITS = [
INPUT_BITS.down | INPUT_BITS.right,
];
// doc: http://www.muppetlabs.com/~breadbox/software/tworld/tworldff.html#3
export function parse_solutions(bytes) {
let buf;
if (bytes.buffer) {
@ -82,10 +83,12 @@ export function parse_solutions(bytes) {
let q = p + 16;
while (q < p + len) {
// There are four formats for packing solutions, identified by the lowest two bits,
// except that format 3 is actually two formats, don't ask
// except that format 3 is actually two formats. Be aware that the documentation
// refers to them in a different order than suggested by the identifying nybble.
let fmt = bytes[q] & 0x3;
let fmt2 = (bytes[q] >> 4) & 0x1;
if (fmt === 0) {
// "Third format": three consecutive moves packed into one byte
let val = bytes[q];
q += 1;
let input1 = TW_DIRECTION_TO_INPUT_BITS[(val >> 2) & 0x3];
@ -98,6 +101,8 @@ export function parse_solutions(bytes) {
);
}
else if (fmt === 1 || fmt === 2 || (fmt === 3 && fmt2 === 0)) {
// "First format" and "second format": one, two, or four bytes containing a
// direction and a number of tics
let val;
if (fmt === 1) {
val = bytes[q];
@ -118,9 +123,34 @@ export function parse_solutions(bytes) {
}
inputs.push(input);
}
else { // 3-1
// variable-size and only needed for ms so let's just hope not
throw new Error;
else { // low nybble is 3, and bit 4 is set
// "Fourth format": 2 to 5 bytes, containing an exceptionally long direction
// field and time field, mostly used for MSCC mouse moves
let n = ((bytes[q] >> 2) & 0x3) + 2;
if (q + n - 1 >= bytes.length)
throw new Error(`Malformed TWS file: expected ${n} bytes starting at ${q}, but only found ${bytes.length - q}`);
// Up to 5 bytes is an annoying amount, but we can cut it down to 1-4 by
// extracting the direction first
let input = (bytes[q] >> 5) | ((bytes[q + 1] & 0x3f) << 3);
let duration = bytes[q + 1] >> 6;
for (let i = 3; i <= n; i++) {
duration |= bytes[q + i - 1] << (2 + (i - 3) * 8);
}
// Mouse moves are encoded as 16 + ((y + 9) * 19) + (x + 9), but I extremely do
// not support them at the moment (and may never), so replace them with blank
// input for now (and possibly forever)
if (input >= 16) {
input = 0;
}
// And now queue it up
for (let i = 0; i < duration; i++) {
inputs.push(input);
}
q += n;
}
}

1532
js/game.js

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,15 @@
import { readFile, stat } from 'fs/promises';
import { performance } from 'perf_hooks';
import { argv, exit, stderr, stdout } from 'process';
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
import { compat_flags_for_ruleset } from '../defs.js';
import { Level } from '../game.js';
import * as format_c2g from '../format-c2g.js';
import * as format_dat from '../format-dat.js';
import * as format_tws from '../format-tws.js';
import * as util from '../util.js';
import { argv, exit, stderr, stdout } from 'process';
import { opendir, readFile, stat } from 'fs/promises';
import { performance } from 'perf_hooks';
import { LocalDirectorySource } from './lib.js';
// TODO arguments:
// - custom pack to test, possibly its solutions, possibly its ruleset (or default to steam-strict/lynx)
@ -17,45 +19,17 @@ import { performance } from 'perf_hooks';
// - support for xfails somehow?
// TODO use this for a test suite
export class LocalDirectorySource extends util.FileSource {
constructor(root) {
super();
this.root = root;
this.files = {};
this._loaded_promise = this._scan_dir('/');
}
async _scan_dir(path) {
let dir = await opendir(this.root + path);
for await (let dirent of dir) {
if (dirent.isDirectory()) {
await this._scan_dir(path + dirent.name + '/');
}
else {
let filepath = path + dirent.name;
this.files[filepath.toLowerCase()] = filepath;
if (this.files.size > 2000)
throw `way, way too many files in local directory source ${this.root}`;
}
}
}
async get(path) {
let realpath = this.files[path.toLowerCase()];
if (realpath) {
return (await readFile(this.root + realpath)).buffer;
}
else {
throw new Error(`No such file: ${path}`);
}
}
}
function pad(s, n) {
return s.substring(0, n).padEnd(n, " ");
}
const RESULT_TYPES = {
pending: {
// not a real result type, but used for the initial display
color: "\x1b[90m",
symbol: "?",
},
skipped: {
color: "\x1b[90m",
symbol: "-",
@ -86,198 +60,150 @@ const RESULT_TYPES = {
},
};
const ANSI_RESET = "\x1b[39m";
function ansi_cursor_move(dx, dy) {
if (dx > 0) {
stdout.write(`\x1b[${dx}C`);
}
else if (dx < 0) {
stdout.write(`\x1b[${-dx}D`);
}
async function test_pack(pack, ruleset, level_filter = null) {
let dummy_sfx = {
set_player_position() {},
play() {},
play_once() {},
};
let compat = compat_flags_for_ruleset(ruleset);
if (dy > 0) {
stdout.write(`\x1b[${dy}B`);
}
else if (dy < 0) {
stdout.write(`\x1b[${-dy}A`);
}
}
// TODO factor out the common parts maybe?
stdout.write(pad(`${pack.title} (${ruleset})`, 20) + " ");
let num_levels = pack.level_metadata.length;
let num_passed = 0;
let num_missing = 0;
let total_tics = 0;
let t0 = performance.now();
let last_pause = t0;
let failures = [];
for (let i = 0; i < num_levels; i++) {
let stored_level, level;
let level_start_time = performance.now();
let record_result = (token, short_status, include_canvas, comment) => {
let result_stuff = RESULT_TYPES[token];
stdout.write(result_stuff.color + result_stuff.symbol);
if (token === 'failure' || token === 'short' || token === 'error') {
failures.push({
token,
short_status,
comment,
level,
stored_level,
index: i,
fail_reason: level ? level.fail_reason : null,
time_elapsed: performance.now() - level_start_time,
time_expected: stored_level ? stored_level.replay.duration / 20 : null,
title: stored_level ? stored_level.title : "[error]",
time_simulated: level ? level.tic_counter / 20 : null,
const dummy_sfx = {
play() {},
play_once() {},
};
function test_level(stored_level, compat) {
let level;
let level_start_time = performance.now();
let make_result = (type, short_status, include_canvas) => {
//let result_stuff = RESULT_TYPES[type];
// XXX stdout.write(result_stuff.color + result_stuff.symbol);
return {
type,
short_status,
fail_reason: level ? level.fail_reason : null,
time_elapsed: performance.now() - level_start_time,
time_simulated: level ? level.tic_counter / 20 : null,
tics_simulated: level ? level.tic_counter : null,
};
// FIXME allegedly it's possible to get a canvas working in node...
/*
if (include_canvas && level) {
try {
let tileset = this.conductor.choose_tileset_for_level(level.stored_level);
this.renderer.set_tileset(tileset);
let canvas = mk('canvas', {
width: Math.min(this.renderer.canvas.width, level.size_x * tileset.size_x),
height: Math.min(this.renderer.canvas.height, level.size_y * tileset.size_y),
});
this.renderer.set_level(level);
this.renderer.set_active_player(level.player);
this.renderer.draw();
canvas.getContext('2d').drawImage(
this.renderer.canvas, 0, 0,
this.renderer.canvas.width, this.renderer.canvas.height);
tbody.append(mk('tr', mk('td.-full', {colspan: 5}, canvas)));
}
if (level) {
/*
mk('td.-clock', util.format_duration(level.tic_counter / TICS_PER_SECOND)),
mk('td.-delta', util.format_duration((level.tic_counter - stored_level.replay.duration) / TICS_PER_SECOND, 2)),
mk('td.-speed', ((level.tic_counter / TICS_PER_SECOND) / (performance.now() - level_start_time) * 1000).toFixed(2) + '×'),
*/
catch (e) {
console.error(e);
tbody.append(mk('tr', mk('td.-full', {colspan: 5},
`Internal error while trying to capture screenshot: ${e}`)));
}
}
*/
};
let replay = stored_level.replay;
level = new Level(stored_level, compat);
level.sfx = dummy_sfx;
level.undo_enabled = false; // slight performance boost
replay.configure_level(level);
while (true) {
let input = replay.get(level.tic_counter);
level.advance_tic(input);
if (level.state === 'success') {
if (level.tic_counter < replay.duration - 10) {
// Early exit is dubious (e.g. this happened sometimes before multiple
// players were implemented correctly)
return make_result('early', "Won early", true);
}
else {
return make_result('success', "Won");
}
}
else if (level.state === 'failure') {
return make_result('failure', "Lost", true);
}
else if (level.tic_counter >= replay.duration + 220) {
// This threshold of 11 seconds was scientifically calculated by noticing that
// the TWS of Southpole runs 11 seconds past its last input
return make_result('short', "Out of input", true);
}
// FIXME allegedly it's possible to get a canvas working in node...
if (level.tic_counter % 20 === 1) {
// XXX
/*
if (include_canvas && level) {
try {
let tileset = this.conductor.choose_tileset_for_level(level.stored_level);
this.renderer.set_tileset(tileset);
let canvas = mk('canvas', {
width: Math.min(this.renderer.canvas.width, level.size_x * tileset.size_x),
height: Math.min(this.renderer.canvas.height, level.size_y * tileset.size_y),
});
this.renderer.set_level(level);
this.renderer.set_active_player(level.player);
this.renderer.draw();
canvas.getContext('2d').drawImage(
this.renderer.canvas, 0, 0,
this.renderer.canvas.width, this.renderer.canvas.height);
tbody.append(mk('tr', mk('td.-full', {colspan: 5}, canvas)));
}
catch (e) {
console.error(e);
tbody.append(mk('tr', mk('td.-full', {colspan: 5},
`Internal error while trying to capture screenshot: ${e}`)));
}
if (handle.cancel) {
return make_result('interrupted', "Interrupted");
this.current_status.textContent = `Interrupted on level ${i + 1}/${num_levels}; ${num_passed} passed`;
return;
}
*/
if (level) {
total_tics += level.tic_counter;
// Don't run for more than 100ms at a time, to avoid janking the browser...
// TOO much. I mean, we still want it to reflow the stuff we've added, but
// we also want to be pretty aggressive so this finishes quickly
// XXX unnecessary headless
/*
let now = performance.now();
if (now - last_pause > 100) {
await util.sleep(4);
last_pause = now;
}
*/
}
}
}
// Stuff that's related to testing a level, but is not actually testing a level
function test_level_wrapper(pack, level_index, compat) {
let result;
let stored_level;
try {
stored_level = pack.load_level(level_index);
if (! stored_level.has_replay) {
result = { type: 'no-replay', short_status: "No replay" };
}
else {
result = test_level(stored_level, compat);
}
}
catch (e) {
console.error(e);
result = {
type: 'error',
short_status: "Error",
time_simulated: null,
tics_simulated: null,
exception: e,
};
if (level_filter && ! level_filter.has(i + 1)) {
record_result('skipped', "Skipped");
continue;
}
try {
stored_level = pack.load_level(i);
if (! stored_level.has_replay) {
record_result('no-replay', "No replay");
num_missing += 1;
continue;
}
// TODO? this.current_status.textContent = `Testing level ${i + 1}/${num_levels} ${stored_level.title}...`;
let replay = stored_level.replay;
level = new Level(stored_level, compat);
level.sfx = dummy_sfx;
level.undo_enabled = false; // slight performance boost
replay.configure_level(level);
while (true) {
let input = replay.get(level.tic_counter);
level.advance_tic(input);
if (level.state === 'success') {
if (level.tic_counter < replay.duration - 10) {
// Early exit is dubious (e.g. this happened sometimes before multiple
// players were implemented correctly)
record_result('early', "Won early", true);
}
else {
record_result('success', "Won");
}
num_passed += 1;
break;
}
else if (level.state === 'failure') {
record_result('failure', "Lost", true);
break;
}
else if (level.tic_counter >= replay.duration + 220) {
// This threshold of 11 seconds was scientifically calculated by noticing that
// the TWS of Southpole runs 11 seconds past its last input
record_result('short', "Out of input", true);
break;
}
if (level.tic_counter % 20 === 1) {
// XXX
/*
if (handle.cancel) {
record_result('interrupted', "Interrupted");
this.current_status.textContent = `Interrupted on level ${i + 1}/${num_levels}; ${num_passed} passed`;
return;
}
*/
// Don't run for more than 100ms at a time, to avoid janking the browser...
// TOO much. I mean, we still want it to reflow the stuff we've added, but
// we also want to be pretty aggressive so this finishes quickly
// XXX unnecessary headless
/*
let now = performance.now();
if (now - last_pause > 100) {
await util.sleep(4);
last_pause = now;
}
*/
}
}
}
catch (e) {
console.error(e);
// FIXME this does not seem to work
record_result(
'error', "Error", true,
`Replay failed due to internal error (see console for traceback): ${e}`);
}
}
let total_real_elapsed = (performance.now() - t0) / 1000;
stdout.write(`${ANSI_RESET} ${num_passed}/${num_levels - num_missing}\n`);
for (let failure of failures) {
let short_status = failure.short_status;
if (failure.token === 'failure') {
short_status += ": ";
short_status += failure.fail_reason;
}
let parts = [
String(failure.index + 1).padStart(5),
pad(failure.title.replace(/[\r\n]+/, " "), 32),
RESULT_TYPES[failure.token].color + pad(short_status, 20) + ANSI_RESET,
];
if (failure.time_simulated !== null) {
parts.push("ran for" + util.format_duration(failure.time_simulated).padStart(6, " "));
}
if (failure.token === 'failure') {
parts.push(" with" + util.format_duration(failure.time_expected - failure.time_simulated).padStart(6, " ") + " still to go");
}
stdout.write(parts.join(" ") + "\n");
}
return {
num_passed,
num_missing,
num_failed: num_levels - num_passed - num_missing,
time_elapsed: total_real_elapsed,
time_simulated: total_tics / 20,
};
result.level_index = level_index;
result.time_expected = stored_level && stored_level.has_replay ? stored_level.replay.duration / 20 : null;
result.title = stored_level ? stored_level.title : "[load error]";
return result;
}
async function _scan_source(source) {
@ -315,6 +241,222 @@ async function _scan_source(source) {
// TODO else...? complain we couldn't find anything? list what we did find?? idk
}
async function load_pack(testdef) {
let pack;
if ((await stat(testdef.pack_path)).isDirectory()) {
let source = new LocalDirectorySource(testdef.pack_path);
pack = await _scan_source(source);
}
else {
let pack_data = await readFile(testdef.pack_path);
if (testdef.pack_path.match(/[.]zip$/)) {
let source = new util.ZipFileSource(pack_data.buffer);
pack = await _scan_source(source);
}
else {
pack = format_dat.parse_game(pack_data.buffer);
let solutions_data = await readFile(testdef.solutions_path);
let solutions = format_tws.parse_solutions(solutions_data.buffer);
pack.level_replays = solutions.levels;
}
}
if (! pack.title) {
let match = testdef.pack_path.match(/(?:^|\/)([^/.]+)(?:\..*)?\/?$/);
if (match) {
pack.title = match[1];
}
else {
pack.title = testdef.pack_path;
}
}
return pack;
}
async function main_worker(testdef) {
// We have to load the pack separately in every thread
let pack = await load_pack(testdef);
let ruleset = testdef.ruleset;
let compat = compat_flags_for_ruleset(ruleset);
let t = performance.now();
parentPort.on('message', level_index => {
//console.log("idled for", (performance.now() - t) / 1000);
parentPort.postMessage(test_level_wrapper(pack, level_index, compat));
t = performance.now();
});
}
// the simplest pool in the world
async function* run_in_thread_pool(num_workers, worker_data, items) {
let next_index = 0;
let workers = [];
let result_available_resolve;
let result_available = new Promise(resolve => {
result_available_resolve = resolve;
});
for (let i = 0; i < num_workers; i++) {
let worker = new Worker(new URL(import.meta.url), {
workerData: worker_data,
});
let waiting_on_index = null;
let process_next = () => {
if (next_index < items.length) {
let item = items[next_index];
next_index += 1;
worker.postMessage(item);
}
};
worker.on('message', result => {
result_available_resolve(result);
process_next();
});
process_next();
workers.push(worker);
}
try {
for (let i = 0; i < items.length; i++) {
let result = await result_available;
result_available = new Promise(resolve => {
result_available_resolve = resolve;
});
yield result;
}
}
finally {
for (let worker of workers) {
worker.terminate();
}
}
}
// well maybe this is simpler
async function* dont_run_in_thread_pool(num_workers, testdef, items) {
let pack = await load_pack(testdef);
let ruleset = testdef.ruleset;
let compat = compat_flags_for_ruleset(ruleset);
for (let level_index of items) {
yield test_level_wrapper(pack, level_index, compat);
}
}
async function test_pack(testdef) {
let pack = await load_pack(testdef);
let ruleset = testdef.ruleset;
let level_filter = testdef.level_filter;
let num_levels = pack.level_metadata.length;
let columns = stdout.columns || 80;
// 20 for title, 1 for space, the dots, 1 for space, 9 for succeeded/total, 1 for padding
let title_width = 20;
let dots_per_row = columns - title_width - 1 - 1 - 9 - 1;
// TODO factor out the common parts maybe?
stdout.write(pad(`${pack.title} (${ruleset})`, title_width) + " ");
let indices = [];
let num_dot_lines = 1;
let previous_type = null;
for (let i = 0; i < num_levels; i++) {
if (i > 0 && i % dots_per_row === 0) {
stdout.write("\n");
stdout.write(" ".repeat(title_width + 1));
num_dot_lines += 1;
}
let type = (level_filter && ! level_filter.has(i + 1)) ? 'skipped' : 'pending';
if (type !== previous_type) {
stdout.write(RESULT_TYPES[type].color);
}
stdout.write(RESULT_TYPES[type].symbol);
previous_type = type;
if (type === 'pending') {
indices.push(i);
}
}
ansi_cursor_move(0, -(num_dot_lines - 1));
stdout.write(`\x1b[${title_width + 2}G`);
// We really really don't want to have only a single thread left running at the end on a single
// remaining especially-long replay, so it would be nice to run the levels in reverse order of
// complexity. But that sounds hard so instead just run them backwards, since the earlier
// levels in any given pack tend to be easier.
indices.reverse();
let num_passed = 0;
let num_missing = 0;
let total_tics = 0;
let t0 = performance.now();
let last_pause = t0;
let failures = [];
for await (let result of run_in_thread_pool(4, testdef, indices)) {
let result_stuff = RESULT_TYPES[result.type];
let col = result.level_index % dots_per_row;
let row = Math.floor(result.level_index / dots_per_row);
ansi_cursor_move(col, row);
stdout.write(result_stuff.color + result_stuff.symbol);
ansi_cursor_move(-(col + 1), -row);
if (result.tics_simulated) {
total_tics += result.tics_simulated;
}
if (result.type === 'no-replay') {
num_missing += 1;
}
else if (result.type === 'success' || result.type === 'early') {
num_passed += 1;
}
else {
failures.push(result);
}
}
let total_real_elapsed = (performance.now() - t0) / 1000;
ansi_cursor_move(dots_per_row + 1, 0);
stdout.write(`${ANSI_RESET} ${num_passed}/${num_levels - num_missing}`);
ansi_cursor_move(0, num_dot_lines - 1);
stdout.write("\n");
failures.sort((a, b) => a.level_index - b.level_index);
for (let failure of failures) {
let short_status = failure.short_status;
if (failure.type === 'failure') {
short_status += ": ";
short_status += failure.fail_reason;
}
let parts = [
String(failure.level_index + 1).padStart(5),
pad(failure.title.replace(/[\r\n]+/, " "), 32),
RESULT_TYPES[failure.type].color + pad(short_status, 20) + ANSI_RESET,
];
if (failure.time_simulated !== null) {
parts.push("ran for" + util.format_duration(failure.time_simulated).padStart(6, " "));
}
if (failure.type === 'failure') {
parts.push(" with" + util.format_duration(failure.time_expected - failure.time_simulated).padStart(6, " ") + " still to go");
}
stdout.write(parts.join(" ") + "\n");
}
return {
num_passed,
num_missing,
num_failed: failures.length,
// FIXME should maybe count the thread time if we care about actual game speedup
time_elapsed: total_real_elapsed,
time_simulated: total_tics / 20,
};
}
// -------------------------------------------------------------------------------------------------
const USAGE = `\
@ -330,7 +472,7 @@ may be run with different compat modes.
don't support built-in replays, this must be a TWS file
-l level range to play back; either 'all' or a string like '1-4,10'
-f force the next argument to be interpreted as a file path, if for
some perverse reason you have a level file named '-c'
some perverse reason you have a level file named '-c'
-h, --help ignore other arguments and show this message
Supports the same filetypes as Lexy's Labyrinth: DAT/CCL, C2M, or a directory
@ -429,37 +571,7 @@ async function main() {
time_simulated: 0,
};
for (let testdef of tests) {
let pack;
if ((await stat(testdef.pack_path)).isDirectory()) {
let source = new LocalDirectorySource(testdef.pack_path);
pack = await _scan_source(source);
}
else {
let pack_data = await readFile(testdef.pack_path);
if (testdef.pack_path.match(/[.]zip$/)) {
let source = new util.ZipFileSource(pack_data.buffer);
pack = await _scan_source(source);
}
else {
pack = format_dat.parse_game(pack_data.buffer);
let solutions_data = await readFile(testdef.solutions_path);
let solutions = format_tws.parse_solutions(solutions_data.buffer);
pack.level_replays = solutions.levels;
}
}
if (! pack.title) {
let match = testdef.pack_path.match(/(?:^|\/)([^/.]+)(?:\..*)?\/?$/);
if (match) {
pack.title = match[1];
}
else {
pack.title = testdef.pack_path;
}
}
let result = await test_pack(pack, testdef.ruleset, testdef.level_filter);
let result = await test_pack(testdef);
for (let key of Object.keys(overall)) {
overall[key] += result[key];
}
@ -471,4 +583,10 @@ async function main() {
stdout.write(`Simulated ${util.format_duration(overall.time_simulated)} of game time in ${util.format_duration(overall.time_elapsed)}, speed of ${(overall.time_simulated / overall.time_elapsed).toFixed(1)}×\n`);
}
main();
if (isMainThread) {
main();
}
else {
main_worker(workerData);
}

50
js/headless/lib.js Normal file
View File

@ -0,0 +1,50 @@
import { opendir, readFile } from 'fs/promises';
//import canvas from 'canvas';
//import CanvasRenderer from '../renderer-canvas.js';
import * as util from '../util.js';
/*
export class NodeCanvasRenderer extends CanvasRenderer {
static make_canvas(w, h) {
return canvas.createCanvas(w, h);
}
}
*/
export class LocalDirectorySource extends util.FileSource {
constructor(root) {
super();
this.root = root;
this.files = {};
this._loaded_promise = this._scan_dir('/');
}
async _scan_dir(path) {
let dir = await opendir(this.root + path);
for await (let dirent of dir) {
if (dirent.isDirectory()) {
await this._scan_dir(path + dirent.name + '/');
}
else {
let filepath = path + dirent.name;
this.files[filepath.toLowerCase()] = filepath;
if (this.files.size > 2000)
throw `way, way too many files in local directory source ${this.root}`;
}
}
}
async get(path) {
let realpath = this.files[path.toLowerCase()];
if (realpath) {
return (await readFile(this.root + realpath)).buffer;
}
else {
throw new Error(`No such file: ${path}`);
}
}
}

70
js/headless/render.mjs Normal file
View File

@ -0,0 +1,70 @@
import { readFile, writeFile } from 'fs/promises';
import * as process from 'process';
import canvas from 'canvas';
import minimist from 'minimist';
import * as format_c2g from '../format-c2g.js';
import { infer_tileset_from_image } from '../tileset.js';
import { NodeCanvasRenderer } from './lib.js';
const USAGE = `\
Usage: render.mjs [OPTION]... LEVELFILE OUTFILE
Renders the level contained in LEVELFILE to a PNG and saves it to OUTFILE.
Arguments:
-t FILE path to a tileset to use
-e render in editor mode: use the revealed forms of tiles and
show facing directions
-l NUM choose the level number to render, if LEVELFILE is a pack
[default: 1]
-r REGION specify the region to render; see below
REGION may be one of:
initial an area the size of the level's viewport, centered on the
player's initial position
all the entire level
WxH an area W by H, centered on the player's initial position
...etc...
`;
async function main() {
let args = minimist(process.argv.slice(2), {
alias: {
tileset: ['t'],
},
});
// assert _.length is 2
let [pack_path, dest_path] = args._;
// TODO i need a more consistent and coherent way to turn a path into a level pack, currently
// this is only a single c2m
let pack_data = await readFile(pack_path);
let stored_level = format_c2g.parse_level(pack_data.buffer);
let img = await canvas.loadImage(args.tileset ?? 'tileset-lexy.png');
let tileset = infer_tileset_from_image(img);
let renderer = new NodeCanvasRenderer(tileset);
renderer.set_level(stored_level);
let i = stored_level.linear_cells.findIndex(cell => cell.some(tile => tile && tile.type.is_real_player));
if (i < 0) {
console.log("???");
process.stderr.write("error: no players in this level\n");
process.exit(1);
}
let [x, y] = stored_level.scalar_to_coords(i);
let w = stored_level.viewport_size;
let h = w;
// TODO this is probably duplicated from the renderer, and could also be reused in the editor
// TODO handle a map smaller than the viewport
let x0 = Math.max(0, x - w / 2);
let y0 = Math.max(0, y - h / 2);
renderer.draw_static_region(x0, y0, x0 + w, y0 + h);
await writeFile(dest_path, renderer.canvas.toBuffer());
}
main();

View File

@ -234,8 +234,11 @@ export class DialogOverlay extends Overlay {
this.header.append(mk('h1', {}, title));
}
add_button(label, onclick) {
add_button(label, onclick, is_default) {
let button = mk('button', {type: 'button'}, label);
if (is_default) {
button.classList.add('button-bright');
}
button.addEventListener('click', onclick);
this.footer.append(button);
return button;
@ -252,9 +255,9 @@ export class AlertOverlay extends DialogOverlay {
super(conductor);
this.set_title(title);
this.main.append(mk('p', {}, message));
this.add_button("a'ight", ev => {
this.add_button("a'ight", () => {
this.close();
});
}, true);
}
}
@ -267,7 +270,7 @@ export class ConfirmOverlay extends DialogOverlay {
this.add_button("yep", ev => {
this.close();
what();
});
}, true);
this.add_button("nope", ev => {
this.close();
});

1352
js/main.js

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,12 @@
import { DIRECTIONS, LAYERS } from './defs.js';
import { mk } from './util.js';
import * as util from './util.js';
import { DrawPacket } from './tileset.js';
import TILE_TYPES from './tiletypes.js';
class CanvasRendererDrawPacket extends DrawPacket {
constructor(renderer, ctx, perception, clock, update_progress, update_rate) {
super(perception, renderer.hide_logic, clock, update_progress, update_rate);
this.renderer = renderer;
export class CanvasDrawPacket extends DrawPacket {
constructor(tileset, ctx, perception, hide_logic, clock, update_progress, update_rate) {
super(perception, hide_logic, clock, update_progress, update_rate);
this.tileset = tileset;
this.ctx = ctx;
// Canvas position of the cell being drawn
this.x = 0;
@ -14,20 +14,17 @@ class CanvasRendererDrawPacket extends DrawPacket {
// Offset within the cell, for actors in motion
this.offsetx = 0;
this.offsety = 0;
// Compatibility settings
this.use_cc2_anim_speed = renderer.use_cc2_anim_speed;
this.show_facing = renderer.show_facing;
}
blit(tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) {
this.renderer.blit(this.ctx,
this.tileset.blit_to_canvas(this.ctx,
tx + mx, ty + my,
this.x + this.offsetx + mdx, this.y + this.offsety + mdy,
mw, mh);
}
blit_aligned(tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) {
this.renderer.blit(this.ctx,
this.tileset.blit_to_canvas(this.ctx,
tx + mx, ty + my,
this.x + mdx, this.y + mdy,
mw, mh);
@ -76,7 +73,42 @@ export class CanvasRenderer {
// This is here so command-line Node stuff can swap it out for the canvas package
static make_canvas(w, h) {
return mk('canvas', {width: w, height: h});
return util.mk('canvas', {width: w, height: h});
}
// Draw a single tile, or even the name of a tile type. Either a canvas or a context may be given.
// If neither is given, a new canvas is returned.
static draw_single_tile(tileset, name_or_tile, canvas = null, x = 0, y = 0) {
let ctx;
if (! canvas) {
canvas = this.make_canvas(tileset.size_x, tileset.size_y);
ctx = canvas.getContext('2d');
}
else if (canvas instanceof CanvasRenderingContext2D) {
ctx = canvas;
canvas = ctx.canvas;
}
else {
ctx = canvas.getContext('2d');
}
let name, tile;
if (typeof name_or_tile === 'string' || name_or_tile instanceof String) {
name = name_or_tile;
tile = null;
}
else {
tile = name_or_tile;
name = tile.type.name;
}
// Individual tile types always reveal what they are
let packet = new CanvasDrawPacket(tileset, ctx, 'palette');
packet.x = x;
packet.y = y;
tileset.draw_type(name, tile, packet);
return canvas;
}
set_level(level) {
@ -148,16 +180,6 @@ export class CanvasRenderer {
return [x, y];
}
// Draw to a canvas using tile coordinates
blit(ctx, sx, sy, dx, dy, w = 1, h = w) {
let tw = this.tileset.size_x;
let th = this.tileset.size_y;
ctx.drawImage(
this.tileset.image,
sx * tw, sy * th, w * tw, h * th,
dx * tw, dy * th, w * tw, h * th);
}
_adjust_viewport_if_dirty() {
if (! this.viewport_dirty)
return;
@ -185,8 +207,11 @@ export class CanvasRenderer {
// game starts, because we're trying to interpolate backwards from 0, hence the Math.max()
let clock = (this.level.tic_counter ?? 0) + (
(this.level.frame_offset ?? 0) + (update_progress - 1) * this.update_rate) / 3;
let packet = new CanvasRendererDrawPacket(
this, this.ctx, this.perception, Math.max(0, clock), update_progress, this.update_rate);
let packet = new CanvasDrawPacket(
this.tileset, this.ctx, this.perception, this.hide_logic,
Math.max(0, clock), update_progress, this.update_rate);
packet.use_cc2_anim_speed = this.use_cc2_anim_speed;
packet.show_facing = this.show_facing;
let tw = this.tileset.size_x;
let th = this.tileset.size_y;
@ -355,6 +380,8 @@ export class CanvasRenderer {
draw_rewind_effect(clock) {
// Shift several rows over in a recurring pattern, like a VHS, whatever that is
let rewind_start = clock / 20 % 1;
// Draw noisy white stripes in there too
this.ctx.save();
for (let chunk = 0; chunk < 4; chunk++) {
let y = Math.floor(this.canvas.height * (chunk + rewind_start) / 4);
for (let dy = 1; dy < 5; dy++) {
@ -362,8 +389,22 @@ export class CanvasRenderer {
this.canvas,
0, y + dy, this.canvas.width, 1,
-dy * dy, y + dy, this.canvas.width, 1);
this.ctx.beginPath();
this.ctx.moveTo(0, y + dy + 0.5);
this.ctx.lineTo(this.canvas.width, y + dy + 0.5);
let alpha = (0.9 - y / this.canvas.height * 0.25) * ((dy - 1) / 3);
this.ctx.strokeStyle = `rgba(100%, 100%, 100%, ${alpha})`;
this.ctx.setLineDash([
util.random_range(4, 20),
util.random_range(2, 6),
util.random_range(4, 20),
util.random_range(2, 6),
]);
this.ctx.stroke();
}
}
this.ctx.restore();
}
// Used by the editor and map previews. Draws a region of the level (probably a StoredLevel),
@ -386,7 +427,8 @@ export class CanvasRenderer {
width = width ?? this.level.size_x;
cells = cells ?? this.level.linear_cells;
let packet = new CanvasRendererDrawPacket(this, ctx, perception);
let packet = new CanvasDrawPacket(this.tileset, ctx, perception);
packet.show_facing = show_facing;
for (let x = x0; x <= x1; x++) {
for (let y = y0; y <= y1; y++) {
let cell = cells[y * width + x];
@ -441,7 +483,8 @@ export class CanvasRenderer {
let ctx = canvas.getContext('2d');
// Individual tile types always reveal what they are
let packet = new CanvasRendererDrawPacket(this, ctx, 'palette');
let packet = new CanvasDrawPacket(this.tileset, ctx, 'palette');
packet.show_facing = this.show_facing;
packet.x = x;
packet.y = y;
this.tileset.draw_type(name, tile, packet);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,10 +4,37 @@ import * as fflate from './vendor/fflate.js';
export class LLError extends Error {}
// Random choice
export function random_range(a, b = null) {
if (b === null) {
b = a;
a = 0;
}
return a + Math.floor(Math.random() * (b - a));
}
export function random_choice(list) {
return list[Math.floor(Math.random() * list.length)];
}
export function random_shuffle(list) {
// KnuthFisherYates, of course
for (let i = list.length - 1; i > 0; i--) {
let j = Math.floor(Math.random() * (i + 1));
[list[i], list[j]] = [list[j], list[i]];
}
}
export function setdefault(map, key, defaulter) {
if (map.has(key)) {
return map.get(key);
}
else {
let value = defaulter();
map.set(key, value);
return value;
}
}
// DOM stuff
function _mk(el, children) {
@ -167,11 +194,11 @@ export function promise_event(element, success_event, failure_event) {
}
export async function fetch(url) {
export async function fetch(url, response_type = 'arraybuffer') {
let xhr = new XMLHttpRequest;
let promise = promise_event(xhr, 'load', 'error');
xhr.open('GET', url);
xhr.responseType = 'arraybuffer';
xhr.responseType = response_type;
xhr.send();
await promise;
if (xhr.status !== 200)
@ -258,52 +285,56 @@ export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) {
let goal_x = Math.floor(x1);
let goal_y = Math.floor(y1);
// Use a modified Bresenham. Use mirroring to move everything into the
// first quadrant, then split it into two octants depending on whether dx
// or dy increases faster, and call that the main axis. Track an "error"
// value, which is the (negative) distance between the ray and the next
// grid line parallel to the main axis, but scaled up by dx. Every
// iteration, we move one cell along the main axis and increase the error
// value by dy (the ray's slope, scaled up by dx); when it becomes
// positive, we can subtract dx (1) and move one cell along the minor axis
// as well. Since the main axis is the faster one, we'll never traverse
// more than one cell on the minor axis for one cell on the main axis, and
// this readily provides every cell the ray hits in order.
// Use a modified Bresenham. Use mirroring to move everything into the first quadrant, then
// split it into two octants depending on whether dx or dy increases faster, and call that the
// main axis. Track an "error" value, which is the (negative) distance between the ray and the
// next grid line parallel to the main axis, but scaled up by dx. Every iteration, we move one
// cell along the main axis and increase the error value by dy (the ray's slope, scaled up by
// dx); when it becomes positive, we can subtract dx (1) and move one cell along the minor axis
// as well. Since the main axis is the faster one, we'll never traverse more than one cell on
// the minor axis for one cell on the main axis, and this readily provides every cell the ray
// hits in order.
// Based on: http://www.idav.ucdavis.edu/education/GraphicsNotes/Bresenhams-Algorithm/Bresenhams-Algorithm.html
// Setup: map to the first quadrant. The "offsets" are the distance
// between the starting point and the next grid point.
// Setup: map to the first quadrant. The "offsets" are the distance between the starting point
// and the next grid point.
let step_a = 1;
let offset_x = 1 - (x0 - a);
if (offset_x === 0) {
// Zero offset means we're on a grid line, so we're a full cell away from the next grid line
offset_x = 1;
}
if (dx < 0) {
dx = -dx;
step_a = -step_a;
offset_x = 1 - offset_x;
}
else if (offset_x === 0) {
// Zero offset means we're on a grid line, so we're a full cell away from the next grid line
// (if we're moving forward; if we're moving backward, the next cell really is 0 away)
offset_x = 1;
}
let step_b = 1;
let offset_y = 1 - (y0 - b);
if (offset_y === 0) {
offset_y = 1;
}
if (dy < 0) {
dy = -dy;
step_b = -step_b;
offset_y = 1 - offset_y;
}
else if (offset_y === 0) {
offset_y = 1;
}
let err = dy * offset_x - dx * offset_y;
if (dx > dy) {
// Main axis is x/a
while (min_a <= a && a <= max_a && min_b <= b && b <= max_b) {
yield [a, b];
if (a === goal_x && b === goal_y)
if (a === goal_x && b === goal_y) {
yield [a, b];
return;
}
// When we go exactly through a corner, we cross two grid lines, but between them we
// enter a cell the line doesn't actually pass through. That happens here, when err ===
// dx, because it was 0 last loop
if (err !== dy) {
yield [a, b];
}
if (err > 0) {
err -= dx;
@ -320,9 +351,13 @@ export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) {
err = -err;
// Main axis is y/b
while (min_a <= a && a <= max_a && min_b <= b && b <= max_b) {
yield [a, b];
if (a === goal_x && b === goal_y)
if (a === goal_x && b === goal_y) {
yield [a, b];
return;
}
if (err !== dx) {
yield [a, b];
}
if (err > 0) {
err -= dy;
@ -337,6 +372,33 @@ export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) {
}
}
// Baby's first bit vector
export class BitVector {
constructor(size) {
this.array = new Uint32Array(Math.ceil(size / 32));
}
get(bit) {
let i = Math.floor(bit / 32);
let b = bit % 32;
return (this.array[i] & (1 << b)) !== 0;
}
set(bit) {
let i = Math.floor(bit / 32);
let b = bit % 32;
this.array[i] |= (1 << b);
}
clear(bit) {
let i = Math.floor(bit / 32);
let b = bit % 32;
this.array[i] &= ~(1 << b);
}
}
// Root class to indirect over where we might get files from
// - a pool of uploaded in-memory files
// - a single uploaded zip file
@ -349,6 +411,9 @@ export class FileSource {
// Get a file's contents as an ArrayBuffer
async get(path) {}
// Get a list of all files under here, recursively
// async *iter_all_files() {}
}
// Files we have had uploaded one at a time (note that each upload becomes its own source)
export class FileFileSource extends FileSource {
@ -369,6 +434,10 @@ export class FileFileSource extends FileSource {
return Promise.reject(new Error(`No such file was provided: ${path}`));
}
}
iter_all_files() {
return Object.keys(this.files);
}
}
// Regular HTTP fetch
export class HTTPFileSource extends FileSource {
@ -383,6 +452,58 @@ export class HTTPFileSource extends FileSource {
return fetch(url);
}
}
// Regular HTTP fetch, but for a directory structure from nginx's index module
export class HTTPNginxDirectorySource extends FileSource {
// Should be given a URL object as a root
constructor(root) {
super();
this.root = root;
if (! this.root.pathname.endsWith('/')) {
this.root.pathname += '/';
}
}
get(path) {
// TODO should strip off multiple of these
// TODO and canonicalize, and disallow going upwards
if (path.startsWith('/')) {
path = path.substring(1);
}
let url = new URL(path, this.root);
return fetch(url);
}
async *iter_all_files() {
let fetch_count = 0;
let paths = [''];
while (paths.length > 0) {
let next_paths = [];
for (let path of paths) {
if (fetch_count >= 50) {
throw new Error("Too many subdirectories to fetch one at a time; is this really a single CC2 set?");
}
let response = await fetch(new URL(path, this.root), 'text');
fetch_count += 1;
let doc = document.implementation.createHTMLDocument();
doc.write(response);
doc.close();
for (let link of doc.querySelectorAll('a')) {
let subpath = link.getAttribute('href');
if (subpath === '../') {
continue;
}
else if (subpath.endsWith('/')) {
next_paths.push(path + subpath);
}
else {
yield path + subpath;
}
}
}
paths = next_paths;
}
}
}
// WebKit Entry interface
// XXX this does not appear to work if you drag in a link to a directory but that is probably beyond
// my powers to fix
@ -433,6 +554,11 @@ export class EntryFileSource extends FileSource {
let file = await new Promise((res, rej) => entry.file(res, rej));
return await file.arrayBuffer();
}
async iter_all_files() {
await this._loaded_promise;
return Object.keys(this.files);
}
}
// Zip files, using fflate
// TODO somewhat unfortunately fflate only supports unzipping the whole thing at once, not
@ -455,4 +581,8 @@ export class ZipFileSource extends FileSource {
return file.buffer;
}
iter_all_files() {
return Object.keys(this.files);
}
}

BIN
levels/CCLP5.ccl Normal file

Binary file not shown.

BIN
levels/previews/cclp5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

BIN
sfx/mmf2.ogg Normal file

Binary file not shown.

BIN
sfx/step-dirt.ogg Normal file

Binary file not shown.

Binary file not shown.

BIN
sfx/step-floor1.ogg Normal file

Binary file not shown.

BIN
sfx/step-floor2.ogg Normal file

Binary file not shown.

480
style.css
View File

@ -12,18 +12,22 @@ body {
font-family: Ubuntu, Source Sans Pro, DejaVu Sans, sans-serif;
line-height: 1.33;
background: hsl(220, 5%, 5%);
background: hsl(var(--main-hue), 5%, 5%);
background-image: url(background.svg);
background-size: 12em;
color: #ececec;
--panel-bg-color: hsl(220, 10%, 15%);
--button-bg-color: hsl(220, 20%, 25%);
--main-hue: 340;
--hover-hue: 15;
--selected-hue: 320;
--panel-bg-color: hsl(var(--main-hue), 30%, 12.5%);
--button-bg-color: hsl(var(--main-hue), 60%, 25%);
--button-bg-gradient: linear-gradient(to bottom, var(--button-bg-shadow-color), transparent 75%);
--button-bg-shadow-color: #fff1;
--button-bg-hover-color: hsl(220, 30%, 30%);
--generic-bg-hover-on-white: hsl(220, 60%, 90%);
--generic-bg-selected-on-white: hsl(220, 60%, 85%);
--generic-border-selected-on-white: hsl(220, 60%, 75%);
--button-bg-hover-color: hsl(var(--hover-hue), 70%, 35%);
--generic-bg-hover-on-white: hsl(var(--hover-hue), 90%, 85%);
--generic-bg-selected-on-white: hsl(var(--selected-hue), 50%, 85%);
--generic-border-selected-on-white: hsl(var(--selected-hue), 60%, 75%);
}
/* Generic element styling */
@ -33,6 +37,7 @@ main[hidden] {
input[type=radio],
input[type=checkbox],
input[type=range] {
font-size: inherit;
margin: 0.125em;
vertical-align: middle;
}
@ -49,12 +54,13 @@ button,
font-family: inherit;
color: white;
background-color: var(--button-bg-color);
background-image: linear-gradient(to bottom, var(--button-bg-shadow-color), transparent 75%);
border: 1px solid hsl(220, 10%, 7.5%);
background-image: var(--button-bg-gradient);
border: 1px solid hsl(var(--main-hue), 10%, 2.5%);
box-shadow:
inset 0 0 1px 1px #fff2,
0 1px 1px hsl(220, 10%, 7.5%);
inset 0 0 0 1px #ffffff18,
0 1px 1px hsl(var(--main-hue), 10%, 7.5%);
border-radius: 0.25em;
text-shadow: 0 1px 0 #0006;
text-transform: lowercase;
cursor: pointer;
}
@ -69,13 +75,13 @@ button:active,
z-index: 1;
}
button:enabled.button-bright {
background-color: hsl(220, 50%, 25%);
background-color: hsl(var(--main-hue), 60%, 40%);
}
button:enabled.button-bright:hover {
background-color: hsl(220, 70%, 30%);
background-color: hsl(var(--hover-hue), 65%, 50%);
}
button:disabled {
color: #606060;
color: #808080;
background-color: #202020;
cursor: auto;
}
@ -86,7 +92,7 @@ button.button-big {
padding: 1em;
}
button.--button-glow-ok {
background: hsl(220, 100%, 50%);
background: hsl(var(--main-hue), 100%, 50%);
}
button.--button-glow {
transition: background-color 0.5s ease-out;
@ -100,6 +106,9 @@ button.--image {
button.--image img {
display: block;
}
select {
font-size: inherit;
}
h1, h2, h3, h4, h5, h6 {
font-weight: normal;
margin: 0;
@ -149,17 +158,17 @@ a:visited {
text-decoration: underline dotted;
}
a:link {
color: hsl(220, 50%, 75%);
color: hsl(var(--main-hue), 80%, 75%);
}
a:visited {
color: hsl(255, 50%, 75%);
color: hsl(270, 80%, 75%);
}
a:link:hover,
a:visited:hover {
text-decoration: underline;
}
a:active {
color: hsl(0, 50%, 60%);
color: hsl(0, 90%, 75%);
}
svg#svg-iconsheet {
@ -207,11 +216,11 @@ svg.svg-icon {
}
button.--pressed,
.radio-faux-button-set > label > input:checked + span {
background: hsl(220, 80%, 50%);
background: hsl(var(--main-hue), 80%, 50%);
box-shadow:
inset 0 1px 3px 1px hsl(220, 50%, 15%),
inset 0 0.25em 1em 0.5em hsl(220, 50%, 30%),
0 1px 1px hsl(220, 10%, 10%)
inset 0 1px 3px 1px hsl(var(--main-hue), 50%, 15%),
inset 0 0.25em 1em 0.5em hsl(var(--main-hue), 50%, 30%),
0 1px 1px hsl(var(--main-hue), 10%, 10%)
}
.button-row {
@ -251,7 +260,7 @@ button.--pressed,
min-width: 10vw;
border: 1px solid #444;
color: black;
background: hsl(220, 20%, 95%);
background: hsl(var(--main-hue), 20%, 95%);
box-shadow: 0 1px 3px 1px #0009;
}
.popup-menu > li {
@ -259,8 +268,8 @@ button.--pressed,
cursor: pointer;
}
.popup-menu > li:hover {
color: hsl(220, 90%, 10%);
background: hsl(220, 90%, 75%);
color: hsl(var(--hover-hue), 60%, 10%);
background: var(--generic-bg-hover-on-white);
}
.dialog {
display: flex;
@ -272,16 +281,20 @@ button.--pressed,
border: 1px solid black;
color: black;
background: #f4f4f4;
box-shadow: 0 1px 3px #000c;
box-shadow: 0 1px 6px #000c;
}
.dialog > header {
padding: 0.5em;
line-height: 1;
background: hsl(220, 20%, 40%);
background: linear-gradient(
hsl(var(--main-hue), 40%, 50%),
hsl(var(--main-hue), 50%, 45%));
border-bottom: 1px solid hsl(var(--main-hue), 60%, 30%);
color: white;
text-shadow: 0 2px 0 #0006;
}
.dialog > header h1 {
font-size: 1em;
font-size: 1.25em;
}
.dialog > footer {
display: flex;
@ -309,11 +322,21 @@ button.--pressed,
padding: 0.5em 1em;
}
.dialog a:link {
color: hsl(220, 50%, 50%);
color: hsl(var(--main-hue), 50%, 50%);
}
.dialog a:visited {
color: hsl(255, 50%, 50%);
}
.dialog code {
color: hsl(var(--main-hue), 50%, 30%);
}
.dialog h2 {
color: hsl(var(--main-hue), 75%, 25%);
border-bottom: 1px dotted hsl(var(--main-hue), 50%, 40%);
}
.dialog h2:nth-child(n+1) {
margin-top: 1rem;
}
dl.formgrid {
display: grid;
grid: auto-flow min-content / 1fr 4fr;
@ -324,7 +347,7 @@ dl.formgrid {
dl.formgrid > dt {
grid-column: 1;
text-align: right;
color: hsl(220, 50%, 25%);
color: hsl(var(--main-hue), 50%, 25%);
}
dl.formgrid > dd {
grid-column: 2;
@ -366,7 +389,7 @@ table.level-browser thead {
background: #f4f4f4; /* match dialog background */
}
table.level-browser thead tr th {
border-bottom: 2px solid hsl(220, 20%, 60%);
border-bottom: 2px solid hsl(var(--main-hue), 20%, 60%);
}
table.level-browser tfoot {
position: sticky;
@ -374,7 +397,7 @@ table.level-browser tfoot {
background: #f4f4f4; /* match dialog background */
}
table.level-browser tfoot tr th {
border-top: 2px solid hsl(220, 20%, 60%);
border-top: 2px solid hsl(var(--main-hue), 20%, 60%);
text-align: right;
}
table.level-browser th,
@ -413,7 +436,7 @@ table.level-browser tbody tr:hover {
background: var(--generic-bg-hover-on-white);
}
table.level-browser tbody tr:nth-child(10n) td {
border-bottom: 2px solid hsl(220, 20%, 80%);
border-bottom: 2px solid hsl(var(--main-hue), 20%, 80%);
}
@media (max-width: 600px) {
/* Unique media query: this is only necessary for VERY narrow screens */
@ -478,10 +501,10 @@ table.level-browser tbody tr:nth-child(10n) td {
border: none;
}
table.level-browser thead tr {
border-bottom: 2px solid hsl(220, 20%, 60%);
border-bottom: 2px solid hsl(var(--main-hue), 20%, 60%);
}
table.level-browser tfoot tr {
border-top: 2px solid hsl(220, 20%, 60%);
border-top: 2px solid hsl(var(--main-hue), 20%, 60%);
}
table.level-browser tbody tr {
border-bottom: 1px solid #ddd;
@ -490,7 +513,7 @@ table.level-browser tbody tr:nth-child(10n) td {
border: none;
}
table.level-browser tbody tr:nth-child(10n) {
border-bottom: 2px solid hsl(220, 20%, 80%);
border-bottom: 2px solid hsl(var(--main-hue), 20%, 80%);
}
}
@ -512,6 +535,9 @@ ul.compat-flags > li > label {
align-items: center;
gap: 0.25em;
}
ul.compat-flags > li > label > input[type=check] {
margin: 0.25em;
}
ul.compat-flags > li > label > span.-desc {
flex: 1;
}
@ -574,7 +600,26 @@ img.compat-icon,
.option-volume > input[type=range] {
flex: auto;
}
.option-tileset canvas {
table.option-tilesets th,
table.option-tilesets td {
padding: 0.25rem 0.5rem;
}
table.option-tilesets > tr > .-format {
font-size: 0.75em;
text-align: center;
}
table.option-tilesets > tr > .-slot {
padding: 0;
}
table.option-tilesets > tr > .-slot > label {
display: grid;
box-sizing: border-box;
min-width: 100%;
min-height: 100%;
padding: 0.5em 1em;
place-items: center;
}
table.option-tilesets canvas {
vertical-align: middle;
}
label.option {
@ -597,6 +642,9 @@ label.option .option-label {
.option-help.--visible {
/* TODO */
}
.dialog-options input[type=file][name=custom-tileset] {
display: none;
}
@media (max-width: 800px) {
.dialog {
@ -714,6 +762,11 @@ pre.stack-trace {
image-rendering: crisp-edges;
image-rendering: pixelated;
}
#main-compat > img {
height: 16px;
vertical-align: middle;
margin-right: 0.25em;
}
@media (orientation: portrait) and (max-width: 800px), (orientation: landscape) and (max-height: 600px) {
body > header {
@ -831,9 +884,11 @@ pre.stack-trace {
height: auto;
}
#splash h2 {
border-bottom: 1px solid #404040;
color: #909090;
text-shadow: 0 1px #0004;
color: hsl(var(--main-hue), 40%, 90%);
text-shadow: 0 1px #000c;
background: hsl(var(--main-hue), 40%, 25%);
margin: -1rem -1rem 1rem;
padding: 0.5rem 1rem;
}
#splash * + h2 {
margin-top: 1rem;
@ -970,7 +1025,9 @@ pre.stack-trace {
position: relative;
z-index: 1;
padding: 0.25em;
border: 1px solid hsl(220, 25%, 40%);
border: 1px solid hsl(var(--main-hue), 40%, 40%);
background: hsl(var(--main-hue), 30%, 5%);
box-shadow: 0 0 1px 1px #0009;
text-shadow: 0 1px 1px black;
text-align: center;
}
@ -985,11 +1042,11 @@ pre.stack-trace {
}
.played-pack-list .-progress > .-levels::before {
width: calc(var(--cleared) * 100%);
background: hsl(220, 25%, 30%);
background: hsl(var(--main-hue), 50%, 25%);
}
.played-pack-list .-progress > .-levels::after {
width: calc(var(--aidless) * 100%);
background: hsl(220, 25%, 40%);
background: hsl(var(--main-hue), 40%, 40%);
}
.played-pack-list .-progress > .-score {
grid-area: score;
@ -997,16 +1054,15 @@ pre.stack-trace {
.played-pack-list .-progress > .-time {
grid-area: time;
}
.played-pack-list .-progress > .-levels {
grid-area: levels;
}
.played-pack-list .-progress > .-score::before {
content: "Score: ";
color: #909090;
}
.played-pack-list .-progress > .-time::before {
content: "Time: ";
color: #909090;
}
.played-pack-list .-progress > .-score::before,
.played-pack-list .-progress > .-time::before {
color: hsl(var(--main-hue), 20%, 50%);
}
.played-pack-list .-editor-status {
display: flex;
@ -1129,7 +1185,7 @@ ol.packtest-summary > li {
font-weight: bold;
}
.packtest-dialog .grade-B {
color: hsl(220, 60%, 45%);
color: hsl(var(--main-hue), 60%, 45%);
font-weight: bold;
}
.packtest-dialog .grade-C {
@ -1234,6 +1290,13 @@ ol.packtest-summary > li {
#player button:disabled .keyhint {
display: none;
}
#player-controls button:enabled.control-restart {
/* Special shenanigans for holding R to restart */
--restart-progress: 0;
background-image: var(--button-bg-gradient), conic-gradient(
hsl(345, 60%, 40%) 0deg calc(var(--restart-progress) * 360deg),
transparent calc(var(--restart-progress) * 360deg) 360deg)
}
@media (orientation: portrait) {
/* On a portrait screen, put the controls on top */
#player-main {
@ -1256,17 +1319,9 @@ ol.packtest-summary > li {
padding: 0.25em 0.5em;
line-height: 1.33;
}
/* Hackily remove the <br>s in "turn based mode" */
#player-controls .radio-faux-button-set br {
display: none;
}
#player-actions {
justify-content: end;
}
#player-actions button svg {
display: inline-block;
margin: 0.125em;
}
#player button .keyhint {
top: -2em;
left: 0;
@ -1301,6 +1356,10 @@ ol.packtest-summary > li {
/* Hide key hints; there's nowhere to put them and they take up surprisingly a lot of space */
display: none;
}
#player-controls .radio-faux-button-set span {
/* "step mode" is real big */
font-size: 0.75em;
}
}
@media (orientation: landscape) and (max-height: 600px) {
/* On a small landscape screen, remove the music row (it matters!) */
@ -1347,7 +1406,7 @@ ol.packtest-summary > li {
row-gap: calc(var(--tile-height) * var(--scale) / 4);
padding: calc(var(--tile-height) * var(--scale) / 4) calc(var(--tile-width) * var(--scale) / 4);
background: hsl(220, 10%, 15%);
background: hsl(var(--main-hue), 10%, 15%);
box-shadow: 0 0.25em 1em black;
}
@ -1355,7 +1414,7 @@ ol.packtest-summary > li {
grid-area: level;
position: relative;
outline: 1px solid hsl(220, 10%, 5%);
outline: 1px solid hsl(var(--main-hue), 10%, 5%);
}
.level canvas {
display: block;
@ -1372,10 +1431,11 @@ ol.packtest-summary > li {
position: relative;
display: grid;
grid:
"pack" calc(1.25em * 1.25 * 1)
"pack" calc(1em * 1.25 * 1)
"level" calc(1.333em * 1.25 * 2)
"author" calc(1em * 1.25 * 1)
"space" 1fr
"score" 1.5em
"controls" 1.5em
;
align-items: center;
@ -1405,11 +1465,11 @@ body.--debug .player-overlay-message {
.player-overlay-message h1 {
/* Pack title, doesn't need to be too big */
grid-area: pack;
font-size: 1em;
font-size: 0.833em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: hsl(220, 25%, 60%);
color: hsl(var(--main-hue), 25%, 75%);
}
.player-overlay-message > h2 {
grid-area: level;
@ -1425,10 +1485,14 @@ body.--debug .player-overlay-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: hsl(220, 10%, 90%);
color: hsl(var(--main-hue), 10%, 90%);
}
.player-overlay-message > .-best-score {
grid-area: score;
align-self: flex-end;
}
.player-overlay-message > .scoreboard {
grid-row: author / space;
grid-row: author / score;
}
.player-overlay-message .-controls-hint {
grid-area: controls;
@ -1490,10 +1554,10 @@ body.--debug .player-overlay-message {
}
.player-overlay-message[data-reason=failure] {
background: hsla(330, 20%, 10%, 0.5);
background: radial-gradient(#0004, hsla(330, 10%, 10%, 0.5) 40%, hsl(330, 20%, 10%));
background: radial-gradient(hsla(330, 10%, 10%, 0.75) 40%, hsl(330, 20%, 10%));
}
.player-overlay-message[data-reason=success] {
background: radial-gradient(hsla(220, 60%, 5%, 0.75), 60%, hsla(220, 60%, 25%, 0.75));
background: radial-gradient(hsla(40, 80%, 10%, 0.75), hsla(40, 80%, 20%, 0.875) 80%, hsla(40, 80%, 30%, 0.875));
}
.player-overlay-message[data-reason=ended] {
/* Rearrange this entirely, to fit the ending image in */
@ -1506,7 +1570,7 @@ body.--debug .player-overlay-message {
;
overflow: hidden;
background: url(ending.png) no-repeat center center / cover;
box-shadow: inset 0 0 calc(4 * var(--tile-width)) hsl(220, 50%, 25%);
box-shadow: inset 0 0 calc(4 * var(--tile-width)) hsl(var(--main-hue), 50%, 25%);
}
.player-overlay-message[data-reason=ended] .mobile-pause-menu {
grid-area: menu;
@ -1561,6 +1625,9 @@ body.--debug .player-overlay-message {
margin: auto 5%;
font-weight: normal;
text-align: center;
/* this is a lot of stuff crammed into a small space, so prefer having more space between rows
* and less space between labels+values (which makes them more clearly related anyway) */
line-height: 1.1;
}
.scoreboard .-subscore {
grid-column: span 2;
@ -1592,11 +1659,11 @@ body.--debug .player-overlay-message {
}
.scoreboard .-total-score {
grid-column: span 3;
color: hsl(45, 50%, 75%);
color: hsl(45, 100%, 75%);
}
.scoreboard h4 {
font-size: 0.833em;
color: hsl(220, 10%, 80%);
font-size: 0.75em;
color: hsl(var(--main-hue), 10%, 60%);
}
.scoreboard .-total-score h4 {
color: hsl(30, 50%, 60%);
@ -1608,6 +1675,51 @@ body.--debug .player-overlay-message {
font-size: 1.333em;
}
/* Transparent container for displaying captions for captions */
.player-overlay-captions {
grid-area: level;
place-self: stretch;
position: relative;
/* above the message layer */
z-index: 3;
font-size: calc(0.75em * var(--scale));
pointer-events: none;
}
.player-overlay-captions > span.-caption {
position: absolute;
left: calc(var(--x-offset) * var(--tile-width) * var(--scale));
top: calc(var(--y-offset) * var(--tile-height) * var(--scale));
animation: 1s ease-in 1 forwards caption-fade;
font-weight: bold;
color: white;
/* Lol this sucks, please save me Tab */
/* TODO use an svg element for these instead? would also avoid overflow issues */
text-shadow:
-1px -1px black,
1px -1px black,
-1px 2px black,
1px 2px black,
/* one more to fix lowercase k! */
1px 0 black;
white-space: nowrap;
/* Anchor these to their absolute centers */
transform: translate(-50%, -50%);
}
@keyframes caption-fade {
0% {
opacity: 1;
}
75% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.player-level-number {
grid-area: number;
/* This is only for portrait, and mostly to fill space */
@ -1638,7 +1750,7 @@ body.--debug .player-overlay-message {
order: 2;
font-size: 0.75em;
line-height: 1;
color: hsl(220, 20%, 80%);
color: hsl(var(--main-hue), 20%, 80%);
}
.chips output,
.time output,
@ -1649,7 +1761,7 @@ body.--debug .player-overlay-message {
line-height: 1;
text-align: right;
font-family: monospace;
color: hsl(220, 20%, 60%);
color: hsl(var(--main-hue), 20%, 60%);
}
/* nb: the hex colors are all taken from the lexy palette */
.chips output {
@ -1677,7 +1789,7 @@ body.--debug .player-overlay-message {
.chips output.--done,
.time output.--frozen,
.bonus output {
color: hsl(220, 10%, 30%);
color: hsl(var(--main-hue), 10%, 30%);
}
#player.--bonus-visible .bonus output {
color: #e2c9ff;
@ -1689,7 +1801,7 @@ body.--debug .player-overlay-message {
display: flex;
flex-direction: column;
gap: 0.5em;
color: hsl(220, 20%, 80%);
color: hsl(var(--main-hue), 20%, 80%);
}
.player-rules p {
display: none;
@ -1713,9 +1825,9 @@ body.--debug .player-overlay-message {
overflow: hidden;
font-size: calc(var(--tile-height) * var(--scale) / 4);
font-family: serif;
color: hsl(220, 20%, 80%);
background: url(#svg-icon-hint) hsl(220, 10%, 10%);
border: 3px double hsl(220, 10%, 15%);
color: hsl(var(--main-hue), 20%, 80%);
background: url(#svg-icon-hint) hsl(var(--main-hue), 10%, 10%);
border: 3px double hsl(var(--main-hue), 10%, 15%);
}
#player-game-area > .player-hint-wrapper > .player-hint-bg-icon {
position: absolute;
@ -1918,7 +2030,7 @@ body.--debug #player-debug {
display: block;
}
#player-debug > .-inventory > button.-wide {
grid-column: span 5;
grid-column: span 3;
padding: 0.25em;
}
#player-debug .-buttons {
@ -2030,7 +2142,7 @@ body.--debug #player-debug {
padding: 0.33em 0.75em;
border: 1px solid black;
color: #d8d8d8;
background: hsl(220, 10%, 20%);
background: hsl(var(--main-hue), 10%, 20%);
box-shadow: 0 1px 2px 1px #0004;
opacity: 0.9;
@ -2129,7 +2241,7 @@ body.--debug #player-debug {
width: -moz-fit-content;
width: fit-content;
}
#editor .editor-canvas canvas {
#editor .editor-canvas canvas.editor-renderer-canvas {
display: block;
width: calc(var(--viewport-width) * var(--tile-width) * var(--scale));
--viewport-width: 9;
@ -2139,15 +2251,16 @@ body.--debug #player-debug {
/* SVG overlays */
svg.level-editor-overlay {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
inset: calc(-1 * var(--tile-width) * var(--scale)) calc(-1 * var(--tile-height) * var(--scale));
/* allow clicks to go through us! */
pointer-events: none;
/* not used to shrink us (absolute positioning does that), just to make the stroke width a
* consistent size at any zoom level */
--scale: 1;
/* default svg properties */
stroke-width: 0.0625;
--stroke-width: calc(0.125 / var(--scale));
stroke-width: calc(1px * var(--stroke-width));
fill: none;
}
svg.level-editor-overlay .overlay-transient {
@ -2156,25 +2269,39 @@ svg.level-editor-overlay .overlay-transient {
svg.level-editor-overlay .overlay-transient.--visible {
display: initial;
}
svg.level-editor-overlay rect.overlay-cursor {
x-stroke: hsla(220, 100%, 60%, 0.5);
fill: hsla(220, 100%, 75%, 0.25);
svg.level-editor-overlay rect.overlay-pencil-cursor {
stroke: hsla(var(--main-hue), 80%, 40%, 0.9);
fill: hsla(var(--main-hue), 100%, 75%, 0.25);
/* Automatically scale the cursor up just enough that the outline appears outside the cell,
* rather than straddling it */
transform: scale(calc(100% * (1 + var(--stroke-width))));
transform-origin: 0.5px 0.5px;
}
svg.level-editor-overlay rect.overlay-pending-selection {
stroke: hsla(220, 100%, 60%, 0.5);
fill: hsla(220, 100%, 75%, 0.25);
stroke: hsla(var(--selected-hue), 100%, 60%, 0.5);
fill: hsla(var(--selected-hue), 100%, 75%, 0.25);
}
svg.level-editor-overlay rect.overlay-selection {
stroke: #000c;
fill: hsla(220, 0%, 75%, 0.25);
stroke-dasharray: 0.125, 0.125;
animation: marching-ants 1s linear infinite;
svg.level-editor-overlay path.overlay-selection-background {
stroke: hsla(var(--selected-hue), 10%, 90%, 0.9);
fill: none;
pointer-events: none;
}
svg.level-editor-overlay path.overlay-selection {
stroke: hsla(var(--selected-hue), 10%, 10%, 0.75);
fill: hsla(var(--selected-hue), 50%, 75%, 0.375);
fill-rule: evenodd;
stroke-width: calc(0.125px / var(--scale));
stroke-dasharray: calc(0.125px / var(--scale)), calc(0.125px / var(--scale));
animation: marching-ants 0.5s linear infinite;
pointer-events: auto;
cursor: move;
}
svg.level-editor-overlay path.overlay-selection.--floating {
stroke: hsla(var(--selected-hue), 80%, 50%, 0.75);
}
@keyframes marching-ants {
0% {
stroke-dashoffset: 0.25;
stroke-dashoffset: calc(0.25px / var(--scale));
}
100% {
stroke-dashoffset: 0;
@ -2182,14 +2309,83 @@ svg.level-editor-overlay rect.overlay-selection {
}
#overlay-arrowhead {
fill: white;
fill: context-stroke;
}
svg.level-editor-overlay g.overlay-connection {
stroke: white;
stroke: #e4e4e4;
filter: url(#overlay-filter-outline);
}
svg.level-editor-overlay g.overlay-connection.--cursor {
stroke: hsl(90, 90%, 40%);
}
svg.level-editor-overlay g.overlay-connection[data-source=button_red] {
stroke: hsl(0, 90%, 60%);
}
svg.level-editor-overlay g.overlay-connection[data-source=button_brown] {
stroke: hsl(50, 90%, 50%);
}
svg.level-editor-overlay g.overlay-connection[data-source=button_orange] {
stroke: hsl(30, 90%, 60%);
}
svg.level-editor-overlay g.overlay-connection.--implicit line.-arrow {
stroke-dasharray: calc(0.25px / var(--scale)), calc(0.25px / var(--scale));
}
svg.level-editor-overlay g.overlay-connection line.-arrow {
marker-end: url(#overlay-arrowhead);
}
svg.level-editor-overlay g.overlay-circuitry {
filter: url(#overlay-filter-outline);
opacity: 0.75;
}
svg.level-editor-overlay .overlay-circuit-wires {
stroke: hsl(180, 100%, 50%);
stroke-linecap: square; /* so a corner meets nicely in the middle of a tile */
}
svg.level-editor-overlay .overlay-circuit-tunnels {
stroke: hsl(180, 100%, 50%);
stroke-dasharray: 0.0625, 0.0625;
}
svg.level-editor-overlay circle.overlay-circuit-input {
stroke: hsl(180, 100%, 50%);
fill: hsl(180, 100%, 10%);
}
svg.level-editor-overlay circle.overlay-circuit-output {
stroke: hsl(180, 100%, 50%);
fill: hsl(180, 100%, 50%);
}
svg.level-editor-overlay .overlay-adjust-cursor {
/* shared between rotate+adjust tools, though they use different elements/shapes */
stroke: #444;
fill: #fff4;
}
svg.level-editor-overlay .overlay-adjust-cursor[data-layer=terrain] {
stroke: hsl(150deg, 80%, 20%, 0.8);
fill: hsl(150deg, 80%, 60%, 0.4);
}
svg.level-editor-overlay .overlay-adjust-cursor[data-layer=item] {
stroke: hsl(50deg, 80%, 20%, 0.8);
fill: hsl(50deg, 80%, 60%, 0.4);
}
svg.level-editor-overlay .overlay-adjust-cursor[data-layer=item-mod] {
}
svg.level-editor-overlay .overlay-adjust-cursor[data-layer=actor] {
stroke: hsl(215deg, 80%, 20%, 0.8);
fill: hsl(215deg, 80%, 60%, 0.4);
}
svg.level-editor-overlay .overlay-adjust-cursor[data-layer=swivel] {
}
svg.level-editor-overlay .overlay-adjust-cursor[data-layer=thin-wall] {
stroke: hsl(330deg, 80%, 20%, 0.8);
fill: hsl(330deg, 80%, 60%, 0.4);
}
svg.level-editor-overlay .overlay-adjust-gray-button-radius {
stroke: #f4f4f4;
fill: hsla(10, 10%, 80%, 0.125);
}
svg.level-editor-overlay .overlay-adjust-gray-button-shroud {
stroke: none;
fill: hsla(10, 10%, 30%, 0.6);
}
svg.level-editor-overlay rect.overlay-camera {
stroke: #808080;
fill: #80808040;
@ -2200,8 +2396,24 @@ svg.level-editor-overlay text {
font-size: 1px;
}
svg.level-editor-overlay text.overlay-edit-tip {
/* Used for showing e.g. the size of a pending selection. Centered around its anchor */
stroke: none;
fill: black;
fill: hsl(var(--selected-hue), 80%, 30%);
text-anchor: middle;
dominant-baseline: middle;
}
svg.level-editor-overlay text.overlay-adjust-hint {
font-size: calc(0.5px / var(--scale));
font-weight: bold;
stroke: black;
fill: white;
paint-order: stroke;
text-anchor: middle;
dominant-baseline: auto;
}
svg.level-editor-overlay .overlay-text-cursor {
stroke: hsla(50, 90%, 60%, 0.75);
fill: hsla(50, 90%, 60%, 0.25);
}
.editor-big-tooltip {
@ -2224,7 +2436,7 @@ svg.level-editor-overlay text.overlay-edit-tip {
text-transform: none;
text-align: left;
color: #d8d8d8;
background: hsl(220, 10%, 20%);
background: hsl(var(--main-hue), 10%, 20%);
box-shadow: 0 1px 2px 1px #0004;
}
.editor-big-tooltip h3 {
@ -2233,6 +2445,28 @@ svg.level-editor-overlay text.overlay-edit-tip {
border-bottom: 1px solid currentColor;
color: white;
}
.editor-big-tooltip kbd {
font-size: 0.75em;
display: inline-block;
margin-right: 0.25rem;
padding: 1px 2px;
border: 1px solid #d8d8d8;
border-bottom-width: 2px;
border-radius: 2px;
line-height: 1;
vertical-align: 0.25em;
background: #d8d8d8;
box-shadow: 0 2px #999;
color: hsl(var(--main-hue), 10%, 20%);
letter-spacing: -1px;
text-transform: lowercase;
}
.editor-big-tooltip svg {
width: 1.5em;
height: 1.5em;
margin: 0 -0.25em; /* these are mouse buttons; shave off some of the extra space */
vertical-align: -0.375em;
}
#editor .controls {
/* TODO with the hint area gone i don't think this needs to be a grid? could just flex */
grid-area: controls;
@ -2269,9 +2503,40 @@ svg.level-editor-overlay text.overlay-edit-tip {
transition-delay: 0.5s;
transition-timing-function: ease-in;
}
#editor .controls #editor-layer-selector {
display: grid;
grid:
"icon header" auto
"icon name" 1fr
/ auto 6em
;
align-items: center;
gap: 0 0.5em;
line-height: 1;
user-select: none;
}
#editor .controls #editor-layer-selector > img {
grid-area: icon;
}
#editor .controls #editor-layer-selector > h3 {
grid-area: header;
margin: 0;
font-size: 0.75em;
font-weight: normal;
color: #606060;
}
#editor .controls #editor-layer-selector > output {
grid-area: name;
}
#editor .controls .-buttons {
grid-area: menu;
}
#editor .controls .-toolbar-section {
padding: 2px;
border-radius: 4px;
background: hsl(var(--selected-hue), 10%, 10%);
border: 1px solid hsl(var(--selected-hue), 10%, 20%);
}
.icon-button-set {
display: flex;
flex-wrap: wrap;
@ -2282,13 +2547,16 @@ svg.level-editor-overlay text.overlay-edit-tip {
padding: 0;
margin: 0;
line-height: 1;
background: url(icons/tool-bg-unselected.png) no-repeat;
background: none;
border: none;
border-radius: 0;
border-radius: 2px;
box-shadow: none;
}
.icon-button-set button:hover {
background: hsl(var(--hover-hue), 50%, 40%);
}
.icon-button-set button.-selected {
background-image: url(icons/tool-bg-selected.png);
background: hsl(var(--selected-hue), 90%, 75%);
}
.icon-button-set button img {
display: block;
@ -2322,7 +2590,7 @@ svg.level-editor-overlay text.overlay-edit-tip {
.palette-entry {
}
.palette-entry:hover {
box-shadow: 0 0 0 1px black, 0 0 0 3px hsl(220, 100%, 75%);
box-shadow: 0 0 0 1px black, 0 0 0 3px hsl(var(--main-hue), 100%, 75%);
}
.palette-entry.--selected {
z-index: 1;
@ -2488,8 +2756,8 @@ ol.editor-letter-tile-picker input[type=radio] {
display: none;
}
ol.editor-letter-tile-picker input[type=radio]:checked + .-glyph {
background: hsl(220, 75%, 90%);
outline: 2px solid hsl(220, 75%, 80%);
background: hsl(var(--main-hue), 75%, 90%);
outline: 2px solid hsl(var(--main-hue), 75%, 80%);
}
/* Hint tiles accept prose */
textarea.editor-hint-tile-text {
@ -2514,7 +2782,7 @@ textarea.editor-hint-tile-text {
stroke-width: 2;
}
.editor-tile-editor-svg-parts input:checked + svg {
stroke: hsl(220, 90%, 50%);
stroke: hsl(var(--main-hue), 90%, 50%);
}
/* Directional blocks have arrows */
ol.editor-directional-block-tile-arrows {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.